feat(DRW-001) initial commit!
CI/CD / Build (push) Successful in 5m59s
CI/CD / Frontend Build (push) Successful in 1m27s
CI/CD / Unit Tests (push) Failing after 1m12s
CI/CD / Docker (push) Skipped
CI/CD / Frontend Docker (push) Failing after 3m29s

This commit is contained in:
kwiialyssa
2026-09-13 20:37:48 +03:00
commit edb4cd8613
59 changed files with 8669 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
name: CI/CD
on:
push:
branches:
- '**'
env:
REGISTRY: gitea.kawaii-alyssa.dev
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Cache Rust build
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: rust-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: rust-${{ runner.os }}-
- name: Build
run: cargo build --release --locked
- name: Run clippy
run: cargo clippy --release --all-targets --locked -- -D warnings
test:
name: Unit Tests
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Cache Rust build
uses: actions/cache@v3
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: rust-${{ runner.os }}-${{ hashFiles('Cargo.lock') }}
restore-keys: rust-${{ runner.os }}-
- name: Run tests
run: cargo test --release --locked
docker:
name: Docker
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Compute image metadata
id: meta
run: |
IMAGE="${{ env.REGISTRY }}/${{ gitea.repository }}"
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
SHORT_SHA=$(echo "${{ gitea.sha }}" | cut -c1-7)
if [[ "${{ gitea.ref }}" == refs/heads/release/* ]]; then
VERSION=$(echo "${{ gitea.ref_name }}" | sed 's|release/||')
TAG="${VERSION}"
TAGS="${IMAGE}:${TAG}
${IMAGE}:latest"
else
BRANCH=$(echo "${{ gitea.ref_name }}" | tr '/' '-')
TAG="${BRANCH}-${SHORT_SHA}"
TAGS="${IMAGE}:${TAG}"
fi
echo "image=${IMAGE}" >> $GITHUB_OUTPUT
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Image: ${IMAGE}:${TAG}"
- name: Log in to Gitea container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Set up Docker Build
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
frontend-build:
name: Frontend Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Type-check and build
working-directory: frontend
run: npm run build
frontend-docker:
name: Frontend Docker
needs: frontend-build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Compute image metadata
id: meta
run: |
IMAGE="${{ env.REGISTRY }}/${{ gitea.repository }}-frontend"
IMAGE=$(echo "$IMAGE" | tr '[:upper:]' '[:lower:]')
SHORT_SHA=$(echo "${{ gitea.sha }}" | cut -c1-7)
if [[ "${{ gitea.ref }}" == refs/heads/release/* ]]; then
VERSION=$(echo "${{ gitea.ref_name }}" | sed 's|release/||')
TAG="${VERSION}"
TAGS="${IMAGE}:${TAG}
${IMAGE}:latest"
else
BRANCH=$(echo "${{ gitea.ref_name }}" | tr '/' '-')
TAG="${BRANCH}-${SHORT_SHA}"
TAGS="${IMAGE}:${TAG}"
fi
echo "image=${IMAGE}" >> $GITHUB_OUTPUT
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
echo "Image: ${IMAGE}:${TAG}"
- name: Log in to Gitea container registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Set up Docker Build
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Build and push
uses: docker/build-push-action@v6
with:
context: ./frontend
push: true
tags: ${{ steps.meta.outputs.tags }}
#================================================================
# Deploy is intentionally omitted for now -- this workflow only
# builds, tests, and pushes the images.
#================================================================
+3
View File
@@ -0,0 +1,3 @@
/target
/.claude
.env
Generated
+1773
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "dota-random-web"
version = "1.0.0"
edition = "2024"
[dependencies]
axum = "0.8.9"
dotenvy = "0.15.7"
rand = "0.10.2"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
sqlx = { version = "0.9.0", features = ["runtime-tokio", "postgres", "macros", "migrate"] }
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
uuid = { version = "1.26.1", features = ["v4", "serde"] }
+20
View File
@@ -0,0 +1,20 @@
FROM rust:1-slim-bookworm AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
COPY migrations ./migrations
RUN cargo build --release --locked
FROM debian:bookworm-slim
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/dota-random-web ./dota-random-web
EXPOSE 8080
CMD ["./dota-random-web"]
+106
View File
@@ -0,0 +1,106 @@
# 🎲 Dota Random
> "I-it's not like I made a whole randomizer just so YOU could have chaotic fun in your games or anything... b-baka."
Fine, FINE. Since you're making me explain it: this is a Dota 2 hero + item build randomizer. You pick how much chaos you want — scope it to melee heroes, lock item tags, disable half the randomness if you're a coward — and it rolls you a hero, a full item build (slot 1 is *always* boots, don't argue with me about it), neutral item candidates per stage, enchantments, and tells you when you're allowed to buy your Aghanim's. Not that I care if you actually use it properly.
It's not a toy. ...Okay it's kind of a toy. Shut up.
## What it actually does
- **Hero scope** — filter by attack type and/or primary attribute, or leave it wide open if you're feeling brave
- **Item scope** — restrict rolls to items carrying specific purpose tags (Physical Damage, Disable, Sustain, etc.)
- **Slot 1 is always boots** — every existing boots item in the game is eligible, including separate grades like Boots of Travel / Boots of Travel 2 and Dagon's five levels, each rolled as its own distinct outcome
- **Neutral items, honestly modeled** — each active tier suggests 2 real candidates, because the app can't actually control what drops in your match. You click whichever one you got, or browse the full tier if neither matches
- **Enchantments, guaranteed** — no RNG-within-RNG nonsense here. If enchantment rolling is on, you get one, scoped correctly to your hero's primary attribute (including the "works on literally any hero" ones — yes, those exist, no I didn't forget about them)
- **Aghanim's Scepter & Shard roll as normal items now** — no special-cased fake probability. You also get to say whether you're "allowed" to buy them mid-build or only after your 6 slots are full, for whenever they don't get rolled naturally
- **Reroll anything** — hero, a single item slot, a specific neutral tier, a specific enchantment tier. Rerolling your hero also refreshes your neutral items and enchantments, since a new hero deserves a fresh itemization pass, o-obviously
- **Manual override, always available** — disable randomness for any slot/dimension and pick it yourself instead
## Stack
Not that you need to know this to use it, but since you're being nosy:
| Layer | Tech |
|---|---|
| Backend | Rust, [axum](https://github.com/tokio-rs/axum), [sqlx](https://github.com/launchbadge/sqlx) (Postgres) |
| Frontend | React, TypeScript, Vite, Tailwind CSS |
| Database | PostgreSQL |
| Data source | Scraped live from [Liquipedia](https://liquipedia.net/dota2/) via Python scripts (no hardcoded/stale game data, thank you very much) |
| Deployment | Docker Compose + a Gitea Actions CI/CD pipeline |
## Getting it running (pay attention, I'm not repeating myself)
### 1. Database
Spin up a Postgres instance however you like — there's a `docker-compose.yml` in here that already knows how to do it. Then fill in `.env` at the project root:
```
DATABASE_URL=postgres://user:password@host:port/dbname
NEUTRAL_TIER_GATE_CHANCE=0.5 # optional, defaults to 0.5 -- the odds of the "can I even change my neutral item this tier" gate
```
Migrations run automatically on startup (`sqlx::migrate!()` — it's embedded in the binary, you don't need to run anything by hand). But the tables start **empty** — the actual hero/item/enchantment data isn't hardcoded anywhere, it's scraped:
```bash
pip install requests beautifulsoup4
# edit USER_AGENT at the top of each script first -- Liquipedia's API
# terms require a real, identifying User-Agent, not a generic one
python scripts/scrape_heroes.py
python scripts/scrape_items.py
sqlx migrate run
```
Yes, this means the roster stays current with whatever patch Liquipedia reflects. Rerun the scripts whenever you want fresh data. I'm not doing that part for you.
### 2. Backend
```bash
cargo run
```
Serves the API on `:8080`.
### 3. Frontend
```bash
cd frontend
npm install
npm run dev
```
Runs on `:5173` with a dev proxy to the backend, so it just works. You're welcome.
### 4. Or just use Docker Compose
```bash
docker compose up
```
...if the images are actually built and pushed, which they will be, because there's a whole CI pipeline for it. I didn't half-ass this.
## Project layout, since you'll get lost otherwise
```
src/ Rust backend (axum handlers, roll logic, models)
migrations/ sqlx migrations (schema + scraped seed data)
scripts/ Python scrapers (Liquipedia -> migration files)
frontend/ React + TypeScript SPA
.gitea/ CI/CD workflow
```
## A note on the data
Heroes, items, and enchantments are scraped from Liquipedia's Dota 2 wiki, not hand-typed or pulled from memory — patches change too fast for that to stay accurate, and I refuse to ship stale data. The scrapers check each item's actual current status (active / removed / unreleased / hidden) rather than trusting whatever list happens to mention it, because apparently even wikis get this wrong sometimes.
## Disclaimer
Part of this project — the whole frontend, a chunk of the Rust backend, the data scrapers, the CI/CD setup — was written with AI assistance. It's not like every line was hand-crafted by a lone genius at 3am. Mostly. I just don't like writing boilerplate code or code I already know how to write! Baka!
## Author
**ItzYumeii**
...I guess if you actually enjoy using this, that's... fine. Not that it matters to me or anything.
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+14
View File
@@ -0,0 +1,14 @@
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dota Random</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Same-origin API calls from the SPA get forwarded to the backend
# container by its compose service name -- the frontend never needs
# to know a real hostname/port for the API, and there's no CORS to deal with.
location /api/ {
proxy_pass http://dota-random-web:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
try_files $uri /index.html;
}
}
+1942
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<defs>
<linearGradient id="dice-fire" x1="10" y1="40" x2="38" y2="8" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#6b0f0f"/>
<stop offset="0.5" stop-color="#e2321a"/>
<stop offset="0.8" stop-color="#ff8a1e"/>
<stop offset="1" stop-color="#ffcf4d"/>
</linearGradient>
<radialGradient id="dice-glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24 24) rotate(90) scale(23)">
<stop offset="0.6" stop-color="#ff5a1f" stop-opacity="0.55"/>
<stop offset="1" stop-color="#ff5a1f" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="24" cy="24" r="23" fill="url(#dice-glow)"/>
<g transform="rotate(-8 24 24)">
<rect x="8" y="8" width="32" height="32" rx="8" fill="url(#dice-fire)" stroke="#4a0808" stroke-width="1.5"/>
<circle cx="16.5" cy="16.5" r="2.9" fill="#fff3d6"/>
<circle cx="31.5" cy="16.5" r="2.9" fill="#fff3d6"/>
<circle cx="24" cy="24" r="2.9" fill="#fff3d6"/>
<circle cx="16.5" cy="31.5" r="2.9" fill="#fff3d6"/>
<circle cx="31.5" cy="31.5" r="2.9" fill="#fff3d6"/>
</g>
<path d="M37 6c1.8 2 1.6 3.7.2 5.1-1 1-.7 2.6.8 2.6 2.6 0 4.7-2.1 4.7-4.7 0-2.9-2.7-4.2-5.7-3z" fill="#ffb13c"/>
<path d="M41.5 13.2c1 1 .9 2-.1 2.9-.6.6-.4 1.5.5 1.5 1.5 0 2.7-1.2 2.7-2.7 0-1.6-1.6-2.4-3.1-1.7z" fill="#ff7a1a"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import {
generateBuild,
getTags,
rerollEnchantmentStage,
rerollHero,
rerollItemSlot,
rerollNeutralStage,
setEnchantmentStageManually,
setItemSlotManually,
setNeutralStageManually,
} from "./api";
import { BuildResult } from "./components/BuildResult";
import { ConfigPanel } from "./components/ConfigPanel";
import { Spinner } from "./components/ui";
import type { Build, BuildConfig, Tag } from "./types";
const DEFAULT_CONFIG: BuildConfig = {
attackTypes: [],
attributes: [],
itemTags: [],
disableNeutralArtifactRandomness: false,
disableEnchantmentRandomness: false,
disableAllNeutralRolls: false,
disabledItemSlots: [],
disableAghanimScepterRandomness: false,
disableAghanimShardRandomness: false,
neutralTierRerollEnabled: true,
aghanimScepterTiming: "anytime",
aghanimShardTiming: "anytime",
};
export default function App() {
const [tags, setTags] = useState<Tag[]>([]);
const [config, setConfig] = useState<BuildConfig>(DEFAULT_CONFIG);
const [build, setBuild] = useState<Build | null>(null);
const [generating, setGenerating] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
getTags().then(setTags);
}, []);
function updateConfig(patch: Partial<BuildConfig>) {
setConfig((prev) => ({ ...prev, ...patch }));
}
async function handleGenerate() {
setGenerating(true);
try {
setBuild(await generateBuild(config));
} finally {
setGenerating(false);
}
}
async function withBusy(fn: () => Promise<Build>) {
setBusy(true);
try {
setBuild(await fn());
} finally {
setBusy(false);
}
}
return (
<div className="mx-auto flex min-h-screen max-w-6xl flex-col gap-6 p-6">
<header className="flex items-center gap-3">
<img src="/favicon.svg" alt="" className="h-10 w-10" />
<div>
<h1 className="text-2xl font-bold text-neutral-100">Dota Random</h1>
<p className="text-sm text-neutral-500">Randomized hero + item builds, with full control over what gets rolled.</p>
</div>
</header>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[320px_1fr]">
<ConfigPanel config={config} onChange={updateConfig} tags={tags} onGenerate={handleGenerate} generating={generating} />
<div>
{!build && !generating && (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-neutral-800 text-neutral-600">
Configure your scope, then generate a build.
</div>
)}
{generating && !build && (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-neutral-800">
<Spinner />
</div>
)}
{build && (
<BuildResult
build={build}
busy={busy}
onRerollHero={() => withBusy(() => rerollHero(build.id))}
onRerollItemSlot={(slot) => withBusy(() => rerollItemSlot(build.id, slot))}
onRerollNeutral={(tier) => withBusy(() => rerollNeutralStage(build.id, tier))}
onRerollEnchantment={(tier) => withBusy(() => rerollEnchantmentStage(build.id, tier))}
onManualPickItemSlot={(slot, itemId) => withBusy(() => setItemSlotManually(build.id, slot, itemId))}
onManualPickNeutral={(tier, itemId) => withBusy(() => setNeutralStageManually(build.id, tier, itemId))}
onManualPickEnchantment={(tier, enchantmentId) =>
withBusy(() => setEnchantmentStageManually(build.id, tier, enchantmentId))
}
/>
)}
</div>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import type { Build, BuildConfig, Enchantment, Item, PrimaryAttribute, Tag } from "./types";
/**
* Thin fetch wrappers around the real backend. In production nginx proxies
* `/api/*` to the backend container (see frontend/nginx.conf); in dev, Vite's
* server.proxy does the same thing against a locally-running backend (see
* vite.config.ts) -- either way these calls are same-origin, so there's no
* CORS to deal with here.
*/
const API_BASE = "/api";
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error ?? `Request to ${path} failed with status ${res.status}`);
}
return res.json() as Promise<T>;
}
function tagsQueryParam(tags: string[]): string {
return tags.length ? `?tags=${encodeURIComponent(tags.join(","))}` : "";
}
// GET /api/tags
export function getTags(): Promise<Tag[]> {
return apiFetch<Tag[]>("/tags");
}
// POST /api/builds
export function generateBuild(config: BuildConfig): Promise<Build> {
return apiFetch<Build>("/builds", { method: "POST", body: JSON.stringify(config) });
}
// POST /api/builds/{buildId}/reroll/hero
export function rerollHero(buildId: string): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/reroll/hero`, { method: "POST" });
}
// POST /api/builds/{buildId}/reroll/item-slot/{slot}
export function rerollItemSlot(buildId: string, slot: number): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/reroll/item-slot/${slot}`, { method: "POST" });
}
// POST /api/builds/{buildId}/reroll/neutral/{tier}
export function rerollNeutralStage(buildId: string, tier: number): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/reroll/neutral/${tier}`, { method: "POST" });
}
// POST /api/builds/{buildId}/reroll/enchantment/{tier}
export function rerollEnchantmentStage(buildId: string, tier: number): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/reroll/enchantment/${tier}`, { method: "POST" });
}
// GET /api/items?tags=&bootsOnly=
export function getManualItemOptions(tags: string[], bootsOnly = false): Promise<Item[]> {
const params = bootsOnly ? "?bootsOnly=true" : tagsQueryParam(tags);
return apiFetch<Item[]>(`/items${params}`);
}
// GET /api/neutral-items?tier=
export function getManualNeutralOptions(tier: number): Promise<Item[]> {
return apiFetch<Item[]>(`/neutral-items?tier=${tier}`);
}
// GET /api/enchantments?tier=&primaryAttribute=
export function getManualEnchantmentOptions(tier: number, attribute: PrimaryAttribute): Promise<Enchantment[]> {
return apiFetch<Enchantment[]>(`/enchantments?tier=${tier}&primaryAttribute=${attribute}`);
}
// PATCH /api/builds/{buildId}/item-slot/{slot}
export function setItemSlotManually(buildId: string, slot: number, itemId: number): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/item-slot/${slot}`, {
method: "PATCH",
body: JSON.stringify({ itemId }),
});
}
// PATCH /api/builds/{buildId}/neutral/{tier}
export function setNeutralStageManually(buildId: string, tier: number, itemId: number): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/neutral/${tier}`, {
method: "PATCH",
body: JSON.stringify({ itemId }),
});
}
// PATCH /api/builds/{buildId}/enchantment/{tier}
export function setEnchantmentStageManually(buildId: string, tier: number, enchantmentId: number): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/enchantment/${tier}`, {
method: "PATCH",
body: JSON.stringify({ enchantmentId }),
});
}
+338
View File
@@ -0,0 +1,338 @@
import { useState } from "react";
import { getManualEnchantmentOptions, getManualItemOptions, getManualNeutralOptions } from "../api";
import type { Build, Item } from "../types";
import { ManualPicker } from "./ManualPicker";
import { Button } from "./ui";
type PickerTarget =
| { kind: "item-slot"; slot: number }
| { kind: "neutral"; tier: number }
| { kind: "enchantment"; tier: number };
export function BuildResult({
build,
busy,
onRerollHero,
onRerollItemSlot,
onRerollNeutral,
onRerollEnchantment,
onManualPickItemSlot,
onManualPickNeutral,
onManualPickEnchantment,
}: {
build: Build;
busy: boolean;
onRerollHero: () => void;
onRerollItemSlot: (slot: number) => void;
onRerollNeutral: (tier: number) => void;
onRerollEnchantment: (tier: number) => void;
onManualPickItemSlot: (slot: number, itemId: number) => void;
onManualPickNeutral: (tier: number, itemId: number) => void;
onManualPickEnchantment: (tier: number, enchantmentId: number) => void;
}) {
const [picker, setPicker] = useState<PickerTarget | null>(null);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
<div className="flex items-center gap-3">
<img
src={build.hero.imageUrl}
alt={build.hero.name}
referrerPolicy="no-referrer"
className="h-12 w-20 rounded object-cover"
/>
<div>
<p className="text-base font-semibold text-neutral-100">{build.hero.name}</p>
<p className="text-xs capitalize text-neutral-500">
{build.hero.primaryAttribute} · {build.hero.attackType}
</p>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<Button variant="ghost" small onClick={onRerollHero} disabled={busy}>
Reroll hero
</Button>
<p className="text-[10px] text-neutral-600">Also re-rolls neutral items and enchantments.</p>
</div>
</div>
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-400">Item build</h3>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{build.itemSlots.map((slot) => (
<div key={slot.slot} className="flex flex-col items-center gap-1 rounded-md border border-neutral-800 bg-neutral-800/40 p-2">
<span className="text-[10px] text-neutral-500">Slot {slot.slot}{slot.slot === 1 ? " (Boots)" : ""}</span>
{slot.item ? (
<img
src={slot.item.imageUrl}
alt={slot.item.name}
title={slot.item.name}
referrerPolicy="no-referrer"
className="h-10 w-16 rounded object-cover"
/>
) : (
<div className="flex h-10 w-16 items-center justify-center rounded bg-neutral-800 text-[10px] text-neutral-600">empty</div>
)}
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">{slot.item?.name ?? "manual"}</span>
{slot.isManual ? (
<Button small variant="ghost" onClick={() => setPicker({ kind: "item-slot", slot: slot.slot })} disabled={busy}>
Pick
</Button>
) : (
<Button small variant="ghost" onClick={() => onRerollItemSlot(slot.slot)} disabled={busy}>
Reroll
</Button>
)}
</div>
))}
</div>
<AghanimRow build={build} />
</div>
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-400">Neutral items by stage</h3>
<p className="mb-2 text-xs text-neutral-600">
Each tier suggests up to 2 candidates -- click whichever one actually dropped in your game, or browse all.
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{build.neutralItems.map((stage) => (
<NeutralStageCard
key={stage.tier}
tier={stage.tier}
resolved={stage.resolved}
attempts={stage.attempts}
requiresManualPick={stage.requiresManualPick}
tierGateWon={stage.tierGateWon}
busy={busy}
onReroll={() => onRerollNeutral(stage.tier)}
onQuickPick={(itemId) => onManualPickNeutral(stage.tier, itemId)}
onBrowseAll={() => setPicker({ kind: "neutral", tier: stage.tier })}
/>
))}
</div>
</div>
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-400">Enchantments by stage</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{build.enchantments.map((stage) => (
<EnchantmentStageCard
key={stage.tier}
tier={stage.tier}
resolvedName={stage.resolved?.name}
resolvedImage={stage.resolved?.imageUrl}
requiresManualPick={stage.requiresManualPick}
busy={busy}
onReroll={() => onRerollEnchantment(stage.tier)}
onManualPick={() => setPicker({ kind: "enchantment", tier: stage.tier })}
/>
))}
</div>
<p className="mt-2 text-xs text-neutral-600">
Guaranteed once rolling is on -- scoped to {build.hero.name}'s primary attribute ({build.hero.primaryAttribute}).
</p>
</div>
{picker?.kind === "item-slot" && (
<ManualPicker
title={`Pick an item for slot ${picker.slot}${picker.slot === 1 ? " (Boots)" : ""}`}
loadOptions={() => getManualItemOptions(build.config.itemTags, picker.slot === 1)}
onPick={(id) => {
onManualPickItemSlot(picker.slot, id);
setPicker(null);
}}
onClose={() => setPicker(null)}
/>
)}
{picker?.kind === "neutral" && (
<ManualPicker
title={`Pick a tier ${picker.tier} neutral item`}
loadOptions={() => getManualNeutralOptions(picker.tier)}
onPick={(id) => {
onManualPickNeutral(picker.tier, id);
setPicker(null);
}}
onClose={() => setPicker(null)}
/>
)}
{picker?.kind === "enchantment" && (
<ManualPicker
title={`Pick a tier ${picker.tier} enchantment`}
loadOptions={() => getManualEnchantmentOptions(picker.tier, build.hero.primaryAttribute)}
onPick={(id) => {
onManualPickEnchantment(picker.tier, id);
setPicker(null);
}}
onClose={() => setPicker(null)}
/>
)}
</div>
);
}
/** Neutral artifacts: up to 2 rolled candidates, always a manual confirm
* (the app can't know what actually dropped in-game), plus a full browse
* fallback and a reroll for a fresh pair of candidates. */
function NeutralStageCard({
tier,
resolved,
attempts,
requiresManualPick,
tierGateWon,
busy,
onReroll,
onQuickPick,
onBrowseAll,
}: {
tier: number;
resolved?: Item | null;
attempts: Item[];
requiresManualPick: boolean;
tierGateWon?: boolean;
busy: boolean;
onReroll: () => void;
onQuickPick: (itemId: number) => void;
onBrowseAll: () => void;
}) {
const noCandidates = requiresManualPick && attempts.length === 0 && !resolved;
return (
<div className="flex flex-col items-center gap-1 rounded-md border border-neutral-800 bg-neutral-800/40 p-2">
<span className="text-[10px] text-neutral-500">Tier {tier}</span>
{tierGateWon === false && <span className="text-[10px] text-amber-500">kept from last tier</span>}
{resolved ? (
<>
<img
src={resolved.imageUrl}
alt={resolved.name}
title={resolved.name}
referrerPolicy="no-referrer"
className="h-10 w-16 rounded object-cover"
/>
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">{resolved.name}</span>
<Button small variant="ghost" onClick={onReroll} disabled={busy}>
Reroll
</Button>
</>
) : attempts.length > 0 ? (
<>
<div className="flex gap-1">
{attempts.map((item) => (
<button
key={item.id}
title={`Got ${item.name}?`}
onClick={() => onQuickPick(item.id)}
disabled={busy}
className="rounded border border-neutral-700 hover:border-emerald-600 disabled:opacity-50"
>
<img
src={item.imageUrl}
alt={item.name}
referrerPolicy="no-referrer"
className="h-10 w-8 rounded object-cover"
/>
</button>
))}
</div>
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">click which you got</span>
<div className="flex gap-1">
<Button small variant="ghost" onClick={onReroll} disabled={busy}>
Reroll
</Button>
<Button small variant="ghost" onClick={onBrowseAll} disabled={busy}>
Other
</Button>
</div>
</>
) : (
<>
<div className="flex h-10 w-16 items-center justify-center rounded bg-neutral-800 text-[10px] text-neutral-600">
pick one
</div>
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">{noCandidates ? "disabled" : "no candidates"}</span>
<Button small variant="ghost" onClick={onBrowseAll} disabled={busy}>
Pick
</Button>
</>
)}
</div>
);
}
/** Enchantments: guaranteed once rolling is on for this stage -- the only
* choice is whether to roll at all, so this stays a simple resolved/pick binary. */
function EnchantmentStageCard({
tier,
resolvedName,
resolvedImage,
requiresManualPick,
busy,
onReroll,
onManualPick,
}: {
tier: number;
resolvedName?: string;
resolvedImage?: string;
requiresManualPick: boolean;
busy: boolean;
onReroll: () => void;
onManualPick: () => void;
}) {
return (
<div className="flex flex-col items-center gap-1 rounded-md border border-neutral-800 bg-neutral-800/40 p-2">
<span className="text-[10px] text-neutral-500">Tier {tier}</span>
{resolvedImage ? (
<img
src={resolvedImage}
alt={resolvedName}
title={resolvedName}
referrerPolicy="no-referrer"
className="h-10 w-16 rounded object-cover"
/>
) : (
<div className="flex h-10 w-16 items-center justify-center rounded bg-neutral-800 text-[10px] text-neutral-600">
{requiresManualPick ? "pick one" : "—"}
</div>
)}
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">{resolvedName ?? (requiresManualPick ? "disabled" : "—")}</span>
{requiresManualPick ? (
<Button small variant="ghost" onClick={onManualPick} disabled={busy}>
Pick
</Button>
) : (
<Button small variant="ghost" onClick={onReroll} disabled={busy}>
Reroll
</Button>
)}
</div>
);
}
function AghanimRow({ build }: { build: Build }) {
return (
<div className="mt-3 flex flex-wrap gap-3 border-t border-neutral-800 pt-3">
{(["aghanimScepter", "aghanimShard"] as const).map((key) => {
const result = build[key];
const label = key === "aghanimScepter" ? "Aghanim's Scepter" : "Aghanim's Shard";
return (
<div key={key} className="flex items-center gap-2 rounded-md border border-neutral-800 bg-neutral-800/40 px-3 py-1.5">
{result.item && (
<img
src={result.item.imageUrl}
alt={label}
referrerPolicy="no-referrer"
className="h-6 w-10 rounded object-cover"
/>
)}
<span className="text-xs text-neutral-300">
{label}: {result.included ? "in build" : "not in this build"} ·{" "}
{result.timing === "anytime" ? "purchasable anytime" : "buy only after your 6 slots are full"}
</span>
</div>
);
})}
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
import type { AghanimTiming, AttackType, BuildConfig, PrimaryAttribute, Tag } from "../types";
import { Button, PillButton, Section, Toggle } from "./ui";
const ATTACK_TYPES: AttackType[] = ["melee", "ranged"];
const ATTRIBUTES: PrimaryAttribute[] = ["strength", "agility", "intelligence", "universal"];
const SLOTS = [1, 2, 3, 4, 5, 6];
function toggleInArray<T>(arr: T[], value: T): T[] {
return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value];
}
export function ConfigPanel({
config,
onChange,
tags,
onGenerate,
generating,
}: {
config: BuildConfig;
onChange: (patch: Partial<BuildConfig>) => void;
tags: Tag[];
onGenerate: () => void;
generating: boolean;
}) {
return (
<div className="flex flex-col gap-4">
<Section title="Hero scope">
<div className="mb-3">
<p className="mb-1.5 text-xs text-neutral-500">Attack type</p>
<div className="flex flex-wrap gap-1.5">
{ATTACK_TYPES.map((t) => (
<PillButton
key={t}
active={config.attackTypes.includes(t)}
onClick={() => onChange({ attackTypes: toggleInArray(config.attackTypes, t) })}
>
{t}
</PillButton>
))}
</div>
</div>
<div>
<p className="mb-1.5 text-xs text-neutral-500">Primary attribute</p>
<div className="flex flex-wrap gap-1.5">
{ATTRIBUTES.map((a) => (
<PillButton
key={a}
active={config.attributes.includes(a)}
onClick={() => onChange({ attributes: toggleInArray(config.attributes, a) })}
>
{a}
</PillButton>
))}
</div>
</div>
<p className="mt-2 text-xs text-neutral-600">No selection = any hero is eligible.</p>
</Section>
<Section title="Item scope">
<p className="mb-1.5 text-xs text-neutral-500">Only roll items carrying at least one of these tags</p>
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<PillButton
key={tag.id}
active={config.itemTags.includes(tag.name)}
onClick={() => onChange({ itemTags: toggleInArray(config.itemTags, tag.name) })}
>
{tag.name}
</PillButton>
))}
</div>
<p className="mt-2 text-xs text-neutral-600">No selection = any tag is eligible.</p>
</Section>
<Section title="Item slots">
<p className="mb-1.5 text-xs text-neutral-500">Click a slot to exclude it from randomness (you'll pick it yourself)</p>
<div className="flex flex-wrap gap-1.5">
{SLOTS.map((slot) => (
<PillButton
key={slot}
active={!config.disabledItemSlots.includes(slot)}
onClick={() => onChange({ disabledItemSlots: toggleInArray(config.disabledItemSlots, slot) })}
>
Slot {slot}
</PillButton>
))}
</div>
</Section>
<Section title="Neutral items">
<Toggle
label="Randomize neutral artifacts"
hint="Off = every tier is left for manual pick (1.4.1 / 1.4.3)"
checked={!config.disableAllNeutralRolls && !config.disableNeutralArtifactRandomness}
onChange={(checked) => onChange({ disableNeutralArtifactRandomness: !checked })}
/>
<Toggle
label="Randomize enchantments"
hint="Off = every tier is left for manual pick (1.4.2 / 1.4.3)"
checked={!config.disableAllNeutralRolls && !config.disableEnchantmentRandomness}
onChange={(checked) => onChange({ disableEnchantmentRandomness: !checked })}
/>
<Toggle
label="Disable neutral rolls entirely"
hint="Overrides both toggles above (1.4.3)"
checked={config.disableAllNeutralRolls}
onChange={(checked) => onChange({ disableAllNeutralRolls: checked })}
/>
<Toggle
label="Guaranteed roll every tier"
hint="Off = 50/50 gate per tier decides if you even get to roll (1.4.6)"
checked={config.neutralTierRerollEnabled}
onChange={(checked) => onChange({ neutralTierRerollEnabled: checked })}
/>
</Section>
<Section title="Aghanim's items">
<Toggle
label="Randomize Aghanim's Scepter"
hint="Off = assumed purchasable anytime, outside the roll (1.4.5)"
checked={!config.disableAghanimScepterRandomness}
onChange={(checked) => onChange({ disableAghanimScepterRandomness: !checked })}
/>
<Toggle
label="Randomize Aghanim's Shard"
hint="Off = assumed purchasable anytime, outside the roll (1.4.5)"
checked={!config.disableAghanimShardRandomness}
onChange={(checked) => onChange({ disableAghanimShardRandomness: !checked })}
/>
<TimingSelect
label="Scepter purchase timing"
value={config.aghanimScepterTiming}
onChange={(v) => onChange({ aghanimScepterTiming: v })}
/>
<TimingSelect
label="Shard purchase timing"
value={config.aghanimShardTiming}
onChange={(v) => onChange({ aghanimShardTiming: v })}
/>
</Section>
<Button onClick={onGenerate} disabled={generating}>
{generating ? "Rolling…" : "Generate random build"}
</Button>
</div>
);
}
function TimingSelect({
label,
value,
onChange,
}: {
label: string;
value: AghanimTiming;
onChange: (v: AghanimTiming) => void;
}) {
return (
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-sm text-neutral-300">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value as AghanimTiming)}
className="rounded-md border border-neutral-700 bg-neutral-800 px-2 py-1 text-xs text-neutral-200"
>
<option value="anytime">Anytime mid-build</option>
<option value="after_core_slots">Only after 6 slots filled</option>
</select>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import { Spinner } from "./ui";
interface PickableOption {
id: number;
name: string;
imageUrl: string;
}
export function ManualPicker({
title,
loadOptions,
onPick,
onClose,
}: {
title: string;
loadOptions: () => Promise<PickableOption[]>;
onPick: (id: number) => void;
onClose: () => void;
}) {
const [options, setOptions] = useState<PickableOption[] | null>(null);
useEffect(() => {
let cancelled = false;
loadOptions().then((opts) => {
if (!cancelled) setOptions(opts);
});
return () => {
cancelled = true;
};
}, [loadOptions]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={onClose}>
<div
className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-lg border border-neutral-700 bg-neutral-900 p-4"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-200">{title}</h3>
<button onClick={onClose} className="text-neutral-500 hover:text-neutral-300">
✕
</button>
</div>
{!options ? (
<div className="flex justify-center py-8">
<Spinner />
</div>
) : options.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-500">No eligible options for the current scope.</p>
) : (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{options.map((opt) => (
<button
key={opt.id}
onClick={() => onPick(opt.id)}
className="flex flex-col items-center gap-1 rounded-md border border-neutral-800 bg-neutral-800/60 p-2 text-center hover:border-emerald-600"
>
<img
src={opt.imageUrl}
alt={opt.name}
referrerPolicy="no-referrer"
className="h-10 w-16 rounded object-cover"
/>
<span className="text-[11px] leading-tight text-neutral-300">{opt.name}</span>
</button>
))}
</div>
)}
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import type { ReactNode } from "react";
export function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-400">{title}</h3>
{children}
</div>
);
}
export function Toggle({
checked,
onChange,
label,
hint,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
hint?: string;
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-3 py-1.5">
<span>
<span className="block text-sm text-neutral-200">{label}</span>
{hint && <span className="block text-xs text-neutral-500">{hint}</span>}
</span>
<span
onClick={() => onChange(!checked)}
className={`relative mt-0.5 h-5 w-9 shrink-0 rounded-full transition-colors ${
checked ? "bg-emerald-600" : "bg-neutral-700"
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
checked ? "translate-x-4" : "translate-x-0.5"
}`}
/>
</span>
</label>
);
}
export function PillButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
active
? "border-emerald-500 bg-emerald-500/15 text-emerald-300"
: "border-neutral-700 bg-neutral-800/60 text-neutral-400 hover:border-neutral-600"
}`}
>
{children}
</button>
);
}
export function Button({
onClick,
children,
variant = "primary",
disabled,
small,
}: {
onClick: () => void;
children: ReactNode;
variant?: "primary" | "ghost" | "danger";
disabled?: boolean;
small?: boolean;
}) {
const base = small ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
const styles = {
primary: "bg-emerald-600 hover:bg-emerald-500 text-white disabled:bg-neutral-700",
ghost: "bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border border-neutral-700",
danger: "bg-red-700 hover:bg-red-600 text-white",
} as const;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-md font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${base} ${styles[variant]}`}
>
{children}
</button>
);
}
export function Spinner({ small }: { small?: boolean }) {
return (
<span
className={`inline-block animate-spin rounded-full border-2 border-neutral-600 border-t-emerald-400 ${
small ? "h-3 w-3" : "h-5 w-5"
}`}
/>
);
}
+28
View File
@@ -0,0 +1,28 @@
@import "tailwindcss";
:root {
color-scheme: dark;
}
body {
margin: 0;
background: #0f1115;
color: #e5e7eb;
font-family: system-ui, "Segoe UI", Roboto, sans-serif;
}
#root {
min-height: 100vh;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: #14161c;
}
::-webkit-scrollbar-thumb {
background: #2e313d;
border-radius: 6px;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+94
View File
@@ -0,0 +1,94 @@
export type PrimaryAttribute = "strength" | "agility" | "intelligence" | "universal";
export type AttackType = "melee" | "ranged";
export type AghanimTiming = "anytime" | "after_core_slots";
export interface Hero {
id: number;
name: string;
primaryAttribute: PrimaryAttribute;
attackType: AttackType;
imageUrl: string;
}
export interface Tag {
id: number;
name: string;
}
export interface Item {
id: number;
name: string;
source: "shop" | "neutral";
minTier?: number;
maxTier?: number;
// Grade tracks shop items that upgrade in place rather than being bought
// fresh each time (Dagon 1-5, Boots of Travel 1-2). Null for neutral items
// and 1-1 for a normal single-grade shop item.
minGrade?: number;
maxGrade?: number;
imageUrl: string;
tags: string[];
isAghanim?: "scepter" | "shard";
}
export interface Enchantment {
id: number;
name: string;
minTier: number;
maxTier: number;
attributes: PrimaryAttribute[];
imageUrl: string;
}
/** Everything the user can configure before a build is generated. */
export interface BuildConfig {
attackTypes: AttackType[]; // empty = no restriction
attributes: PrimaryAttribute[]; // empty = no restriction
itemTags: string[]; // empty = no restriction
disableNeutralArtifactRandomness: boolean; // 1.4.1 - keep enchantment rolls
disableEnchantmentRandomness: boolean; // 1.4.2 - keep artifact rolls
disableAllNeutralRolls: boolean; // 1.4.3 - overrides the two above
disabledItemSlots: number[]; // 1.4.4 - slots (1-6) left for manual pick
disableAghanimScepterRandomness: boolean; // 1.4.5
disableAghanimShardRandomness: boolean; // 1.4.5
neutralTierRerollEnabled: boolean; // 1.4.6
aghanimScepterTiming: AghanimTiming; // 2.2.5
aghanimShardTiming: AghanimTiming; // 2.2.6
}
export interface ItemSlotResult {
slot: number; // 1-6
item: Item | null;
isManual: boolean; // true when randomness was disabled for this slot (1.4.4)
}
export interface NeutralStageResult {
tier: number;
attempts: Item[]; // up to 2 rolled candidates (2.2.3)
resolved: Item | null;
requiresManualPick: boolean;
tierGateWon?: boolean; // only meaningful when neutralTierRerollEnabled === false (1.4.6)
}
export interface EnchantmentStageResult {
tier: number;
resolved: Enchantment | null;
requiresManualPick: boolean;
}
export interface AghanimResult {
included: boolean;
item: Item | null;
timing: AghanimTiming;
}
export interface Build {
id: string;
config: BuildConfig;
hero: Hero;
itemSlots: ItemSlotResult[];
neutralItems: NeutralStageResult[];
enchantments: EnchantmentStageResult[];
aghanimScepter: AghanimResult;
aghanimShard: AghanimResult;
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
// Mirrors what nginx does in production (see frontend/nginx.conf) so the
// app talks to a real backend the same way in dev and prod.
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})
@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS heroes;
DROP TYPE IF EXISTS primary_attribute;
DROP TYPE IF EXISTS attack_type;
@@ -0,0 +1,10 @@
CREATE TYPE primary_attribute AS ENUM ('strength', 'agility', 'intelligence', 'universal');
CREATE TYPE attack_type AS ENUM ('melee', 'ranged');
CREATE TABLE heroes (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
primary_attribute primary_attribute NOT NULL,
attack_type attack_type NOT NULL,
image_url TEXT NOT NULL
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS tags;
@@ -0,0 +1,4 @@
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS items;
DROP TYPE IF EXISTS item_source;
@@ -0,0 +1,14 @@
CREATE TYPE item_source AS ENUM ('shop', 'neutral');
CREATE TABLE items (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
source item_source NOT NULL,
min_tier SMALLINT,
max_tier SMALLINT,
image_url TEXT NOT NULL,
CONSTRAINT neutral_tier_matches_source CHECK (
(source = 'neutral' AND min_tier BETWEEN 1 AND 5 AND max_tier BETWEEN 1 AND 5 AND min_tier <= max_tier)
OR (source = 'shop' AND min_tier IS NULL AND max_tier IS NULL)
)
);
@@ -0,0 +1 @@
DROP TABLE IF EXISTS item_tags;
@@ -0,0 +1,5 @@
CREATE TABLE item_tags (
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (item_id, tag_id)
);
@@ -0,0 +1,2 @@
DROP TABLE IF EXISTS enchantment_attributes;
DROP TABLE IF EXISTS enchantments;
@@ -0,0 +1,16 @@
CREATE TABLE enchantments (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
min_tier SMALLINT NOT NULL,
max_tier SMALLINT NOT NULL,
image_url TEXT NOT NULL,
CONSTRAINT enchantment_tier_range CHECK (
min_tier BETWEEN 1 AND 5 AND max_tier BETWEEN 1 AND 5 AND min_tier <= max_tier
)
);
CREATE TABLE enchantment_attributes (
enchantment_id INTEGER NOT NULL REFERENCES enchantments(id) ON DELETE CASCADE,
attribute primary_attribute NOT NULL,
PRIMARY KEY (enchantment_id, attribute)
);
@@ -0,0 +1 @@
DELETE FROM heroes;
@@ -0,0 +1,128 @@
INSERT INTO heroes (name, primary_attribute, attack_type, image_url) VALUES
('Alchemist', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/4/44/Alchemist_icon_dota2_gameasset.png/100px-Alchemist_icon_dota2_gameasset.png'),
('Axe', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/0/08/Axe_icon_dota2_gameasset.png/100px-Axe_icon_dota2_gameasset.png'),
('Bristleback', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/8/8e/Bristleback_icon_dota2_gameasset.png/100px-Bristleback_icon_dota2_gameasset.png'),
('Centaur Warrunner', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/8/8b/Centaur_Warrunner_icon_dota2_gameasset.png/100px-Centaur_Warrunner_icon_dota2_gameasset.png'),
('Chaos Knight', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/f/f1/Chaos_Knight_icon_dota2_gameasset.png/100px-Chaos_Knight_icon_dota2_gameasset.png'),
('Clockwerk', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/6/6b/Clockwerk_icon_dota2_gameasset.png/100px-Clockwerk_icon_dota2_gameasset.png'),
('Dawnbreaker', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/2/27/Dawnbreaker_icon_dota2_gameasset.png/100px-Dawnbreaker_icon_dota2_gameasset.png'),
('Doom', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/5/57/Doom_icon_dota2_gameasset.png/100px-Doom_icon_dota2_gameasset.png'),
('Dragon Knight', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/6/61/Dragon_Knight_icon_dota2_gameasset.png/100px-Dragon_Knight_icon_dota2_gameasset.png'),
('Earth Spirit', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/4/46/Earth_Spirit_icon_dota2_gameasset.png/100px-Earth_Spirit_icon_dota2_gameasset.png'),
('Earthshaker', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/6/66/Earthshaker_icon_dota2_gameasset.png/100px-Earthshaker_icon_dota2_gameasset.png'),
('Elder Titan', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/c/cc/Elder_Titan_icon_dota2_gameasset.png/100px-Elder_Titan_icon_dota2_gameasset.png'),
('Huskar', 'strength', 'ranged', 'https://liquipedia.net/commons/images/thumb/e/ee/Huskar_icon_dota2_gameasset.png/100px-Huskar_icon_dota2_gameasset.png'),
('Kunkka', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/2/2d/Kunkka_icon_dota2_gameasset.png/100px-Kunkka_icon_dota2_gameasset.png'),
('Largo', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/7/78/Largo_icon_dota2_gameasset.png/100px-Largo_icon_dota2_gameasset.png'),
('Legion Commander', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/2/28/Legion_Commander_icon_dota2_gameasset.png/100px-Legion_Commander_icon_dota2_gameasset.png'),
('Lifestealer', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/8/8f/Lifestealer_icon_dota2_gameasset.png/100px-Lifestealer_icon_dota2_gameasset.png'),
('Lycan', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/7/7a/Lycan_icon_dota2_gameasset.png/100px-Lycan_icon_dota2_gameasset.png'),
('Mars', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/6/6f/Mars_icon_dota2_gameasset.png/100px-Mars_icon_dota2_gameasset.png'),
('Night Stalker', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/6/65/Night_Stalker_icon_dota2_gameasset.png/100px-Night_Stalker_icon_dota2_gameasset.png'),
('Ogre Magi', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/b/ba/Ogre_Magi_icon_dota2_gameasset.png/100px-Ogre_Magi_icon_dota2_gameasset.png'),
('Omniknight', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/d/d3/Omniknight_icon_dota2_gameasset.png/100px-Omniknight_icon_dota2_gameasset.png'),
('Phoenix', 'strength', 'ranged', 'https://liquipedia.net/commons/images/thumb/d/d7/Phoenix_icon_dota2_gameasset.png/100px-Phoenix_icon_dota2_gameasset.png'),
('Primal Beast', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/9/92/Primal_Beast_icon_dota2_gameasset.png/100px-Primal_Beast_icon_dota2_gameasset.png'),
('Pudge', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/e/e9/Pudge_icon_dota2_gameasset.png/100px-Pudge_icon_dota2_gameasset.png'),
('Slardar', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/5/5b/Slardar_icon_dota2_gameasset.png/100px-Slardar_icon_dota2_gameasset.png'),
('Spirit Breaker', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/5/57/Spirit_Breaker_icon_dota2_gameasset.png/100px-Spirit_Breaker_icon_dota2_gameasset.png'),
('Sven', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/7/75/Sven_icon_dota2_gameasset.png/100px-Sven_icon_dota2_gameasset.png'),
('Tidehunter', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/b/b0/Tidehunter_icon_dota2_gameasset.png/100px-Tidehunter_icon_dota2_gameasset.png'),
('Timbersaw', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/1/1e/Timbersaw_icon_dota2_gameasset.png/100px-Timbersaw_icon_dota2_gameasset.png'),
('Tiny', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/0/07/Tiny_icon_dota2_gameasset.png/100px-Tiny_icon_dota2_gameasset.png'),
('Treant Protector', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/6/6d/Treant_Protector_icon_dota2_gameasset.png/100px-Treant_Protector_icon_dota2_gameasset.png'),
('Tusk', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/b/b4/Tusk_icon_dota2_gameasset.png/100px-Tusk_icon_dota2_gameasset.png'),
('Underlord', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/7/70/Underlord_icon_dota2_gameasset.png/100px-Underlord_icon_dota2_gameasset.png'),
('Undying', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/0/00/Undying_icon_dota2_gameasset.png/100px-Undying_icon_dota2_gameasset.png'),
('Wraith King', 'strength', 'melee', 'https://liquipedia.net/commons/images/thumb/c/cc/Wraith_King_icon_dota2_gameasset.png/100px-Wraith_King_icon_dota2_gameasset.png'),
('Anti-Mage', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/d/d4/Anti-Mage_icon_dota2_gameasset.png/100px-Anti-Mage_icon_dota2_gameasset.png'),
('Bloodseeker', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/0/00/Bloodseeker_icon_dota2_gameasset.png/100px-Bloodseeker_icon_dota2_gameasset.png'),
('Bounty Hunter', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/0/04/Bounty_Hunter_icon_dota2_gameasset.png/100px-Bounty_Hunter_icon_dota2_gameasset.png'),
('Broodmother', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/9/9b/Broodmother_icon_dota2_gameasset.png/100px-Broodmother_icon_dota2_gameasset.png'),
('Clinkz', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/6/6b/Clinkz_icon_dota2_gameasset.png/100px-Clinkz_icon_dota2_gameasset.png'),
('Drow Ranger', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/8/85/Drow_Ranger_icon_dota2_gameasset.png/100px-Drow_Ranger_icon_dota2_gameasset.png'),
('Ember Spirit', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/9/97/Ember_Spirit_icon_dota2_gameasset.png/100px-Ember_Spirit_icon_dota2_gameasset.png'),
('Faceless Void', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/6/6f/Faceless_Void_icon_dota2_gameasset.png/100px-Faceless_Void_icon_dota2_gameasset.png'),
('Gyrocopter', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/1/1b/Gyrocopter_icon_dota2_gameasset.png/100px-Gyrocopter_icon_dota2_gameasset.png'),
('Hoodwink', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/5/5b/Hoodwink_icon_dota2_gameasset.png/100px-Hoodwink_icon_dota2_gameasset.png'),
('Juggernaut', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/5/5c/Juggernaut_icon_dota2_gameasset.png/100px-Juggernaut_icon_dota2_gameasset.png'),
('Kez', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/b/b1/Kez_icon_dota2_gameasset.png/100px-Kez_icon_dota2_gameasset.png'),
('Lone Druid', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/5/5c/Lone_Druid_icon_dota2_gameasset.png/100px-Lone_Druid_icon_dota2_gameasset.png'),
('Luna', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/8/8d/Luna_icon_dota2_gameasset.png/100px-Luna_icon_dota2_gameasset.png'),
('Medusa', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/c/c6/Medusa_icon_dota2_gameasset.png/100px-Medusa_icon_dota2_gameasset.png'),
('Meepo', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/4/40/Meepo_icon_dota2_gameasset.png/100px-Meepo_icon_dota2_gameasset.png'),
('Mirana', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/0/01/Mirana_icon_dota2_gameasset.png/100px-Mirana_icon_dota2_gameasset.png'),
('Monkey King', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/7/7c/Monkey_King_icon_dota2_gameasset.png/100px-Monkey_King_icon_dota2_gameasset.png'),
('Morphling', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/6/65/Morphling_icon_dota2_gameasset.png/100px-Morphling_icon_dota2_gameasset.png'),
('Naga Siren', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/d/db/Naga_Siren_icon_dota2_gameasset.png/100px-Naga_Siren_icon_dota2_gameasset.png'),
('Phantom Assassin', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/3/34/Phantom_Assassin_icon_dota2_gameasset.png/100px-Phantom_Assassin_icon_dota2_gameasset.png'),
('Phantom Lancer', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/f/fd/Phantom_Lancer_icon_dota2_gameasset.png/100px-Phantom_Lancer_icon_dota2_gameasset.png'),
('Razor', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/b/bb/Razor_icon_dota2_gameasset.png/100px-Razor_icon_dota2_gameasset.png'),
('Riki', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/b/bd/Riki_icon_dota2_gameasset.png/100px-Riki_icon_dota2_gameasset.png'),
('Shadow Fiend', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/5/55/Shadow_Fiend_icon_dota2_gameasset.png/100px-Shadow_Fiend_icon_dota2_gameasset.png'),
('Slark', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/0/02/Slark_icon_dota2_gameasset.png/100px-Slark_icon_dota2_gameasset.png'),
('Sniper', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/a/a4/Sniper_icon_dota2_gameasset.png/100px-Sniper_icon_dota2_gameasset.png'),
('Spectre', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/f/f8/Spectre_icon_dota2_gameasset.png/100px-Spectre_icon_dota2_gameasset.png'),
('Templar Assassin', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/d/df/Templar_Assassin_icon_dota2_gameasset.png/100px-Templar_Assassin_icon_dota2_gameasset.png'),
('Terrorblade', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/2/2d/Terrorblade_icon_dota2_gameasset.png/100px-Terrorblade_icon_dota2_gameasset.png'),
('Troll Warlord', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/f/f2/Troll_Warlord_icon_dota2_gameasset.png/100px-Troll_Warlord_icon_dota2_gameasset.png'),
('Ursa', 'agility', 'melee', 'https://liquipedia.net/commons/images/thumb/3/33/Ursa_icon_dota2_gameasset.png/100px-Ursa_icon_dota2_gameasset.png'),
('Vengeful Spirit', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/c/c4/Vengeful_Spirit_icon_dota2_gameasset.png/100px-Vengeful_Spirit_icon_dota2_gameasset.png'),
('Viper', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/7/71/Viper_icon_dota2_gameasset.png/100px-Viper_icon_dota2_gameasset.png'),
('Weaver', 'agility', 'ranged', 'https://liquipedia.net/commons/images/thumb/8/8a/Weaver_icon_dota2_gameasset.png/100px-Weaver_icon_dota2_gameasset.png'),
('Ancient Apparition', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/3/33/Ancient_Apparition_icon_dota2_gameasset.png/100px-Ancient_Apparition_icon_dota2_gameasset.png'),
('Chen', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/4/47/Chen_icon_dota2_gameasset.png/100px-Chen_icon_dota2_gameasset.png'),
('Crystal Maiden', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/5/54/Crystal_Maiden_icon_dota2_gameasset.png/100px-Crystal_Maiden_icon_dota2_gameasset.png'),
('Dark Seer', 'intelligence', 'melee', 'https://liquipedia.net/commons/images/thumb/8/87/Dark_Seer_icon_dota2_gameasset.png/100px-Dark_Seer_icon_dota2_gameasset.png'),
('Dark Willow', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/a/a8/Dark_Willow_icon_dota2_gameasset.png/100px-Dark_Willow_icon_dota2_gameasset.png'),
('Disruptor', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/8/84/Disruptor_icon_dota2_gameasset.png/100px-Disruptor_icon_dota2_gameasset.png'),
('Enchantress', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/4/4a/Enchantress_icon_dota2_gameasset.png/100px-Enchantress_icon_dota2_gameasset.png'),
('Grimstroke', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/9/9b/Grimstroke_icon_dota2_gameasset.png/100px-Grimstroke_icon_dota2_gameasset.png'),
('Invoker', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/0/0f/Invoker_icon_dota2_gameasset.png/100px-Invoker_icon_dota2_gameasset.png'),
('Jakiro', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/4/45/Jakiro_icon_dota2_gameasset.png/100px-Jakiro_icon_dota2_gameasset.png'),
('Keeper of the Light', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/d/d0/Keeper_of_the_Light_icon_dota2_gameasset.png/100px-Keeper_of_the_Light_icon_dota2_gameasset.png'),
('Leshrac', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/d/d1/Leshrac_icon_dota2_gameasset.png/100px-Leshrac_icon_dota2_gameasset.png'),
('Lich', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/3/32/Lich_icon_dota2_gameasset.png/100px-Lich_icon_dota2_gameasset.png'),
('Lina', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/6/6c/Lina_icon_dota2_gameasset.png/100px-Lina_icon_dota2_gameasset.png'),
('Lion', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/3/3f/Lion_icon_dota2_gameasset.png/100px-Lion_icon_dota2_gameasset.png'),
('Muerta', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/d/d8/Muerta_icon_dota2_gameasset.png/100px-Muerta_icon_dota2_gameasset.png'),
('Necrophos', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/e/eb/Necrophos_icon_dota2_gameasset.png/100px-Necrophos_icon_dota2_gameasset.png'),
('Oracle', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/b/b9/Oracle_icon_dota2_gameasset.png/100px-Oracle_icon_dota2_gameasset.png'),
('Outworld Destroyer', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/4/45/Outworld_Destroyer_icon_dota2_gameasset.png/100px-Outworld_Destroyer_icon_dota2_gameasset.png'),
('Puck', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/9/9f/Puck_icon_dota2_gameasset.png/100px-Puck_icon_dota2_gameasset.png'),
('Pugna', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/3/38/Pugna_icon_dota2_gameasset.png/100px-Pugna_icon_dota2_gameasset.png'),
('Queen of Pain', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/7/7d/Queen_of_Pain_icon_dota2_gameasset.png/100px-Queen_of_Pain_icon_dota2_gameasset.png'),
('Ringmaster', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/7/73/Ringmaster_icon_dota2_gameasset.png/100px-Ringmaster_icon_dota2_gameasset.png'),
('Rubick', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/f/fe/Rubick_icon_dota2_gameasset.png/100px-Rubick_icon_dota2_gameasset.png'),
('Shadow Demon', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/9/9f/Shadow_Demon_icon_dota2_gameasset.png/100px-Shadow_Demon_icon_dota2_gameasset.png'),
('Shadow Shaman', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/4/4a/Shadow_Shaman_icon_dota2_gameasset.png/100px-Shadow_Shaman_icon_dota2_gameasset.png'),
('Silencer', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/4/43/Silencer_icon_dota2_gameasset.png/100px-Silencer_icon_dota2_gameasset.png'),
('Skywrath Mage', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/5/57/Skywrath_Mage_icon_dota2_gameasset.png/100px-Skywrath_Mage_icon_dota2_gameasset.png'),
('Storm Spirit', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/7/78/Storm_Spirit_icon_dota2_gameasset.png/100px-Storm_Spirit_icon_dota2_gameasset.png'),
('Tinker', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/2/23/Tinker_icon_dota2_gameasset.png/100px-Tinker_icon_dota2_gameasset.png'),
('Warlock', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/1/1f/Warlock_icon_dota2_gameasset.png/100px-Warlock_icon_dota2_gameasset.png'),
('Winter Wyvern', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/7/79/Winter_Wyvern_icon_dota2_gameasset.png/100px-Winter_Wyvern_icon_dota2_gameasset.png'),
('Witch Doctor', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/8/81/Witch_Doctor_icon_dota2_gameasset.png/100px-Witch_Doctor_icon_dota2_gameasset.png'),
('Zeus', 'intelligence', 'ranged', 'https://liquipedia.net/commons/images/thumb/d/d0/Zeus_icon_dota2_gameasset.png/100px-Zeus_icon_dota2_gameasset.png'),
('Abaddon', 'universal', 'melee', 'https://liquipedia.net/commons/images/thumb/5/5f/Abaddon_icon_dota2_gameasset.png/100px-Abaddon_icon_dota2_gameasset.png'),
('Arc Warden', 'universal', 'ranged', 'https://liquipedia.net/commons/images/thumb/8/8f/Arc_Warden_icon_dota2_gameasset.png/100px-Arc_Warden_icon_dota2_gameasset.png'),
('Bane', 'universal', 'ranged', 'https://liquipedia.net/commons/images/thumb/1/1c/Bane_icon_dota2_gameasset.png/100px-Bane_icon_dota2_gameasset.png'),
('Batrider', 'universal', 'ranged', 'https://liquipedia.net/commons/images/thumb/7/71/Batrider_icon_dota2_gameasset.png/100px-Batrider_icon_dota2_gameasset.png'),
('Beastmaster', 'universal', 'melee', 'https://liquipedia.net/commons/images/thumb/c/c3/Beastmaster_icon_dota2_gameasset.png/100px-Beastmaster_icon_dota2_gameasset.png'),
('Brewmaster', 'universal', 'melee', 'https://liquipedia.net/commons/images/thumb/7/76/Brewmaster_icon_dota2_gameasset.png/100px-Brewmaster_icon_dota2_gameasset.png'),
('Dazzle', 'universal', 'ranged', 'https://liquipedia.net/commons/images/thumb/8/81/Dazzle_icon_dota2_gameasset.png/100px-Dazzle_icon_dota2_gameasset.png'),
('Death Prophet', 'universal', 'ranged', 'https://liquipedia.net/commons/images/thumb/6/6a/Death_Prophet_icon_dota2_gameasset.png/100px-Death_Prophet_icon_dota2_gameasset.png'),
('Enigma', 'universal', 'ranged', 'https://liquipedia.net/commons/images/thumb/6/65/Enigma_icon_dota2_gameasset.png/100px-Enigma_icon_dota2_gameasset.png'),
('Io', 'universal', 'ranged', 'https://liquipedia.net/commons/images/thumb/4/4b/Io_icon_dota2_gameasset.png/100px-Io_icon_dota2_gameasset.png'),
('Magnus', 'universal', 'melee', 'https://liquipedia.net/commons/images/thumb/8/8c/Magnus_icon_dota2_gameasset.png/100px-Magnus_icon_dota2_gameasset.png'),
('Marci', 'universal', 'melee', 'https://liquipedia.net/commons/images/thumb/f/f4/Marci_icon_dota2_gameasset.png/100px-Marci_icon_dota2_gameasset.png'),
('Nature''s Prophet', 'universal', 'ranged', 'https://liquipedia.net/commons/images/thumb/6/69/Nature%27s_Prophet_icon_dota2_gameasset.png/100px-Nature%27s_Prophet_icon_dota2_gameasset.png'),
('Nyx Assassin', 'universal', 'melee', 'https://liquipedia.net/commons/images/thumb/a/ae/Nyx_Assassin_icon_dota2_gameasset.png/100px-Nyx_Assassin_icon_dota2_gameasset.png'),
('Pangolier', 'universal', 'melee', 'https://liquipedia.net/commons/images/thumb/2/2f/Pangolier_icon_dota2_gameasset.png/100px-Pangolier_icon_dota2_gameasset.png'),
('Sand King', 'universal', 'melee', 'https://liquipedia.net/commons/images/thumb/d/df/Sand_King_icon_dota2_gameasset.png/100px-Sand_King_icon_dota2_gameasset.png'),
('Snapfire', 'universal', 'ranged', 'https://liquipedia.net/commons/images/thumb/2/2a/Snapfire_icon_dota2_gameasset.png/100px-Snapfire_icon_dota2_gameasset.png'),
('Techies', 'universal', 'ranged', 'https://liquipedia.net/commons/images/thumb/0/0b/Techies_icon_dota2_gameasset.png/100px-Techies_icon_dota2_gameasset.png'),
('Venomancer', 'universal', 'ranged', 'https://liquipedia.net/commons/images/thumb/a/a6/Venomancer_icon_dota2_gameasset.png/100px-Venomancer_icon_dota2_gameasset.png'),
('Visage', 'universal', 'ranged', 'https://liquipedia.net/commons/images/thumb/3/3d/Visage_icon_dota2_gameasset.png/100px-Visage_icon_dota2_gameasset.png'),
('Void Spirit', 'universal', 'melee', 'https://liquipedia.net/commons/images/thumb/3/3e/Void_Spirit_icon_dota2_gameasset.png/100px-Void_Spirit_icon_dota2_gameasset.png'),
('Windranger', 'universal', 'ranged', 'https://liquipedia.net/commons/images/thumb/c/c5/Windranger_icon_dota2_gameasset.png/100px-Windranger_icon_dota2_gameasset.png');
@@ -0,0 +1,3 @@
ALTER TABLE items DROP CONSTRAINT grade_matches_source;
ALTER TABLE items DROP COLUMN min_grade;
ALTER TABLE items DROP COLUMN max_grade;
@@ -0,0 +1,11 @@
ALTER TABLE items ADD COLUMN min_grade SMALLINT;
ALTER TABLE items ADD COLUMN max_grade SMALLINT;
-- Backfill any existing shop rows to grade 1-1 (a normal single-grade item)
-- before the constraint below starts requiring it.
UPDATE items SET min_grade = 1, max_grade = 1 WHERE source = 'shop';
ALTER TABLE items ADD CONSTRAINT grade_matches_source CHECK (
(source = 'shop' AND min_grade >= 1 AND max_grade >= min_grade)
OR (source = 'neutral' AND min_grade IS NULL AND max_grade IS NULL)
);
@@ -0,0 +1,3 @@
DELETE FROM item_tags;
DELETE FROM tags;
DELETE FROM items;
+764
View File
@@ -0,0 +1,764 @@
DELETE FROM item_tags;
DELETE FROM tags;
DELETE FROM items;
INSERT INTO items (name, source, min_tier, max_tier, min_grade, max_grade, image_url) VALUES
('Town Portal Scroll', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/61/Town_Portal_Scroll_itemicon_dota2_gameasset.png/40px-Town_Portal_Scroll_itemicon_dota2_gameasset.png'),
('Clarity', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/b/b9/Clarity_itemicon_dota2_gameasset.png/40px-Clarity_itemicon_dota2_gameasset.png'),
('Faerie Fire', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/b/b2/Faerie_Fire_itemicon_dota2_gameasset.png/40px-Faerie_Fire_itemicon_dota2_gameasset.png'),
('Smoke of Deceit', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/ff/Smoke_of_Deceit_itemicon_dota2_gameasset.png/40px-Smoke_of_Deceit_itemicon_dota2_gameasset.png'),
('Observer Ward', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/65/Observer_Ward_itemicon_dota2_gameasset.png/40px-Observer_Ward_itemicon_dota2_gameasset.png'),
('Sentry Ward', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/8/8d/Sentry_Ward_itemicon_dota2_gameasset.png/40px-Sentry_Ward_itemicon_dota2_gameasset.png'),
('Enchanted Mango', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/9/9c/Enchanted_Mango_itemicon_dota2_gameasset.png/40px-Enchanted_Mango_itemicon_dota2_gameasset.png'),
('Healing Salve', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/4f/Healing_Salve_itemicon_dota2_gameasset.png/40px-Healing_Salve_itemicon_dota2_gameasset.png'),
('Tango', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/e/e2/Tango_itemicon_dota2_gameasset.png/40px-Tango_itemicon_dota2_gameasset.png'),
('Blood Grenade', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/9/9b/Blood_Grenade_itemicon_dota2_gameasset.png/40px-Blood_Grenade_itemicon_dota2_gameasset.png'),
('Dust of Appearance', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/9/9d/Dust_of_Appearance_itemicon_dota2_gameasset.png/40px-Dust_of_Appearance_itemicon_dota2_gameasset.png'),
('Bottle', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/34/Bottle_itemicon_dota2_gameasset.png/40px-Bottle_itemicon_dota2_gameasset.png'),
('Infused Raindrops', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/3e/Infused_Raindrops_itemicon_dota2_gameasset.png/40px-Infused_Raindrops_itemicon_dota2_gameasset.png'),
('Aghanim''s Shard', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/7/79/Aghanim%27s_Shard_itemicon_dota2_gameasset.png/40px-Aghanim%27s_Shard_itemicon_dota2_gameasset.png'),
('Iron Branch', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/d/d0/Iron_Branch_itemicon_dota2_gameasset.png/40px-Iron_Branch_itemicon_dota2_gameasset.png'),
('Gauntlets of Strength', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/45/Gauntlets_of_Strength_itemicon_dota2_gameasset.png/40px-Gauntlets_of_Strength_itemicon_dota2_gameasset.png'),
('Slippers of Agility', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/5/5b/Slippers_of_Agility_itemicon_dota2_gameasset.png/40px-Slippers_of_Agility_itemicon_dota2_gameasset.png'),
('Mantle of Intelligence', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/d/df/Mantle_of_Intelligence_itemicon_dota2_gameasset.png/40px-Mantle_of_Intelligence_itemicon_dota2_gameasset.png'),
('Circlet', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/46/Circlet_itemicon_dota2_gameasset.png/40px-Circlet_itemicon_dota2_gameasset.png'),
('Belt of Strength', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/3d/Belt_of_Strength_itemicon_dota2_gameasset.png/40px-Belt_of_Strength_itemicon_dota2_gameasset.png'),
('Band of Elvenskin', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/3f/Band_of_Elvenskin_itemicon_dota2_gameasset.png/40px-Band_of_Elvenskin_itemicon_dota2_gameasset.png'),
('Robe of the Magi', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/9/95/Robe_of_the_Magi_itemicon_dota2_gameasset.png/40px-Robe_of_the_Magi_itemicon_dota2_gameasset.png'),
('Crown', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/e/e3/Crown_itemicon_dota2_gameasset.png/40px-Crown_itemicon_dota2_gameasset.png'),
('Ogre Axe', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/5/58/Ogre_Axe_itemicon_dota2_gameasset.png/40px-Ogre_Axe_itemicon_dota2_gameasset.png'),
('Blade of Alacrity', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/b/be/Blade_of_Alacrity_itemicon_dota2_gameasset.png/40px-Blade_of_Alacrity_itemicon_dota2_gameasset.png'),
('Staff of Wizardry', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/64/Staff_of_Wizardry_itemicon_dota2_gameasset.png/40px-Staff_of_Wizardry_itemicon_dota2_gameasset.png'),
('Diadem', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/5/5a/Diadem_itemicon_dota2_gameasset.png/40px-Diadem_itemicon_dota2_gameasset.png'),
('Ghost Scepter', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/9/9b/Ghost_Scepter_itemicon_dota2_gameasset.png/40px-Ghost_Scepter_itemicon_dota2_gameasset.png'),
('Quelling Blade', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/5/55/Quelling_Blade_itemicon_dota2_gameasset.png/40px-Quelling_Blade_itemicon_dota2_gameasset.png'),
('Ring of Protection', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/2/2a/Ring_of_Protection_itemicon_dota2_gameasset.png/40px-Ring_of_Protection_itemicon_dota2_gameasset.png'),
('Blades of Attack', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/60/Blades_of_Attack_itemicon_dota2_gameasset.png/40px-Blades_of_Attack_itemicon_dota2_gameasset.png'),
('Orb of Frost', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/d/da/Orb_of_Frost_itemicon_dota2_gameasset.png/40px-Orb_of_Frost_itemicon_dota2_gameasset.png'),
('Gloves of Haste', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/3b/Gloves_of_Haste_itemicon_dota2_gameasset.png/40px-Gloves_of_Haste_itemicon_dota2_gameasset.png'),
('Chainmail', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/c/cf/Chainmail_itemicon_dota2_gameasset.png/40px-Chainmail_itemicon_dota2_gameasset.png'),
('Broadsword', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/5/52/Broadsword_itemicon_dota2_gameasset.png/40px-Broadsword_itemicon_dota2_gameasset.png'),
('Orb of Blight', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/c/c5/Orb_of_Blight_itemicon_dota2_gameasset.png/40px-Orb_of_Blight_itemicon_dota2_gameasset.png'),
('Blitz Knuckles', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/a/af/Blitz_Knuckles_itemicon_dota2_gameasset.png/40px-Blitz_Knuckles_itemicon_dota2_gameasset.png'),
('Splintmail', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/a/ac/Splintmail_itemicon_dota2_gameasset.png/40px-Splintmail_itemicon_dota2_gameasset.png'),
('Claymore', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/8/8a/Claymore_itemicon_dota2_gameasset.png/40px-Claymore_itemicon_dota2_gameasset.png'),
('Orb of Venom', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/0/08/Orb_of_Venom_itemicon_dota2_gameasset.png/40px-Orb_of_Venom_itemicon_dota2_gameasset.png'),
('Javelin', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/3d/Javelin_itemicon_dota2_gameasset.png/40px-Javelin_itemicon_dota2_gameasset.png'),
('Helm of Iron Will', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/44/Helm_of_Iron_Will_itemicon_dota2_gameasset.png/40px-Helm_of_Iron_Will_itemicon_dota2_gameasset.png'),
('Mithril Hammer', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/1/10/Mithril_Hammer_itemicon_dota2_gameasset.png/40px-Mithril_Hammer_itemicon_dota2_gameasset.png'),
('Ring of Regen', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/7/72/Ring_of_Regen_itemicon_dota2_gameasset.png/40px-Ring_of_Regen_itemicon_dota2_gameasset.png'),
('Sage''s Mask', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/b/bc/Sage%27s_Mask_itemicon_dota2_gameasset.png/40px-Sage%27s_Mask_itemicon_dota2_gameasset.png'),
('Wind Lace', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/c/c8/Wind_Lace_itemicon_dota2_gameasset.png/40px-Wind_Lace_itemicon_dota2_gameasset.png'),
('Magic Stick', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/f1/Magic_Stick_itemicon_dota2_gameasset.png/40px-Magic_Stick_itemicon_dota2_gameasset.png'),
('Fluffy Hat', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/5/58/Fluffy_Hat_itemicon_dota2_gameasset.png/40px-Fluffy_Hat_itemicon_dota2_gameasset.png'),
('Wizard Hat', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/35/Wizard_Hat_itemicon_dota2_gameasset.png/40px-Wizard_Hat_itemicon_dota2_gameasset.png'),
('Boots of Speed', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/0/04/Boots_of_Speed_itemicon_dota2_gameasset.png/40px-Boots_of_Speed_itemicon_dota2_gameasset.png'),
('Voodoo Mask', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/37/Voodoo_Mask_itemicon_dota2_gameasset.png/40px-Voodoo_Mask_itemicon_dota2_gameasset.png'),
('Shawl', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/42/Shawl_itemicon_dota2_gameasset.png/40px-Shawl_itemicon_dota2_gameasset.png'),
('Shadow Amulet', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/fc/Shadow_Amulet_itemicon_dota2_gameasset.png/40px-Shadow_Amulet_itemicon_dota2_gameasset.png'),
('Gem of True Sight', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/1/1d/Gem_of_True_Sight_itemicon_dota2_gameasset.png/40px-Gem_of_True_Sight_itemicon_dota2_gameasset.png'),
('Morbid Mask', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/8/83/Morbid_Mask_itemicon_dota2_gameasset.png/40px-Morbid_Mask_itemicon_dota2_gameasset.png'),
('Cloak', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/6c/Cloak_itemicon_dota2_gameasset.png/40px-Cloak_itemicon_dota2_gameasset.png'),
('Chasm Stone', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/0/0d/Chasm_Stone_itemicon_dota2_gameasset.png/40px-Chasm_Stone_itemicon_dota2_gameasset.png'),
('Blink Dagger', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/d/dc/Blink_Dagger_itemicon_dota2_gameasset.png/40px-Blink_Dagger_itemicon_dota2_gameasset.png'),
('Magic Wand', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/7/7a/Magic_Wand_itemicon_dota2_gameasset.png/40px-Magic_Wand_itemicon_dota2_gameasset.png'),
('Bracer', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/3c/Bracer_itemicon_dota2_gameasset.png/40px-Bracer_itemicon_dota2_gameasset.png'),
('Wraith Band', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/7/70/Wraith_Band_itemicon_dota2_gameasset.png/40px-Wraith_Band_itemicon_dota2_gameasset.png'),
('Null Talisman', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/d/d1/Null_Talisman_itemicon_dota2_gameasset.png/40px-Null_Talisman_itemicon_dota2_gameasset.png'),
('Power Treads', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/b/b4/Power_Treads_itemicon_dota2_gameasset.png/40px-Power_Treads_itemicon_dota2_gameasset.png'),
('Soul Ring', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/c/cf/Soul_Ring_itemicon_dota2_gameasset.png/40px-Soul_Ring_itemicon_dota2_gameasset.png'),
('Orb of Corrosion', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/e/ec/Orb_of_Corrosion_itemicon_dota2_gameasset.png/40px-Orb_of_Corrosion_itemicon_dota2_gameasset.png'),
('Falcon Blade', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/9/99/Falcon_Blade_itemicon_dota2_gameasset.png/40px-Falcon_Blade_itemicon_dota2_gameasset.png'),
('Phase Boots', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/1/14/Phase_Boots_itemicon_dota2_gameasset.png/40px-Phase_Boots_itemicon_dota2_gameasset.png'),
('Perseverance', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/b/bb/Perseverance_itemicon_dota2_gameasset.png/40px-Perseverance_itemicon_dota2_gameasset.png'),
('Mask of Madness', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/6a/Mask_of_Madness_itemicon_dota2_gameasset.png/40px-Mask_of_Madness_itemicon_dota2_gameasset.png'),
('Oblivion Staff', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/6e/Oblivion_Staff_itemicon_dota2_gameasset.png/40px-Oblivion_Staff_itemicon_dota2_gameasset.png'),
('Boots of Travel', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/2/20/Boots_of_Travel_itemicon_dota2_gameasset.png'),
('Boots of Travel 2', 'shop', NULL, NULL, 2, 2, 'https://liquipedia.net/commons/images/5/50/Boots_of_Travel_2_itemicon_dota2_gameasset.png'),
('Hand of Midas', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/69/Hand_of_Midas_itemicon_dota2_gameasset.png/40px-Hand_of_Midas_itemicon_dota2_gameasset.png'),
('Moon Shard', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/0/09/Moon_Shard_itemicon_dota2_gameasset.png/40px-Moon_Shard_itemicon_dota2_gameasset.png'),
('Soul Booster', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/63/Soul_Booster_itemicon_dota2_gameasset.png/40px-Soul_Booster_itemicon_dota2_gameasset.png'),
('Ring of Basilius', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/e/e0/Ring_of_Basilius_itemicon_dota2_gameasset.png/40px-Ring_of_Basilius_itemicon_dota2_gameasset.png'),
('Headdress', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/f4/Headdress_itemicon_dota2_gameasset.png/40px-Headdress_itemicon_dota2_gameasset.png'),
('Urn of Shadows', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/a/a4/Urn_of_Shadows_itemicon_dota2_gameasset.png/40px-Urn_of_Shadows_itemicon_dota2_gameasset.png'),
('Tranquil Boots', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/c/cf/Tranquil_Boots_%28Active%29_itemicon_dota2_gameasset.png/40px-Tranquil_Boots_%28Active%29_itemicon_dota2_gameasset.png'),
('Arcane Boots', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/7/75/Arcane_Boots_itemicon_dota2_gameasset.png/40px-Arcane_Boots_itemicon_dota2_gameasset.png'),
('Mekansm', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/32/Mekansm_itemicon_dota2_gameasset.png/40px-Mekansm_itemicon_dota2_gameasset.png'),
('Essence Distiller', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/4f/Essence_Distiller_itemicon_dota2_gameasset.png/40px-Essence_Distiller_itemicon_dota2_gameasset.png'),
('Drum of Endurance', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/9/95/Drum_of_Endurance_itemicon_dota2_gameasset.png/40px-Drum_of_Endurance_itemicon_dota2_gameasset.png'),
('Pavise', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/7/74/Pavise_itemicon_dota2_gameasset.png/40px-Pavise_itemicon_dota2_gameasset.png'),
('Guardian Greaves', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/a/a7/Guardian_Greaves_itemicon_dota2_gameasset.png/40px-Guardian_Greaves_itemicon_dota2_gameasset.png'),
('Spirit Vessel', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/ff/Spirit_Vessel_itemicon_dota2_gameasset.png/40px-Spirit_Vessel_itemicon_dota2_gameasset.png'),
('Boots of Bearing', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/2/2f/Boots_of_Bearing_itemicon_dota2_gameasset.png/40px-Boots_of_Bearing_itemicon_dota2_gameasset.png'),
('Solar Crest', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/e/e8/Solar_Crest_itemicon_dota2_gameasset.png/40px-Solar_Crest_itemicon_dota2_gameasset.png'),
('Glimmer Cape', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/f9/Glimmer_Cape_itemicon_dota2_gameasset.png/40px-Glimmer_Cape_itemicon_dota2_gameasset.png'),
('Holy Locket', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/34/Holy_Locket_itemicon_dota2_gameasset.png/40px-Holy_Locket_itemicon_dota2_gameasset.png'),
('Pipe of Insight', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/e/ec/Pipe_of_Insight_itemicon_dota2_gameasset.png/40px-Pipe_of_Insight_itemicon_dota2_gameasset.png'),
('Veil of Discord', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/b/b6/Veil_of_Discord_itemicon_dota2_gameasset.png/40px-Veil_of_Discord_itemicon_dota2_gameasset.png'),
('Rod of Atos', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/e/e6/Rod_of_Atos_itemicon_dota2_gameasset.png/40px-Rod_of_Atos_itemicon_dota2_gameasset.png'),
('Eul''s Scepter of Divinity', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/2/2d/Eul%27s_Scepter_of_Divinity_itemicon_dota2_gameasset.png/40px-Eul%27s_Scepter_of_Divinity_itemicon_dota2_gameasset.png'),
('Phylactery', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/66/Phylactery_itemicon_dota2_gameasset.png/40px-Phylactery_itemicon_dota2_gameasset.png'),
('Bloodstone', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/8/87/Bloodstone_itemicon_dota2_gameasset.png/40px-Bloodstone_itemicon_dota2_gameasset.png'),
('Gleipnir', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/0/06/Gleipnir_itemicon_dota2_gameasset.png/40px-Gleipnir_itemicon_dota2_gameasset.png'),
('Wind Waker', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/e/ed/Wind_Waker_itemicon_dota2_gameasset.png/40px-Wind_Waker_itemicon_dota2_gameasset.png'),
('Khanda', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/5/5f/Khanda_itemicon_dota2_gameasset.png/40px-Khanda_itemicon_dota2_gameasset.png'),
('Force Staff', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/b/b3/Force_Staff_itemicon_dota2_gameasset.png/40px-Force_Staff_itemicon_dota2_gameasset.png'),
('Aether Lens', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/0/01/Aether_Lens_itemicon_dota2_gameasset.png/40px-Aether_Lens_itemicon_dota2_gameasset.png'),
('Meteor Hammer', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/a/aa/Meteor_Hammer_itemicon_dota2_gameasset.png/40px-Meteor_Hammer_itemicon_dota2_gameasset.png'),
('Dagon', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/d/de/Dagon_itemicon_dota2_gameasset.png'),
('Dagon 2', 'shop', NULL, NULL, 2, 2, 'https://liquipedia.net/commons/images/9/9a/Dagon_2_itemicon_dota2_gameasset.png'),
('Dagon 3', 'shop', NULL, NULL, 3, 3, 'https://liquipedia.net/commons/images/a/a0/Dagon_3_itemicon_dota2_gameasset.png'),
('Dagon 4', 'shop', NULL, NULL, 4, 4, 'https://liquipedia.net/commons/images/9/92/Dagon_4_itemicon_dota2_gameasset.png'),
('Dagon 5', 'shop', NULL, NULL, 5, 5, 'https://liquipedia.net/commons/images/3/3e/Dagon_5_itemicon_dota2_gameasset.png'),
('Aghanim''s Scepter', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/1/1b/Aghanim%27s_Scepter_itemicon_dota2_gameasset.png/40px-Aghanim%27s_Scepter_itemicon_dota2_gameasset.png'),
('Crella''s Crozier', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/2/26/Crella%27s_Crozier_itemicon_dota2_gameasset.png/40px-Crella%27s_Crozier_itemicon_dota2_gameasset.png'),
('Octarine Core', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/5/5f/Octarine_Core_itemicon_dota2_gameasset.png/40px-Octarine_Core_itemicon_dota2_gameasset.png'),
('Orchid Malevolence', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/43/Orchid_Malevolence_itemicon_dota2_gameasset.png/40px-Orchid_Malevolence_itemicon_dota2_gameasset.png'),
('Refresher Orb', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/ff/Refresher_Orb_itemicon_dota2_gameasset.png/40px-Refresher_Orb_itemicon_dota2_gameasset.png'),
('Ethereal Blade', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/47/Ethereal_Blade_itemicon_dota2_gameasset.png/40px-Ethereal_Blade_itemicon_dota2_gameasset.png'),
('Scythe of Vyse', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/2/26/Scythe_of_Vyse_itemicon_dota2_gameasset.png/40px-Scythe_of_Vyse_itemicon_dota2_gameasset.png'),
('Bloodthorn', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/4e/Bloodthorn_itemicon_dota2_gameasset.png/40px-Bloodthorn_itemicon_dota2_gameasset.png'),
('Buckler', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/f1/Buckler_itemicon_dota2_gameasset.png/40px-Buckler_itemicon_dota2_gameasset.png'),
('Vanguard', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/8/89/Vanguard_itemicon_dota2_gameasset.png/40px-Vanguard_itemicon_dota2_gameasset.png'),
('Blade Mail', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/d/d3/Blade_Mail_itemicon_dota2_gameasset.png/40px-Blade_Mail_itemicon_dota2_gameasset.png'),
('Helm of the Dominator', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/4a/Helm_of_the_Dominator_itemicon_dota2_gameasset.png/40px-Helm_of_the_Dominator_itemicon_dota2_gameasset.png'),
('Vladmir''s Offering', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/7/78/Vladmir%27s_Offering_itemicon_dota2_gameasset.png/40px-Vladmir%27s_Offering_itemicon_dota2_gameasset.png'),
('Crimson Guard', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/e/e6/Crimson_Guard_itemicon_dota2_gameasset.png/40px-Crimson_Guard_itemicon_dota2_gameasset.png'),
('Armlet of Mordiggian', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/e/e8/Armlet_of_Mordiggian_itemicon_dota2_gameasset.png/40px-Armlet_of_Mordiggian_itemicon_dota2_gameasset.png'),
('Helm of the Overlord', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/2/2e/Helm_of_the_Overlord_itemicon_dota2_gameasset.png/40px-Helm_of_the_Overlord_itemicon_dota2_gameasset.png'),
('Consecrated Wraps', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/a/aa/Consecrated_Wraps_itemicon_dota2_gameasset.png/40px-Consecrated_Wraps_itemicon_dota2_gameasset.png'),
('Aeon Disk', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/7/7f/Aeon_Disk_itemicon_dota2_gameasset.png/40px-Aeon_Disk_itemicon_dota2_gameasset.png'),
('Lotus Orb', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/a/ac/Lotus_Orb_itemicon_dota2_gameasset.png/40px-Lotus_Orb_itemicon_dota2_gameasset.png'),
('Black King Bar', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/d/dc/Black_King_Bar_itemicon_dota2_gameasset.png/40px-Black_King_Bar_itemicon_dota2_gameasset.png'),
('Shiva''s Guard', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/b/b8/Shiva%27s_Guard_itemicon_dota2_gameasset.png/40px-Shiva%27s_Guard_itemicon_dota2_gameasset.png'),
('Linken''s Sphere', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/7/7c/Linken%27s_Sphere_itemicon_dota2_gameasset.png/40px-Linken%27s_Sphere_itemicon_dota2_gameasset.png'),
('Assault Cuirass', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/9/93/Assault_Cuirass_itemicon_dota2_gameasset.png/40px-Assault_Cuirass_itemicon_dota2_gameasset.png'),
('Heart of Tarrasque', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/2/2d/Heart_of_Tarrasque_itemicon_dota2_gameasset.png/40px-Heart_of_Tarrasque_itemicon_dota2_gameasset.png'),
('Crystalys', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/6c/Crystalys_itemicon_dota2_gameasset.png/40px-Crystalys_itemicon_dota2_gameasset.png'),
('Mage Slayer', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/47/Mage_Slayer_itemicon_dota2_gameasset.png/40px-Mage_Slayer_itemicon_dota2_gameasset.png'),
('Heaven''s Halberd', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/d/d3/Heaven%27s_Halberd_itemicon_dota2_gameasset.png/40px-Heaven%27s_Halberd_itemicon_dota2_gameasset.png'),
('Desolator', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/37/Desolator_itemicon_dota2_gameasset.png/40px-Desolator_itemicon_dota2_gameasset.png'),
('Revenant''s Brooch', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/2/22/Revenant%27s_Brooch_itemicon_dota2_gameasset.png/40px-Revenant%27s_Brooch_itemicon_dota2_gameasset.png'),
('Skull Basher', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/62/Skull_Basher_itemicon_dota2_gameasset.png/40px-Skull_Basher_itemicon_dota2_gameasset.png'),
('Maelstrom', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/b/b8/Maelstrom_itemicon_dota2_gameasset.png/40px-Maelstrom_itemicon_dota2_gameasset.png'),
('Shadow Blade', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/41/Shadow_Blade_itemicon_dota2_gameasset.png/40px-Shadow_Blade_itemicon_dota2_gameasset.png'),
('Daedalus', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/1/11/Daedalus_itemicon_dota2_gameasset.png/40px-Daedalus_itemicon_dota2_gameasset.png'),
('Abyssal Blade', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/0/02/Abyssal_Blade_itemicon_dota2_gameasset.png/40px-Abyssal_Blade_itemicon_dota2_gameasset.png'),
('Mjollnir', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/0/0c/Mjollnir_itemicon_dota2_gameasset.png/40px-Mjollnir_itemicon_dota2_gameasset.png'),
('Silver Edge', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/3c/Silver_Edge_itemicon_dota2_gameasset.png/40px-Silver_Edge_itemicon_dota2_gameasset.png'),
('Battle Fury', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/63/Battle_Fury_itemicon_dota2_gameasset.png/40px-Battle_Fury_itemicon_dota2_gameasset.png'),
('Nullifier', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/9/9e/Nullifier_itemicon_dota2_gameasset.png/40px-Nullifier_itemicon_dota2_gameasset.png'),
('Manta Style', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/b/b7/Manta_Style_itemicon_dota2_gameasset.png/40px-Manta_Style_itemicon_dota2_gameasset.png'),
('Radiance', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/c/c7/Radiance_itemicon_dota2_gameasset.png/40px-Radiance_itemicon_dota2_gameasset.png'),
('Divine Rapier', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/9/90/Divine_Rapier_itemicon_dota2_gameasset.png/40px-Divine_Rapier_itemicon_dota2_gameasset.png'),
('Monkey King Bar', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/f7/Monkey_King_Bar_itemicon_dota2_gameasset.png/40px-Monkey_King_Bar_itemicon_dota2_gameasset.png'),
('Butterfly', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/7/7f/Butterfly_itemicon_dota2_gameasset.png/40px-Butterfly_itemicon_dota2_gameasset.png'),
('Satanic', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/e/e2/Satanic_itemicon_dota2_gameasset.png/40px-Satanic_itemicon_dota2_gameasset.png'),
('Sange', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/7/72/Sange_itemicon_dota2_gameasset.png/40px-Sange_itemicon_dota2_gameasset.png'),
('Yasha', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/3a/Yasha_itemicon_dota2_gameasset.png/40px-Yasha_itemicon_dota2_gameasset.png'),
('Kaya', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/4f/Kaya_itemicon_dota2_gameasset.png/40px-Kaya_itemicon_dota2_gameasset.png'),
('Dragon Lance', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/47/Dragon_Lance_itemicon_dota2_gameasset.png/40px-Dragon_Lance_itemicon_dota2_gameasset.png'),
('Kaya and Sange', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/a/a8/Kaya_and_Sange_itemicon_dota2_gameasset.png/40px-Kaya_and_Sange_itemicon_dota2_gameasset.png'),
('Sange and Yasha', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/2/27/Sange_and_Yasha_itemicon_dota2_gameasset.png/40px-Sange_and_Yasha_itemicon_dota2_gameasset.png'),
('Yasha and Kaya', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/d/d9/Yasha_and_Kaya_itemicon_dota2_gameasset.png/40px-Yasha_and_Kaya_itemicon_dota2_gameasset.png'),
('Hurricane Pike', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/1/13/Hurricane_Pike_itemicon_dota2_gameasset.png/40px-Hurricane_Pike_itemicon_dota2_gameasset.png'),
('Echo Sabre', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/1/11/Echo_Sabre_itemicon_dota2_gameasset.png/40px-Echo_Sabre_itemicon_dota2_gameasset.png'),
('Diffusal Blade', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/9/97/Diffusal_Blade_itemicon_dota2_gameasset.png/40px-Diffusal_Blade_itemicon_dota2_gameasset.png'),
('Witch Blade', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/0/0e/Witch_Blade_itemicon_dota2_gameasset.png/40px-Witch_Blade_itemicon_dota2_gameasset.png'),
('Specialist''s Array', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/a/a6/Specialist%27s_Array_itemicon_dota2_gameasset.png/40px-Specialist%27s_Array_itemicon_dota2_gameasset.png'),
('Harpoon', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/4f/Harpoon_itemicon_dota2_gameasset.png/40px-Harpoon_itemicon_dota2_gameasset.png'),
('Disperser', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/8/8e/Disperser_itemicon_dota2_gameasset.png/40px-Disperser_itemicon_dota2_gameasset.png'),
('Parasma', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/e/e2/Parasma_itemicon_dota2_gameasset.png/40px-Parasma_itemicon_dota2_gameasset.png'),
('Hydra''s Breath', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/fd/Hydra%27s_Breath_itemicon_dota2_gameasset.png/40px-Hydra%27s_Breath_itemicon_dota2_gameasset.png'),
('Overwhelming Blink', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/a/a6/Overwhelming_Blink_itemicon_dota2_gameasset.png/40px-Overwhelming_Blink_itemicon_dota2_gameasset.png'),
('Swift Blink', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/9/92/Swift_Blink_itemicon_dota2_gameasset.png/40px-Swift_Blink_itemicon_dota2_gameasset.png'),
('Arcane Blink', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/0/0a/Arcane_Blink_itemicon_dota2_gameasset.png/40px-Arcane_Blink_itemicon_dota2_gameasset.png'),
('Eye of Skadi', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/5/5e/Eye_of_Skadi_itemicon_dota2_gameasset.png/40px-Eye_of_Skadi_itemicon_dota2_gameasset.png'),
('Ring of Health', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/0/0c/Ring_of_Health_itemicon_dota2_gameasset.png/40px-Ring_of_Health_itemicon_dota2_gameasset.png'),
('Void Stone', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/f3/Void_Stone_itemicon_dota2_gameasset.png/40px-Void_Stone_itemicon_dota2_gameasset.png'),
('Talisman of Evasion', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/68/Talisman_of_Evasion_itemicon_dota2_gameasset.png/40px-Talisman_of_Evasion_itemicon_dota2_gameasset.png'),
('Platemail', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/d/d7/Platemail_itemicon_dota2_gameasset.png/40px-Platemail_itemicon_dota2_gameasset.png'),
('Ring of Tarrasque', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/5/5e/Ring_of_Tarrasque_itemicon_dota2_gameasset.png/40px-Ring_of_Tarrasque_itemicon_dota2_gameasset.png'),
('Tiara of Selemene', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/b/b6/Tiara_of_Selemene_itemicon_dota2_gameasset.png/40px-Tiara_of_Selemene_itemicon_dota2_gameasset.png'),
('Hyperstone', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/e/e0/Hyperstone_itemicon_dota2_gameasset.png/40px-Hyperstone_itemicon_dota2_gameasset.png'),
('Demon Edge', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/44/Demon_Edge_itemicon_dota2_gameasset.png/40px-Demon_Edge_itemicon_dota2_gameasset.png'),
('Vitality Booster', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/9/93/Vitality_Booster_itemicon_dota2_gameasset.png/40px-Vitality_Booster_itemicon_dota2_gameasset.png'),
('Energy Booster', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/6/6f/Energy_Booster_itemicon_dota2_gameasset.png/40px-Energy_Booster_itemicon_dota2_gameasset.png'),
('Point Booster', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/4/43/Point_Booster_itemicon_dota2_gameasset.png/40px-Point_Booster_itemicon_dota2_gameasset.png'),
('Sacred Relic', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/1/1e/Sacred_Relic_itemicon_dota2_gameasset.png/40px-Sacred_Relic_itemicon_dota2_gameasset.png'),
('Reaver', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/fc/Reaver_itemicon_dota2_gameasset.png/40px-Reaver_itemicon_dota2_gameasset.png'),
('Eaglesong', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/f2/Eaglesong_itemicon_dota2_gameasset.png/40px-Eaglesong_itemicon_dota2_gameasset.png'),
('Mystic Staff', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/b/b1/Mystic_Staff_itemicon_dota2_gameasset.png/40px-Mystic_Staff_itemicon_dota2_gameasset.png'),
('Ultimate Orb', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/fc/Ultimate_Orb_itemicon_dota2_gameasset.png/40px-Ultimate_Orb_itemicon_dota2_gameasset.png'),
('Aegis of the Immortal', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/2/24/Aegis_of_the_Immortal_itemicon_dota2_gameasset.png/40px-Aegis_of_the_Immortal_itemicon_dota2_gameasset.png'),
('Cheese', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/3/34/Cheese_itemicon_dota2_gameasset.png/40px-Cheese_itemicon_dota2_gameasset.png'),
('Refresher Shard', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/f/f9/Refresher_Shard_itemicon_dota2_gameasset.png/40px-Refresher_Shard_itemicon_dota2_gameasset.png'),
('Roshan''s Banner', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/thumb/c/c6/Roshan%27s_Banner_itemicon_dota2_gameasset.png/40px-Roshan%27s_Banner_itemicon_dota2_gameasset.png'),
('Ash Legion Shield', 'neutral', 1, 1, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/0/0a/Ash_Legion_Shield_itemicon_dota2_gameasset.png/60px-Ash_Legion_Shield_itemicon_dota2_gameasset.png'),
('Chipped Vest', 'neutral', 1, 1, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/9/99/Chipped_Vest_itemicon_dota2_gameasset.png/60px-Chipped_Vest_itemicon_dota2_gameasset.png'),
('Dagger of Ristul', 'neutral', 1, 1, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/8/83/Dagger_of_Ristul_itemicon_dota2_gameasset.png/60px-Dagger_of_Ristul_itemicon_dota2_gameasset.png'),
('Dormant Curio', 'neutral', 1, 1, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/5/56/Dormant_Curio_itemicon_dota2_gameasset.png/60px-Dormant_Curio_itemicon_dota2_gameasset.png'),
('Duelist Gloves', 'neutral', 1, 1, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/8/83/Duelist_Gloves_itemicon_dota2_gameasset.png/60px-Duelist_Gloves_itemicon_dota2_gameasset.png'),
('Forager''s Kit', 'neutral', 1, 1, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/2/29/Forager%27s_Kit_itemicon_dota2_gameasset.png/60px-Forager%27s_Kit_itemicon_dota2_gameasset.png'),
('Kobold Cup', 'neutral', 1, 1, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/8/8d/Kobold_Cup_itemicon_dota2_gameasset.png/60px-Kobold_Cup_itemicon_dota2_gameasset.png'),
('Occult Bracelet', 'neutral', 1, 1, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/d/df/Occult_Bracelet_itemicon_dota2_gameasset.png/60px-Occult_Bracelet_itemicon_dota2_gameasset.png'),
('Pollywog Charm', 'neutral', 1, 1, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/9/96/Pollywog_Charm_itemicon_dota2_gameasset.png/60px-Pollywog_Charm_itemicon_dota2_gameasset.png'),
('Possessed Mask', 'neutral', 1, 1, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/7/70/Possessed_Mask_itemicon_dota2_gameasset.png/60px-Possessed_Mask_itemicon_dota2_gameasset.png'),
('Stonefeather Satchel', 'neutral', 1, 1, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/6/62/Stonefeather_Satchel_itemicon_dota2_gameasset.png/60px-Stonefeather_Satchel_itemicon_dota2_gameasset.png'),
('Weighted Dice', 'neutral', 1, 1, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/5/5b/Weighted_Dice_itemicon_dota2_gameasset.png/60px-Weighted_Dice_itemicon_dota2_gameasset.png'),
('Crippling Crossbow', 'neutral', 2, 2, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/b/b3/Crippling_Crossbow_itemicon_dota2_gameasset.png/60px-Crippling_Crossbow_itemicon_dota2_gameasset.png'),
('Defiant Shell', 'neutral', 2, 2, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/1/1d/Defiant_Shell_itemicon_dota2_gameasset.png/60px-Defiant_Shell_itemicon_dota2_gameasset.png'),
('Essence Ring', 'neutral', 2, 2, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/8/84/Essence_Ring_itemicon_dota2_gameasset.png/60px-Essence_Ring_itemicon_dota2_gameasset.png'),
('Mana Draught', 'neutral', 2, 2, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/d/d2/Mana_Draught_itemicon_dota2_gameasset.png/60px-Mana_Draught_itemicon_dota2_gameasset.png'),
('Medallion of Courage', 'neutral', 2, 2, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/5/58/Medallion_of_Courage_itemicon_dota2_gameasset.png/60px-Medallion_of_Courage_itemicon_dota2_gameasset.png'),
('Poor Man''s Shield', 'neutral', 2, 2, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/a/a0/Poor_Man%27s_Shield_itemicon_dota2_gameasset.png/60px-Poor_Man%27s_Shield_itemicon_dota2_gameasset.png'),
('Searing Signet', 'neutral', 2, 2, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/7/71/Searing_Signet_itemicon_dota2_gameasset.png/60px-Searing_Signet_itemicon_dota2_gameasset.png'),
('Seeds of Serenity', 'neutral', 2, 2, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/f/f2/Seeds_of_Serenity_itemicon_dota2_gameasset.png/60px-Seeds_of_Serenity_itemicon_dota2_gameasset.png'),
('Tumbler''s Toy', 'neutral', 2, 2, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/f/f4/Tumbler%27s_Toy_itemicon_dota2_gameasset.png/60px-Tumbler%27s_Toy_itemicon_dota2_gameasset.png'),
('Cloak of Flames', 'neutral', 3, 3, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/6/64/Cloak_of_Flames_itemicon_dota2_gameasset.png/60px-Cloak_of_Flames_itemicon_dota2_gameasset.png'),
('Gunpowder Gauntlet', 'neutral', 3, 3, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/e/ea/Gunpowder_Gauntlet_itemicon_dota2_gameasset.png/60px-Gunpowder_Gauntlet_itemicon_dota2_gameasset.png'),
('Jidi Pollen Bag', 'neutral', 3, 3, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/7/76/Jidi_Pollen_Bag_itemicon_dota2_gameasset.png/60px-Jidi_Pollen_Bag_itemicon_dota2_gameasset.png'),
('Partisan''s Brand', 'neutral', 3, 3, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/0/0f/Partisan%27s_Brand_itemicon_dota2_gameasset.png/60px-Partisan%27s_Brand_itemicon_dota2_gameasset.png'),
('Psychic Headband', 'neutral', 3, 3, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/3/3f/Psychic_Headband_itemicon_dota2_gameasset.png/60px-Psychic_Headband_itemicon_dota2_gameasset.png'),
('Serrated Shiv', 'neutral', 3, 3, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/5/55/Serrated_Shiv_itemicon_dota2_gameasset.png/60px-Serrated_Shiv_itemicon_dota2_gameasset.png'),
('Spellslinger', 'neutral', 3, 3, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/3/38/Spellslinger_itemicon_dota2_gameasset.png/60px-Spellslinger_itemicon_dota2_gameasset.png'),
('Stormcrafter', 'neutral', 3, 3, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/d/da/Stormcrafter_itemicon_dota2_gameasset.png/60px-Stormcrafter_itemicon_dota2_gameasset.png'),
('Unrelenting Eye', 'neutral', 3, 3, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/7/7c/Unrelenting_Eye_itemicon_dota2_gameasset.png/60px-Unrelenting_Eye_itemicon_dota2_gameasset.png'),
('Conjurer''s Catalyst', 'neutral', 4, 4, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/5/5c/Conjurer%27s_Catalyst_itemicon_dota2_gameasset.png/60px-Conjurer%27s_Catalyst_itemicon_dota2_gameasset.png'),
('Dandelion Amulet', 'neutral', 4, 4, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/e/eb/Dandelion_Amulet_itemicon_dota2_gameasset.png/60px-Dandelion_Amulet_itemicon_dota2_gameasset.png'),
('Enchanter''s Bauble', 'neutral', 4, 4, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/8/8f/Enchanter%27s_Bauble_itemicon_dota2_gameasset.png/60px-Enchanter%27s_Bauble_itemicon_dota2_gameasset.png'),
('Flayer''s Bota', 'neutral', 4, 4, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/a/ad/Flayer%27s_Bota_itemicon_dota2_gameasset.png/60px-Flayer%27s_Bota_itemicon_dota2_gameasset.png'),
('Giant''s Maul', 'neutral', 4, 4, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/8/89/Giant%27s_Maul_itemicon_dota2_gameasset.png/60px-Giant%27s_Maul_itemicon_dota2_gameasset.png'),
('Idol of Scree''Auk', 'neutral', 4, 4, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/5/58/Idol_of_Scree%27Auk_itemicon_dota2_gameasset.png/60px-Idol_of_Scree%27Auk_itemicon_dota2_gameasset.png'),
('Metamorphic Mandible', 'neutral', 4, 4, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/a/aa/Metamorphic_Mandible_itemicon_dota2_gameasset.png/60px-Metamorphic_Mandible_itemicon_dota2_gameasset.png'),
('Prophet''s Pendulum', 'neutral', 4, 4, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/c/cd/Prophet%27s_Pendulum_itemicon_dota2_gameasset.png/60px-Prophet%27s_Pendulum_itemicon_dota2_gameasset.png'),
('Rattlecage', 'neutral', 4, 4, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/e/ef/Rattlecage_itemicon_dota2_gameasset.png/60px-Rattlecage_itemicon_dota2_gameasset.png'),
('Book of the Dead', 'neutral', 5, 5, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/3/34/Book_of_the_Dead_itemicon_dota2_gameasset.png/60px-Book_of_the_Dead_itemicon_dota2_gameasset.png'),
('Dezun Bloodrite', 'neutral', 5, 5, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/9/96/Dezun_Bloodrite_itemicon_dota2_gameasset.png/60px-Dezun_Bloodrite_itemicon_dota2_gameasset.png'),
('Divine Regalia', 'neutral', 5, 5, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/a/ad/Divine_Regalia_itemicon_dota2_gameasset.png/60px-Divine_Regalia_itemicon_dota2_gameasset.png'),
('Fallen Sky', 'neutral', 5, 5, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/b/b8/Fallen_Sky_itemicon_dota2_gameasset.png/60px-Fallen_Sky_itemicon_dota2_gameasset.png'),
('Harmonizer', 'neutral', 5, 5, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/8/8f/Harmonizer_itemicon_dota2_gameasset.png/60px-Harmonizer_itemicon_dota2_gameasset.png'),
('Minotaur Horn', 'neutral', 5, 5, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/8/84/Minotaur_Horn_itemicon_dota2_gameasset.png/60px-Minotaur_Horn_itemicon_dota2_gameasset.png'),
('Riftshadow Prism', 'neutral', 5, 5, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/c/c1/Riftshadow_Prism_itemicon_dota2_gameasset.png/60px-Riftshadow_Prism_itemicon_dota2_gameasset.png'),
('Spider Legs', 'neutral', 5, 5, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/b/b0/Spider_Legs_itemicon_dota2_gameasset.png/60px-Spider_Legs_itemicon_dota2_gameasset.png'),
('Stygian Desolator', 'neutral', 5, 5, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/a/a9/Stygian_Desolator_itemicon_dota2_gameasset.png/60px-Stygian_Desolator_itemicon_dota2_gameasset.png'),
('Witchbane', 'neutral', 5, 5, NULL, NULL, 'https://liquipedia.net/commons/images/thumb/1/1e/Witchbane_itemicon_dota2_gameasset.png/60px-Witchbane_itemicon_dota2_gameasset.png'),
('Aghanim''s Blessing', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/7/79/Aghanim%27s_Blessing_itemicon_dota2_gameasset.png'),
('Block of Cheese', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/6/63/Block_of_Cheese_itemicon_dota2_gameasset.png'),
('Great Healing Lotus', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/a/a1/Great_Healing_Lotus_itemicon_dota2_gameasset.png'),
('Greater Healing Lotus', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/d/d8/Greater_Healing_Lotus_itemicon_dota2_gameasset.png'),
('Healing Lotus', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/0/05/Healing_Lotus_itemicon_dota2_gameasset.png'),
('Power Treads (Agility)', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/6/64/Power_Treads_%28Agility%29_itemicon_dota2_gameasset.png'),
('Power Treads (Intelligence)', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/5/58/Power_Treads_%28Intelligence%29_itemicon_dota2_gameasset.png'),
('Power Treads (Strength)', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/2/2d/Power_Treads_%28Strength%29_itemicon_dota2_gameasset.png'),
('Recipe', 'shop', NULL, NULL, 1, 1, 'https://liquipedia.net/commons/images/1/11/Recipe_itemicon_dota2_gameasset.png');
INSERT INTO tags (name) VALUES
('Attack Speed'),
('Aura'),
('Disable'),
('Dispel'),
('Economy'),
('Illusion / Summon'),
('Magic Damage'),
('Mobility'),
('Physical Damage'),
('Pure Damage'),
('Silence'),
('Slow'),
('Spell Resistance'),
('Survivability'),
('Sustain'),
('Vision');
INSERT INTO item_tags (item_id, tag_id)
SELECT i.id, t.id FROM (VALUES
('Town Portal Scroll', 'Economy'),
('Town Portal Scroll', 'Mobility'),
('Clarity', 'Dispel'),
('Clarity', 'Sustain'),
('Faerie Fire', 'Physical Damage'),
('Faerie Fire', 'Sustain'),
('Smoke of Deceit', 'Vision'),
('Observer Ward', 'Economy'),
('Observer Ward', 'Illusion / Summon'),
('Observer Ward', 'Sustain'),
('Observer Ward', 'Vision'),
('Sentry Ward', 'Disable'),
('Sentry Ward', 'Illusion / Summon'),
('Sentry Ward', 'Sustain'),
('Sentry Ward', 'Vision'),
('Enchanted Mango', 'Sustain'),
('Healing Salve', 'Dispel'),
('Healing Salve', 'Sustain'),
('Tango', 'Sustain'),
('Blood Grenade', 'Mobility'),
('Blood Grenade', 'Slow'),
('Blood Grenade', 'Sustain'),
('Dust of Appearance', 'Mobility'),
('Dust of Appearance', 'Slow'),
('Dust of Appearance', 'Vision'),
('Bottle', 'Dispel'),
('Bottle', 'Sustain'),
('Infused Raindrops', 'Magic Damage'),
('Infused Raindrops', 'Survivability'),
('Aghanim''s Shard', 'Illusion / Summon'),
('Ghost Scepter', 'Magic Damage'),
('Ghost Scepter', 'Physical Damage'),
('Ghost Scepter', 'Survivability'),
('Quelling Blade', 'Physical Damage'),
('Ring of Protection', 'Survivability'),
('Blades of Attack', 'Physical Damage'),
('Orb of Frost', 'Mobility'),
('Orb of Frost', 'Slow'),
('Orb of Frost', 'Sustain'),
('Gloves of Haste', 'Attack Speed'),
('Chainmail', 'Survivability'),
('Broadsword', 'Physical Damage'),
('Orb of Blight', 'Survivability'),
('Blitz Knuckles', 'Attack Speed'),
('Splintmail', 'Survivability'),
('Claymore', 'Physical Damage'),
('Claymore', 'Survivability'),
('Javelin', 'Magic Damage'),
('Javelin', 'Physical Damage'),
('Javelin', 'Survivability'),
('Helm of Iron Will', 'Survivability'),
('Helm of Iron Will', 'Sustain'),
('Mithril Hammer', 'Physical Damage'),
('Ring of Regen', 'Sustain'),
('Wind Lace', 'Mobility'),
('Magic Stick', 'Sustain'),
('Fluffy Hat', 'Sustain'),
('Boots of Speed', 'Mobility'),
('Voodoo Mask', 'Magic Damage'),
('Voodoo Mask', 'Sustain'),
('Shawl', 'Spell Resistance'),
('Shadow Amulet', 'Mobility'),
('Shadow Amulet', 'Vision'),
('Gem of True Sight', 'Disable'),
('Gem of True Sight', 'Vision'),
('Morbid Mask', 'Physical Damage'),
('Morbid Mask', 'Sustain'),
('Cloak', 'Dispel'),
('Cloak', 'Spell Resistance'),
('Blink Dagger', 'Mobility'),
('Magic Wand', 'Sustain'),
('Bracer', 'Sustain'),
('Wraith Band', 'Attack Speed'),
('Wraith Band', 'Survivability'),
('Power Treads', 'Attack Speed'),
('Power Treads', 'Mobility'),
('Soul Ring', 'Survivability'),
('Soul Ring', 'Sustain'),
('Orb of Corrosion', 'Mobility'),
('Orb of Corrosion', 'Slow'),
('Orb of Corrosion', 'Survivability'),
('Orb of Corrosion', 'Sustain'),
('Falcon Blade', 'Physical Damage'),
('Falcon Blade', 'Sustain'),
('Phase Boots', 'Mobility'),
('Phase Boots', 'Physical Damage'),
('Phase Boots', 'Survivability'),
('Perseverance', 'Sustain'),
('Mask of Madness', 'Attack Speed'),
('Mask of Madness', 'Physical Damage'),
('Mask of Madness', 'Slow'),
('Mask of Madness', 'Survivability'),
('Mask of Madness', 'Sustain'),
('Oblivion Staff', 'Attack Speed'),
('Boots of Travel', 'Mobility'),
('Boots of Travel 2', 'Mobility'),
('Hand of Midas', 'Attack Speed'),
('Hand of Midas', 'Economy'),
('Moon Shard', 'Attack Speed'),
('Moon Shard', 'Vision'),
('Soul Booster', 'Sustain'),
('Ring of Basilius', 'Aura'),
('Ring of Basilius', 'Sustain'),
('Headdress', 'Aura'),
('Headdress', 'Sustain'),
('Urn of Shadows', 'Dispel'),
('Urn of Shadows', 'Survivability'),
('Urn of Shadows', 'Sustain'),
('Tranquil Boots', 'Illusion / Summon'),
('Tranquil Boots', 'Mobility'),
('Tranquil Boots', 'Sustain'),
('Arcane Boots', 'Aura'),
('Arcane Boots', 'Mobility'),
('Arcane Boots', 'Sustain'),
('Mekansm', 'Aura'),
('Mekansm', 'Survivability'),
('Mekansm', 'Sustain'),
('Essence Distiller', 'Dispel'),
('Essence Distiller', 'Survivability'),
('Essence Distiller', 'Sustain'),
('Essence Distiller', 'Vision'),
('Drum of Endurance', 'Attack Speed'),
('Drum of Endurance', 'Aura'),
('Drum of Endurance', 'Mobility'),
('Pavise', 'Physical Damage'),
('Pavise', 'Survivability'),
('Pavise', 'Sustain'),
('Guardian Greaves', 'Aura'),
('Guardian Greaves', 'Dispel'),
('Guardian Greaves', 'Mobility'),
('Guardian Greaves', 'Survivability'),
('Guardian Greaves', 'Sustain'),
('Spirit Vessel', 'Dispel'),
('Spirit Vessel', 'Survivability'),
('Spirit Vessel', 'Sustain'),
('Boots of Bearing', 'Attack Speed'),
('Boots of Bearing', 'Aura'),
('Boots of Bearing', 'Mobility'),
('Boots of Bearing', 'Slow'),
('Boots of Bearing', 'Sustain'),
('Solar Crest', 'Attack Speed'),
('Solar Crest', 'Mobility'),
('Solar Crest', 'Physical Damage'),
('Solar Crest', 'Survivability'),
('Solar Crest', 'Sustain'),
('Glimmer Cape', 'Illusion / Summon'),
('Glimmer Cape', 'Magic Damage'),
('Glimmer Cape', 'Mobility'),
('Glimmer Cape', 'Spell Resistance'),
('Glimmer Cape', 'Survivability'),
('Glimmer Cape', 'Vision'),
('Holy Locket', 'Sustain'),
('Pipe of Insight', 'Aura'),
('Pipe of Insight', 'Magic Damage'),
('Pipe of Insight', 'Spell Resistance'),
('Pipe of Insight', 'Survivability'),
('Pipe of Insight', 'Sustain'),
('Veil of Discord', 'Magic Damage'),
('Veil of Discord', 'Sustain'),
('Rod of Atos', 'Disable'),
('Rod of Atos', 'Sustain'),
('Eul''s Scepter of Divinity', 'Dispel'),
('Eul''s Scepter of Divinity', 'Mobility'),
('Eul''s Scepter of Divinity', 'Survivability'),
('Phylactery', 'Magic Damage'),
('Phylactery', 'Mobility'),
('Phylactery', 'Slow'),
('Phylactery', 'Sustain'),
('Bloodstone', 'Aura'),
('Bloodstone', 'Magic Damage'),
('Bloodstone', 'Sustain'),
('Gleipnir', 'Disable'),
('Gleipnir', 'Sustain'),
('Wind Waker', 'Dispel'),
('Wind Waker', 'Mobility'),
('Wind Waker', 'Survivability'),
('Khanda', 'Magic Damage'),
('Khanda', 'Mobility'),
('Khanda', 'Slow'),
('Khanda', 'Sustain'),
('Force Staff', 'Sustain'),
('Meteor Hammer', 'Disable'),
('Meteor Hammer', 'Illusion / Summon'),
('Meteor Hammer', 'Magic Damage'),
('Meteor Hammer', 'Mobility'),
('Meteor Hammer', 'Slow'),
('Dagon', 'Illusion / Summon'),
('Dagon', 'Magic Damage'),
('Dagon', 'Sustain'),
('Dagon 2', 'Illusion / Summon'),
('Dagon 2', 'Magic Damage'),
('Dagon 2', 'Sustain'),
('Dagon 3', 'Illusion / Summon'),
('Dagon 3', 'Magic Damage'),
('Dagon 3', 'Sustain'),
('Dagon 4', 'Illusion / Summon'),
('Dagon 4', 'Magic Damage'),
('Dagon 4', 'Sustain'),
('Dagon 5', 'Illusion / Summon'),
('Dagon 5', 'Magic Damage'),
('Dagon 5', 'Sustain'),
('Aghanim''s Scepter', 'Illusion / Summon'),
('Aghanim''s Scepter', 'Sustain'),
('Crella''s Crozier', 'Aura'),
('Crella''s Crozier', 'Magic Damage'),
('Crella''s Crozier', 'Mobility'),
('Crella''s Crozier', 'Physical Damage'),
('Crella''s Crozier', 'Sustain'),
('Octarine Core', 'Sustain'),
('Orchid Malevolence', 'Attack Speed'),
('Orchid Malevolence', 'Magic Damage'),
('Orchid Malevolence', 'Physical Damage'),
('Orchid Malevolence', 'Silence'),
('Refresher Orb', 'Sustain'),
('Ethereal Blade', 'Magic Damage'),
('Ethereal Blade', 'Mobility'),
('Ethereal Blade', 'Physical Damage'),
('Ethereal Blade', 'Slow'),
('Scythe of Vyse', 'Disable'),
('Scythe of Vyse', 'Illusion / Summon'),
('Bloodthorn', 'Attack Speed'),
('Bloodthorn', 'Magic Damage'),
('Bloodthorn', 'Physical Damage'),
('Bloodthorn', 'Silence'),
('Bloodthorn', 'Survivability'),
('Buckler', 'Aura'),
('Buckler', 'Survivability'),
('Vanguard', 'Physical Damage'),
('Vanguard', 'Survivability'),
('Vanguard', 'Sustain'),
('Blade Mail', 'Physical Damage'),
('Blade Mail', 'Survivability'),
('Helm of the Dominator', 'Economy'),
('Helm of the Dominator', 'Survivability'),
('Helm of the Dominator', 'Sustain'),
('Vladmir''s Offering', 'Aura'),
('Vladmir''s Offering', 'Physical Damage'),
('Vladmir''s Offering', 'Survivability'),
('Vladmir''s Offering', 'Sustain'),
('Crimson Guard', 'Physical Damage'),
('Crimson Guard', 'Survivability'),
('Crimson Guard', 'Sustain'),
('Armlet of Mordiggian', 'Attack Speed'),
('Armlet of Mordiggian', 'Physical Damage'),
('Armlet of Mordiggian', 'Survivability'),
('Armlet of Mordiggian', 'Sustain'),
('Helm of the Overlord', 'Economy'),
('Helm of the Overlord', 'Survivability'),
('Helm of the Overlord', 'Sustain'),
('Consecrated Wraps', 'Mobility'),
('Consecrated Wraps', 'Spell Resistance'),
('Consecrated Wraps', 'Survivability'),
('Consecrated Wraps', 'Sustain'),
('Aeon Disk', 'Dispel'),
('Aeon Disk', 'Sustain'),
('Lotus Orb', 'Dispel'),
('Lotus Orb', 'Survivability'),
('Lotus Orb', 'Sustain'),
('Black King Bar', 'Dispel'),
('Black King Bar', 'Physical Damage'),
('Black King Bar', 'Pure Damage'),
('Black King Bar', 'Spell Resistance'),
('Shiva''s Guard', 'Attack Speed'),
('Shiva''s Guard', 'Aura'),
('Shiva''s Guard', 'Mobility'),
('Shiva''s Guard', 'Slow'),
('Shiva''s Guard', 'Survivability'),
('Linken''s Sphere', 'Sustain'),
('Assault Cuirass', 'Attack Speed'),
('Assault Cuirass', 'Aura'),
('Assault Cuirass', 'Survivability'),
('Heart of Tarrasque', 'Sustain'),
('Crystalys', 'Physical Damage'),
('Crystalys', 'Survivability'),
('Mage Slayer', 'Magic Damage'),
('Mage Slayer', 'Physical Damage'),
('Mage Slayer', 'Spell Resistance'),
('Mage Slayer', 'Sustain'),
('Heaven''s Halberd', 'Survivability'),
('Heaven''s Halberd', 'Sustain'),
('Desolator', 'Physical Damage'),
('Desolator', 'Survivability'),
('Revenant''s Brooch', 'Magic Damage'),
('Revenant''s Brooch', 'Physical Damage'),
('Revenant''s Brooch', 'Sustain'),
('Skull Basher', 'Disable'),
('Skull Basher', 'Physical Damage'),
('Maelstrom', 'Attack Speed'),
('Maelstrom', 'Magic Damage'),
('Maelstrom', 'Mobility'),
('Maelstrom', 'Physical Damage'),
('Maelstrom', 'Survivability'),
('Shadow Blade', 'Attack Speed'),
('Shadow Blade', 'Mobility'),
('Shadow Blade', 'Physical Damage'),
('Shadow Blade', 'Vision'),
('Daedalus', 'Physical Damage'),
('Abyssal Blade', 'Disable'),
('Abyssal Blade', 'Physical Damage'),
('Abyssal Blade', 'Slow'),
('Abyssal Blade', 'Sustain'),
('Mjollnir', 'Attack Speed'),
('Mjollnir', 'Magic Damage'),
('Mjollnir', 'Mobility'),
('Mjollnir', 'Physical Damage'),
('Mjollnir', 'Survivability'),
('Silver Edge', 'Attack Speed'),
('Silver Edge', 'Mobility'),
('Silver Edge', 'Physical Damage'),
('Silver Edge', 'Vision'),
('Battle Fury', 'Physical Damage'),
('Battle Fury', 'Sustain'),
('Nullifier', 'Dispel'),
('Nullifier', 'Mobility'),
('Nullifier', 'Physical Damage'),
('Nullifier', 'Slow'),
('Nullifier', 'Survivability'),
('Manta Style', 'Attack Speed'),
('Manta Style', 'Illusion / Summon'),
('Manta Style', 'Mobility'),
('Radiance', 'Illusion / Summon'),
('Radiance', 'Magic Damage'),
('Radiance', 'Physical Damage'),
('Radiance', 'Survivability'),
('Divine Rapier', 'Magic Damage'),
('Divine Rapier', 'Physical Damage'),
('Monkey King Bar', 'Attack Speed'),
('Monkey King Bar', 'Magic Damage'),
('Monkey King Bar', 'Physical Damage'),
('Monkey King Bar', 'Spell Resistance'),
('Monkey King Bar', 'Survivability'),
('Butterfly', 'Attack Speed'),
('Butterfly', 'Physical Damage'),
('Butterfly', 'Survivability'),
('Satanic', 'Dispel'),
('Satanic', 'Physical Damage'),
('Satanic', 'Sustain'),
('Sange', 'Slow'),
('Sange', 'Sustain'),
('Yasha', 'Attack Speed'),
('Yasha', 'Mobility'),
('Kaya', 'Magic Damage'),
('Kaya and Sange', 'Magic Damage'),
('Kaya and Sange', 'Slow'),
('Kaya and Sange', 'Sustain'),
('Sange and Yasha', 'Attack Speed'),
('Sange and Yasha', 'Mobility'),
('Sange and Yasha', 'Slow'),
('Sange and Yasha', 'Sustain'),
('Yasha and Kaya', 'Attack Speed'),
('Yasha and Kaya', 'Magic Damage'),
('Yasha and Kaya', 'Mobility'),
('Hurricane Pike', 'Attack Speed'),
('Hurricane Pike', 'Sustain'),
('Echo Sabre', 'Attack Speed'),
('Echo Sabre', 'Mobility'),
('Echo Sabre', 'Physical Damage'),
('Echo Sabre', 'Slow'),
('Diffusal Blade', 'Mobility'),
('Diffusal Blade', 'Slow'),
('Witch Blade', 'Attack Speed'),
('Witch Blade', 'Mobility'),
('Witch Blade', 'Slow'),
('Witch Blade', 'Survivability'),
('Specialist''s Array', 'Physical Damage'),
('Harpoon', 'Attack Speed'),
('Harpoon', 'Mobility'),
('Harpoon', 'Physical Damage'),
('Harpoon', 'Slow'),
('Disperser', 'Dispel'),
('Disperser', 'Mobility'),
('Disperser', 'Slow'),
('Parasma', 'Attack Speed'),
('Parasma', 'Mobility'),
('Parasma', 'Slow'),
('Parasma', 'Spell Resistance'),
('Parasma', 'Survivability'),
('Hydra''s Breath', 'Physical Damage'),
('Overwhelming Blink', 'Attack Speed'),
('Overwhelming Blink', 'Mobility'),
('Overwhelming Blink', 'Slow'),
('Swift Blink', 'Mobility'),
('Arcane Blink', 'Mobility'),
('Arcane Blink', 'Sustain'),
('Eye of Skadi', 'Attack Speed'),
('Eye of Skadi', 'Mobility'),
('Eye of Skadi', 'Slow'),
('Eye of Skadi', 'Sustain'),
('Ring of Health', 'Sustain'),
('Talisman of Evasion', 'Survivability'),
('Platemail', 'Survivability'),
('Ring of Tarrasque', 'Sustain'),
('Hyperstone', 'Attack Speed'),
('Demon Edge', 'Physical Damage'),
('Vitality Booster', 'Sustain'),
('Point Booster', 'Sustain'),
('Sacred Relic', 'Physical Damage'),
('Aegis of the Immortal', 'Survivability'),
('Aegis of the Immortal', 'Sustain'),
('Cheese', 'Sustain'),
('Roshan''s Banner', 'Aura'),
('Roshan''s Banner', 'Survivability'),
('Ash Legion Shield', 'Mobility'),
('Ash Legion Shield', 'Physical Damage'),
('Ash Legion Shield', 'Survivability'),
('Dagger of Ristul', 'Sustain'),
('Duelist Gloves', 'Attack Speed'),
('Forager''s Kit', 'Sustain'),
('Kobold Cup', 'Mobility'),
('Occult Bracelet', 'Disable'),
('Pollywog Charm', 'Dispel'),
('Pollywog Charm', 'Sustain'),
('Possessed Mask', 'Sustain'),
('Stonefeather Satchel', 'Mobility'),
('Stonefeather Satchel', 'Survivability'),
('Weighted Dice', 'Economy'),
('Weighted Dice', 'Physical Damage'),
('Crippling Crossbow', 'Slow'),
('Crippling Crossbow', 'Sustain'),
('Essence Ring', 'Sustain'),
('Mana Draught', 'Dispel'),
('Mana Draught', 'Sustain'),
('Medallion of Courage', 'Disable'),
('Medallion of Courage', 'Survivability'),
('Poor Man''s Shield', 'Physical Damage'),
('Poor Man''s Shield', 'Survivability'),
('Searing Signet', 'Magic Damage'),
('Seeds of Serenity', 'Sustain'),
('Tumbler''s Toy', 'Mobility'),
('Cloak of Flames', 'Illusion / Summon'),
('Cloak of Flames', 'Magic Damage'),
('Gunpowder Gauntlet', 'Magic Damage'),
('Gunpowder Gauntlet', 'Physical Damage'),
('Jidi Pollen Bag', 'Sustain'),
('Partisan''s Brand', 'Magic Damage'),
('Serrated Shiv', 'Physical Damage'),
('Serrated Shiv', 'Sustain'),
('Stormcrafter', 'Magic Damage'),
('Stormcrafter', 'Mobility'),
('Stormcrafter', 'Slow'),
('Unrelenting Eye', 'Slow'),
('Conjurer''s Catalyst', 'Magic Damage'),
('Dandelion Amulet', 'Magic Damage'),
('Dandelion Amulet', 'Survivability'),
('Flayer''s Bota', 'Attack Speed'),
('Flayer''s Bota', 'Physical Damage'),
('Giant''s Maul', 'Slow'),
('Idol of Scree''Auk', 'Slow'),
('Idol of Scree''Auk', 'Survivability'),
('Metamorphic Mandible', 'Mobility'),
('Metamorphic Mandible', 'Spell Resistance'),
('Metamorphic Mandible', 'Survivability'),
('Rattlecage', 'Attack Speed'),
('Rattlecage', 'Slow'),
('Book of the Dead', 'Aura'),
('Book of the Dead', 'Dispel'),
('Book of the Dead', 'Illusion / Summon'),
('Book of the Dead', 'Magic Damage'),
('Book of the Dead', 'Mobility'),
('Book of the Dead', 'Physical Damage'),
('Book of the Dead', 'Slow'),
('Book of the Dead', 'Vision'),
('Dezun Bloodrite', 'Sustain'),
('Fallen Sky', 'Disable'),
('Harmonizer', 'Magic Damage'),
('Minotaur Horn', 'Dispel'),
('Minotaur Horn', 'Spell Resistance'),
('Riftshadow Prism', 'Illusion / Summon'),
('Riftshadow Prism', 'Sustain'),
('Spider Legs', 'Mobility'),
('Stygian Desolator', 'Survivability'),
('Witchbane', 'Dispel'),
('Witchbane', 'Physical Damage'),
('Aghanim''s Blessing', 'Illusion / Summon'),
('Aghanim''s Blessing', 'Sustain'),
('Block of Cheese', 'Survivability'),
('Block of Cheese', 'Sustain'),
('Great Healing Lotus', 'Sustain'),
('Greater Healing Lotus', 'Sustain'),
('Healing Lotus', 'Sustain'),
('Power Treads (Agility)', 'Attack Speed'),
('Power Treads (Agility)', 'Mobility'),
('Power Treads (Intelligence)', 'Attack Speed'),
('Power Treads (Intelligence)', 'Mobility'),
('Power Treads (Strength)', 'Attack Speed'),
('Power Treads (Strength)', 'Mobility')
) AS pairs(item_name, tag_name)
JOIN items i ON i.name = pairs.item_name
JOIN tags t ON t.name = pairs.tag_name;
@@ -0,0 +1,2 @@
DELETE FROM enchantment_attributes;
DELETE FROM enchantments;
@@ -0,0 +1,72 @@
DELETE FROM enchantment_attributes;
DELETE FROM enchantments;
INSERT INTO enchantments (name, min_tier, max_tier, image_url) VALUES
('Vital Enchantment', 1, 1, 'https://liquipedia.net/commons/images/thumb/8/8b/Vital_Enchantment_itemicon_dota2_gameasset.png/60px-Vital_Enchantment_itemicon_dota2_gameasset.png'),
('Alert Enchantment', 1, 4, 'https://liquipedia.net/commons/images/thumb/0/0b/Alert_Enchantment_itemicon_dota2_gameasset.png/60px-Alert_Enchantment_itemicon_dota2_gameasset.png'),
('Brawny Enchantment', 1, 4, 'https://liquipedia.net/commons/images/thumb/4/4e/Brawny_Enchantment_itemicon_dota2_gameasset.png/60px-Brawny_Enchantment_itemicon_dota2_gameasset.png'),
('Mystical Enchantment', 1, 4, 'https://liquipedia.net/commons/images/thumb/0/08/Mystical_Enchantment_itemicon_dota2_gameasset.png/60px-Mystical_Enchantment_itemicon_dota2_gameasset.png'),
('Quickened Enchantment', 1, 4, 'https://liquipedia.net/commons/images/thumb/8/84/Quickened_Enchantment_itemicon_dota2_gameasset.png/60px-Quickened_Enchantment_itemicon_dota2_gameasset.png'),
('Tough Enchantment', 1, 4, 'https://liquipedia.net/commons/images/thumb/1/1b/Tough_Enchantment_itemicon_dota2_gameasset.png/60px-Tough_Enchantment_itemicon_dota2_gameasset.png'),
('Greedy Enchantment', 2, 3, 'https://liquipedia.net/commons/images/thumb/e/ee/Greedy_Enchantment_itemicon_dota2_gameasset.png/60px-Greedy_Enchantment_itemicon_dota2_gameasset.png'),
('Crude Enchantment', 2, 4, 'https://liquipedia.net/commons/images/thumb/6/66/Crude_Enchantment_itemicon_dota2_gameasset.png/60px-Crude_Enchantment_itemicon_dota2_gameasset.png'),
('Keen-Eyed Enchantment', 2, 4, 'https://liquipedia.net/commons/images/thumb/0/01/Keen-Eyed_Enchantment_itemicon_dota2_gameasset.png/60px-Keen-Eyed_Enchantment_itemicon_dota2_gameasset.png'),
('Nimble Enchantment', 2, 4, 'https://liquipedia.net/commons/images/thumb/b/be/Nimble_Enchantment_itemicon_dota2_gameasset.png/60px-Nimble_Enchantment_itemicon_dota2_gameasset.png'),
('Titanic Enchantment', 2, 4, 'https://liquipedia.net/commons/images/thumb/7/70/Titanic_Enchantment_itemicon_dota2_gameasset.png/60px-Titanic_Enchantment_itemicon_dota2_gameasset.png'),
('Timeless Enchantment', 4, 5, 'https://liquipedia.net/commons/images/thumb/5/58/Timeless_Enchantment_itemicon_dota2_gameasset.png/60px-Timeless_Enchantment_itemicon_dota2_gameasset.png'),
('Audacious Enchantment', 5, 5, 'https://liquipedia.net/commons/images/thumb/5/58/Audacious_Enchantment_itemicon_dota2_gameasset.png/60px-Audacious_Enchantment_itemicon_dota2_gameasset.png'),
('Evolved Enchantment', 5, 5, 'https://liquipedia.net/commons/images/thumb/4/4e/Evolved_Enchantment_itemicon_dota2_gameasset.png/60px-Evolved_Enchantment_itemicon_dota2_gameasset.png'),
('Feverish Enchantment', 5, 5, 'https://liquipedia.net/commons/images/thumb/5/50/Feverish_Enchantment_itemicon_dota2_gameasset.png/60px-Feverish_Enchantment_itemicon_dota2_gameasset.png'),
('Fleetfooted Enchantment', 5, 5, 'https://liquipedia.net/commons/images/thumb/b/b5/Fleetfooted_Enchantment_itemicon_dota2_gameasset.png/60px-Fleetfooted_Enchantment_itemicon_dota2_gameasset.png'),
('Hulking Enchantment', 5, 5, 'https://liquipedia.net/commons/images/thumb/3/3f/Hulking_Enchantment_itemicon_dota2_gameasset.png/60px-Hulking_Enchantment_itemicon_dota2_gameasset.png'),
('Manic Enchantment', 5, 5, 'https://liquipedia.net/commons/images/thumb/c/cf/Manic_Enchantment_itemicon_dota2_gameasset.png/60px-Manic_Enchantment_itemicon_dota2_gameasset.png'),
('Vampiric Enchantment', 5, 5, 'https://liquipedia.net/commons/images/thumb/9/91/Vampiric_Enchantment_itemicon_dota2_gameasset.png/60px-Vampiric_Enchantment_itemicon_dota2_gameasset.png');
INSERT INTO enchantment_attributes (enchantment_id, attribute)
SELECT e.id, pairs.attribute::primary_attribute FROM (VALUES
('Vital Enchantment', 'agility'),
('Vital Enchantment', 'intelligence'),
('Vital Enchantment', 'strength'),
('Vital Enchantment', 'universal'),
('Alert Enchantment', 'agility'),
('Alert Enchantment', 'universal'),
('Brawny Enchantment', 'agility'),
('Brawny Enchantment', 'strength'),
('Mystical Enchantment', 'intelligence'),
('Mystical Enchantment', 'universal'),
('Quickened Enchantment', 'agility'),
('Quickened Enchantment', 'intelligence'),
('Quickened Enchantment', 'strength'),
('Quickened Enchantment', 'universal'),
('Tough Enchantment', 'intelligence'),
('Tough Enchantment', 'strength'),
('Greedy Enchantment', 'agility'),
('Greedy Enchantment', 'intelligence'),
('Greedy Enchantment', 'strength'),
('Greedy Enchantment', 'universal'),
('Crude Enchantment', 'strength'),
('Keen-Eyed Enchantment', 'intelligence'),
('Nimble Enchantment', 'agility'),
('Titanic Enchantment', 'universal'),
('Timeless Enchantment', 'agility'),
('Timeless Enchantment', 'intelligence'),
('Timeless Enchantment', 'strength'),
('Timeless Enchantment', 'universal'),
('Audacious Enchantment', 'agility'),
('Evolved Enchantment', 'agility'),
('Evolved Enchantment', 'intelligence'),
('Evolved Enchantment', 'strength'),
('Evolved Enchantment', 'universal'),
('Feverish Enchantment', 'intelligence'),
('Fleetfooted Enchantment', 'agility'),
('Fleetfooted Enchantment', 'intelligence'),
('Fleetfooted Enchantment', 'strength'),
('Fleetfooted Enchantment', 'universal'),
('Hulking Enchantment', 'strength'),
('Manic Enchantment', 'universal'),
('Vampiric Enchantment', 'agility'),
('Vampiric Enchantment', 'intelligence'),
('Vampiric Enchantment', 'strength'),
('Vampiric Enchantment', 'universal')
) AS pairs(enchantment_name, attribute)
JOIN enchantments e ON e.name = pairs.enchantment_name;
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""
Scrapes the current Dota 2 hero roster from Liquipedia and writes an sqlx
migration that seeds the `heroes` table.
Requires:
pip install requests beautifulsoup4
Respects Liquipedia's API Terms of Use (https://liquipedia.net/api-terms-of-use):
- action=query requests: max 1 per 2 seconds (used for batched hero data)
- action=parse requests: max 1 per 30 seconds (used exactly once, for the hero grid)
- Requires a descriptive User-Agent with real contact info -- EDIT USER_AGENT BELOW.
"""
import re
import sys
import time
from pathlib import Path
import requests
from bs4 import BeautifulSoup
# --- EDIT THIS before running: Liquipedia requires real contact info in the User-Agent ---
USER_AGENT = "DotaRandomWebHeroScraper/1.0 (contact: YOUR_EMAIL_HERE)"
API_BASE = "https://liquipedia.net/dota2/api.php"
HEADERS = {"User-Agent": USER_AGENT, "Accept-Encoding": "gzip"}
# This script lives in <project_root>/scripts/, migrations live in <project_root>/migrations/
MIGRATIONS_DIR = Path(__file__).resolve().parent.parent / "migrations"
QUERY_BATCH_SIZE = 50 # MediaWiki's default action=query title batch limit
GENERAL_RATE_LIMIT_SECONDS = 2 # applies between action=query batches
def fetch_hero_list():
"""Get the current hero roster (name + image URL) from the Portal:Heroes grid."""
params = {"action": "parse", "page": "Portal:Heroes", "format": "json", "prop": "text"}
resp = requests.get(API_BASE, params=params, headers=HEADERS, timeout=30)
resp.raise_for_status()
html = resp.json()["parse"]["text"]["*"]
soup = BeautifulSoup(html, "html.parser")
heroes = []
for card in soup.select("div.heroes-panel__hero-card"):
link = card.select_one(".heroes-panel__hero-card__title a")
img = card.select_one("img")
if not link or not img:
continue
name = link.get("title") or link.text.strip()
heroes.append({"name": name, "image_url": "https://liquipedia.net" + img["src"]})
return heroes
def fetch_hero_infoboxes(names):
"""Batch-fetch raw wikitext for hero pages via action=query (cheap, not the expensive parse action)."""
infoboxes = {}
for i in range(0, len(names), QUERY_BATCH_SIZE):
batch = names[i:i + QUERY_BATCH_SIZE]
params = {
"action": "query",
"prop": "revisions",
"rvslots": "main",
"rvprop": "content",
"titles": "|".join(batch),
"format": "json",
}
resp = requests.get(API_BASE, params=params, headers=HEADERS, timeout=30)
resp.raise_for_status()
for page in resp.json()["query"]["pages"].values():
title = page.get("title")
revisions = page.get("revisions")
if not revisions:
print(f" WARNING: no content found for '{title}', skipping", file=sys.stderr)
continue
infoboxes[title] = revisions[0]["slots"]["main"]["*"]
if i + QUERY_BATCH_SIZE < len(names):
time.sleep(GENERAL_RATE_LIMIT_SECONDS)
return infoboxes
def parse_infobox_field(wikitext, field):
match = re.search(rf"\|\s*{re.escape(field)}\s*=\s*(.+)", wikitext)
return match.group(1).strip() if match else None
def sql_escape(value):
return value.replace("'", "''")
def write_migration(rows):
timestamp = time.strftime("%Y%m%d%H%M%S")
up_path = MIGRATIONS_DIR / f"{timestamp}_seed_heroes.up.sql"
down_path = MIGRATIONS_DIR / f"{timestamp}_seed_heroes.down.sql"
values = ",\n".join(
f" ('{sql_escape(r['name'])}', '{r['primary_attribute']}', '{r['attack_type']}', '{sql_escape(r['image_url'])}')"
for r in rows
)
up_sql = f"INSERT INTO heroes (name, primary_attribute, attack_type, image_url) VALUES\n{values};\n"
down_sql = "DELETE FROM heroes;\n"
MIGRATIONS_DIR.mkdir(exist_ok=True)
up_path.write_text(up_sql, encoding="utf-8")
down_path.write_text(down_sql, encoding="utf-8")
print(f"\nWrote migration:\n {up_path}\n {down_path}")
print("Run `sqlx migrate run` from the project root to apply it.")
def main():
if "YOUR_EMAIL_HERE" in USER_AGENT:
sys.exit("Edit USER_AGENT at the top of this script with real contact info before running (Liquipedia's API terms require it).")
print("Fetching current hero roster from Portal:Heroes ...")
heroes = fetch_hero_list()
print(f" Found {len(heroes)} heroes on the current grid.")
names = [h["name"] for h in heroes]
print("Fetching hero infoboxes in batches ...")
infoboxes = fetch_hero_infoboxes(names)
rows = []
skipped = []
for hero in heroes:
wikitext = infoboxes.get(hero["name"])
if not wikitext:
skipped.append(hero["name"])
continue
primary = parse_infobox_field(wikitext, "primary")
rangetype = parse_infobox_field(wikitext, "rangetype")
if not primary or not rangetype:
skipped.append(hero["name"])
continue
rows.append({
"name": hero["name"],
"primary_attribute": primary.lower(),
"attack_type": rangetype.lower(),
"image_url": hero["image_url"],
})
print(f" Parsed {len(rows)} heroes successfully.")
if skipped:
print(f" Skipped {len(skipped)} heroes (missing infobox data): {', '.join(skipped)}")
write_migration(rows)
if __name__ == "__main__":
main()
+622
View File
@@ -0,0 +1,622 @@
#!/usr/bin/env python3
"""
Scrapes the current Dota 2 item roster (shop items, active neutral artifacts,
and active neutral enchantments) from Liquipedia and writes sqlx migrations
that seed `items` / `tags` / `item_tags` and `enchantments` / `enchantment_attributes`.
Requires:
pip install requests beautifulsoup4
Respects Liquipedia's API Terms of Use (https://liquipedia.net/api-terms-of-use):
- action=query requests: max 1 per 2 seconds (used for batched item data)
- action=parse requests: max 1 per 30 seconds (used exactly twice: the shop
item grid, and the neutral items page)
- Requires a descriptive User-Agent with real contact info -- EDIT USER_AGENT BELOW.
How "is this actually in the game right now" is decided: every item's own
infobox carries a `game = Dota 2 Removed` / `game = Dota 2 Unreleased` field
when it isn't in normal circulation, and omits the field entirely when it is.
That single field is checked for every shop item, neutral artifact, and
enchantment -- it's far more reliable than inferring status from which page
section something is listed under (which is what earlier versions of this
script did, and which doesn't cover shop items at all).
Note on "purpose" tags: Liquipedia doesn't have a structured "purpose" field
for items, so tags are inferred heuristically from the item's stat bonuses
and ability descriptions (see TAG_KEYWORDS below). This is a best-effort
classifier, not ground truth -- review the generated migration and adjust
TAG_KEYWORDS or the resulting SQL if something looks wrong.
"""
import re
import sys
import time
from pathlib import Path
import requests
from bs4 import BeautifulSoup
# --- EDIT THIS before running: Liquipedia requires real contact info in the User-Agent ---
USER_AGENT = "DotaRandomWebItemScraper/1.0 (contact: YOUR_EMAIL_HERE)"
API_BASE = "https://liquipedia.net/dota2/api.php"
HEADERS = {"User-Agent": USER_AGENT, "Accept-Encoding": "gzip"}
MIGRATIONS_DIR = Path(__file__).resolve().parent.parent / "migrations"
QUERY_BATCH_SIZE = 50
GENERAL_RATE_LIMIT_SECONDS = 2
PARSE_RATE_LIMIT_SECONDS = 30
VALID_ATTRIBUTES = {"strength", "agility", "intelligence", "universal"}
# Real item pages that only exist as a side-effect of a specific hero
# ability or a specific other neutral item (e.g. Ironwood Nut/Bag of Gold
# come from particular neutral items, not from anything a random hero would
# have) -- not something a "random build" should ever hand out as a normal
# rolled item. Curated by hand; extend if more of these turn up.
EXCLUDED_ITEM_NAMES = {
"Tango (Shared)",
"Ironwood Nut",
"Bag of Gold",
"Vital Toadstool",
"Tomo'kan Ringcap",
}
# Keyword -> tag mapping. Matched case-insensitively against a blob built from
# each item's infobox stat-bonus field names, active/passive ability names,
# ability descriptions, and lore text. An item can (and often will) get
# several tags.
TAG_KEYWORDS = {
"Physical Damage": ["attack damage", "physical damage", "cleave", "critical strike"],
"Magic Damage": ["spell amplification", "spell damage", "magical damage", "magic damage"],
"Pure Damage": ["pure damage"],
"Attack Speed": ["attack speed"],
"Survivability": ["evasion", "evade", "damage block", "spell immunity", "invulnerable", "shield", "barrier", "armor"],
"Sustain": ["health regeneration", "hp regeneration", "mana regeneration", "lifesteal", "life steal", "heal"],
"Mobility": ["movement speed", "blink", "teleport", "dash", "leap"],
"Disable": ["stun", "root", "hex", "fear", "sleep", "entangle"],
"Silence": ["silence"],
"Slow": ["slow"],
"Vision": ["true sight", "vision", "invisible", "invisibility"],
"Dispel": ["dispel", "purge"],
"Aura": ["aura"],
"Illusion / Summon": ["illusion", "summon"],
"Economy": ["bounty", "gold "],
"Spell Resistance": ["magic resistance", "spell resistance"],
}
def fetch_shop_candidates():
"""Get all shop/purchasable item candidates (name + image) from the Item Grid page."""
params = {"action": "parse", "page": "Item_Grid", "format": "json", "prop": "text"}
resp = requests.get(API_BASE, params=params, headers=HEADERS, timeout=30)
resp.raise_for_status()
html = resp.json()["parse"]["text"]["*"]
soup = BeautifulSoup(html, "html.parser")
candidates = []
for li in soup.select("li"):
a = li.find("a", recursive=False)
if not a or not a.get("href", "").startswith("/dota2/"):
continue
img = a.find("img")
if not img:
continue
title = a.get("title", "").strip()
name = re.sub(r"\s*\(\d+\)$", "", title) # strip trailing " (cost)"
if not name:
continue
candidates.append({"name": name, "image_url": "https://liquipedia.net" + img["src"]})
return candidates
def fetch_neutral_candidates():
"""
Get every neutral item/enchantment candidate (name + image) from the
Neutral Items page, regardless of section. Active-vs-removed status,
tier, and artifact-vs-enchantment category all come from each
candidate's own infobox later (see parse_item_fields) rather than from
page layout, so we don't need to filter by heading here.
"""
params = {"action": "parse", "page": "Neutral_Items", "format": "json", "prop": "text"}
resp = requests.get(API_BASE, params=params, headers=HEADERS, timeout=30)
resp.raise_for_status()
html = resp.json()["parse"]["text"]["*"]
soup = BeautifulSoup(html, "html.parser")
candidates = []
seen = set()
for itemlist in soup.select("div.itemlist"):
for a in itemlist.select("a:has(img)"):
img = a.find("img")
if not img:
continue
name = a.get("title", "").strip()
if not name or name in seen:
continue
seen.add(name)
candidates.append({"name": name, "image_url": "https://liquipedia.net" + img["src"]})
return candidates
def fetch_category_item_names():
"""The complete, authoritative list of every item page on the wiki.
Item_Grid and Neutral_Items are curated listing pages and can simply omit
something real (e.g. Block of Cheese isn't on Item_Grid at all, despite
being a normal active item) -- this category membership list can't miss
anything, since every item page is tagged with it regardless of which
curated list remembered to mention it.
cmnamespace=0 restricts to the main article namespace -- without it,
miscategorized non-item pages sneak in too (e.g. Template:Item infobox/doc,
the template's own documentation page, is a member of this category despite
obviously not being an item)."""
params = {
"action": "query",
"list": "categorymembers",
"cmtitle": "Category:Items",
"cmlimit": "500",
"cmnamespace": "0",
"format": "json",
}
resp = requests.get(API_BASE, params=params, headers=HEADERS, timeout=30)
resp.raise_for_status()
return [m["title"] for m in resp.json()["query"]["categorymembers"]]
def resolve_image_urls(filenames):
"""Batch-resolve wiki image filenames (as found in an infobox's `image`
field) to their real hosted URLs. Used only for candidates recovered via
fetch_category_item_names(), since Item_Grid/Neutral_Items already give
us a usable <img> src directly."""
urls = {}
unique = list(dict.fromkeys(f for f in filenames if f))
for i in range(0, len(unique), QUERY_BATCH_SIZE):
batch = unique[i:i + QUERY_BATCH_SIZE]
params = {
"action": "query",
"titles": "|".join(f"File:{f}" for f in batch),
"prop": "imageinfo",
"iiprop": "url",
"format": "json",
}
resp = requests.get(API_BASE, params=params, headers=HEADERS, timeout=30)
resp.raise_for_status()
for page in resp.json()["query"]["pages"].values():
title = page.get("title", "")
imageinfo = page.get("imageinfo")
if imageinfo and title.startswith("File:"):
urls[title[len("File:"):]] = imageinfo[0]["url"]
if i + QUERY_BATCH_SIZE < len(unique):
time.sleep(GENERAL_RATE_LIMIT_SECONDS)
return urls
def fetch_wikitext(names):
"""Batch-fetch raw wikitext for pages via action=query (cheap, not the expensive parse action)."""
pages = {}
for i in range(0, len(names), QUERY_BATCH_SIZE):
batch = names[i:i + QUERY_BATCH_SIZE]
params = {
"action": "query",
"prop": "revisions",
"rvslots": "main",
"rvprop": "content",
"titles": "|".join(batch),
"format": "json",
}
resp = requests.get(API_BASE, params=params, headers=HEADERS, timeout=30)
resp.raise_for_status()
for page in resp.json()["query"]["pages"].values():
title = page.get("title")
revisions = page.get("revisions")
if not revisions:
print(f" WARNING: no content found for '{title}', skipping", file=sys.stderr)
continue
pages[title] = revisions[0]["slots"]["main"]["*"]
if i + QUERY_BATCH_SIZE < len(names):
time.sleep(GENERAL_RATE_LIMIT_SECONDS)
return pages
def extract_infobox(wikitext):
match = re.search(r"\{\{Item infobox([\s\S]*?)\n\}\}", wikitext)
return match.group(1) if match else None
INACTIVE_GAME_VALUES = {"dota 2 removed", "dota 2 unreleased"}
def is_active(infobox):
"""Most currently-active items omit the `game` field entirely. Genuinely
gone items set it to 'Dota 2 Removed' / 'Dota 2 Unreleased'. There's also
a 'Dota 2 Hidden' value (seen on e.g. Power Treads, Boots of Travel) that
does NOT mean inactive -- treating any non-empty `game` field as inactive
wrongly excluded those. Only the known removal/unreleased values count."""
match = re.search(r"^\s*\|\s*game\s*=\s*(.+)$", infobox, re.MULTILINE)
if not match:
return True
return match.group(1).strip().lower() not in INACTIVE_GAME_VALUES
def parse_tier(infobox):
"""Returns (None, None) for anything that isn't a real, obtainable 1-5
tier -- including the literal 'tier = 0' Liquipedia uses on a handful of
non-independently-obtainable derived states (e.g. Disgraced Regalia, an
automatic broken-item replacement, not something that drops on its own)."""
match = re.search(r"^\s*\|\s*tier\s*=\s*(\d+)(?:-(\d+))?", infobox, re.MULTILINE)
if not match:
return None, None
min_tier = int(match.group(1))
max_tier = int(match.group(2)) if match.group(2) else min_tier
if not (1 <= min_tier <= 5 and 1 <= max_tier <= 5):
return None, None
return min_tier, max_tier
def parse_type(infobox):
match = re.search(r"^\s*\|\s*type\s*=\s*(.+)$", infobox, re.MULTILINE)
return match.group(1).strip() if match else ""
def parse_neutral_flag(infobox):
match = re.search(r"^\s*\|\s*neutral\s*=\s*(\w+)", infobox, re.MULTILINE)
return bool(match and match.group(1).strip().lower() == "true")
def normalize_filename(filename):
"""MediaWiki collapses repeated whitespace in file titles server-side
(confirmed via the API's own "normalized" field, e.g. Boots of Travel 2's
infobox has a literal double space in its filename) -- without matching
that here, a lookup keyed by the raw wikitext value silently misses the
resolved URL and falls back to the wrong image."""
return re.sub(r"\s+", " ", filename).strip()
def parse_image_filename(infobox):
match = re.search(r"^\s*\|\s*image\s*=\s*(.+)$", infobox, re.MULTILINE)
return normalize_filename(match.group(1)) if match else None
def parse_grade_count(infobox):
"""Some shop items are a single purchase that gets upgraded in place
(Dagon 1-5, Boots of Travel 1-2) rather than being bought fresh each
time. Liquipedia encodes the grade count as N slash-separated parts in
the `intern` field (e.g. `item_dagon/item_dagon_2/.../item_dagon_5` for
5 grades) -- normal single-grade items just have one part."""
match = re.search(r"^\s*\|\s*intern\s*=\s*(.+)$", infobox, re.MULTILINE)
if not match:
return 1
return max(1, len(match.group(1).split("/")))
def parse_all_image_filenames(infobox):
"""A multi-grade item numbers its images image1, image2, ... (one per
grade, in order); a normal single-grade item just has one plain `image`
field. Returns them in grade order."""
numbered = sorted(
(int(n), normalize_filename(fname)) for n, fname in re.findall(r"^\s*\|\s*image(\d+)\s*=\s*(.+)$", infobox, re.MULTILINE)
)
if numbered:
return [fname for _, fname in numbered]
plain = parse_image_filename(infobox)
return [plain] if plain else []
def build_shop_item_rows(name, infobox, wikitext, fallback_image_url):
"""Returns one row for a normal item, or one row per grade for an
upgrade-in-place item like Dagon or Boots of Travel -- grade 1 keeps the
base name, later grades are named '{name} {grade}' (matching Liquipedia's
own convention, e.g. 'Dagon 2', 'Boots of Travel 2')."""
grade_count = parse_grade_count(infobox)
tags = infer_tags(infobox, wikitext)
if grade_count == 1:
return [{
"name": name, "source": "shop", "min_tier": None, "max_tier": None,
"min_grade": 1, "max_grade": 1, "image_url": fallback_image_url, "tags": tags,
}]
filenames = parse_all_image_filenames(infobox)
resolved = resolve_image_urls(filenames)
rows = []
for grade in range(1, grade_count + 1):
grade_name = name if grade == 1 else f"{name} {grade}"
filename = filenames[grade - 1] if grade - 1 < len(filenames) else None
image_url = resolved.get(filename) or fallback_image_url
rows.append({
"name": grade_name, "source": "shop", "min_tier": None, "max_tier": None,
"min_grade": grade, "max_grade": grade, "image_url": image_url, "tags": tags,
})
return rows
def parse_enchantment_attributes(wikitext, infobox):
"""Attribute lock isn't a structured infobox field -- it's stated in the
intro prose right before the infobox, e.g. '... for {{Attribute ID|Strength}}
heroes only.' Search only that intro portion to avoid false hits from
unrelated attribute mentions elsewhere on the page.
Not every enchantment is attribute-locked -- about a third of them say
'... enchantment for all heroes.' instead of naming an attribute. Those
are compatible with every attribute, so they're stored with all four
rather than zero (zero would make them match nothing at all, since the
backend looks enchantments up by a specific requested attribute)."""
infobox_pos = wikitext.find("{{Item infobox")
intro = wikitext[:infobox_pos] if infobox_pos != -1 else wikitext
if re.search(r"for all heroes", intro, re.IGNORECASE):
return sorted(VALID_ATTRIBUTES)
found = {m.lower() for m in re.findall(r"\{\{Attribute ID\|(\w+)\}\}", intro)}
return sorted(found & VALID_ATTRIBUTES)
def build_search_blob(infobox, wikitext):
"""Concatenate everything useful for tag-matching into one lowercase blob."""
parts = []
if infobox:
parts += re.findall(r"^\s*\|\s*([a-z0-9 ]+?)\s*=", infobox, re.MULTILINE | re.IGNORECASE)
parts += re.findall(r"^\s*\|\s*(?:active|passive)\s*=\s*(.+)$", infobox, re.MULTILINE | re.IGNORECASE)
lore_match = re.search(r"^\s*\|\s*lore\s*=\s*(.+)$", infobox, re.MULTILINE | re.IGNORECASE)
if lore_match:
parts.append(lore_match.group(1))
parts += re.findall(r"\bdesc\d*\s*=\s*(.+)", wikitext)
return " ".join(parts).lower()
def infer_tags(infobox, wikitext):
blob = build_search_blob(infobox, wikitext)
return sorted(tag for tag, keywords in TAG_KEYWORDS.items() if any(kw in blob for kw in keywords))
def sql_escape(value):
return value.replace("'", "''")
def write_items_migration(rows, item_tags, timestamp):
up_path = MIGRATIONS_DIR / f"{timestamp}_seed_items.up.sql"
down_path = MIGRATIONS_DIR / f"{timestamp}_seed_items.down.sql"
item_values = ",\n".join(
" ('{}', '{}', {}, {}, {}, {}, '{}')".format(
sql_escape(r["name"]),
r["source"],
r["min_tier"] if r["min_tier"] is not None else "NULL",
r["max_tier"] if r["max_tier"] is not None else "NULL",
r["min_grade"] if r["min_grade"] is not None else "NULL",
r["max_grade"] if r["max_grade"] is not None else "NULL",
sql_escape(r["image_url"]),
)
for r in rows
)
all_tags = sorted({tag for tags in item_tags.values() for tag in tags})
tag_values = ",\n".join(f" ('{sql_escape(t)}')" for t in all_tags)
pair_values = ",\n".join(
f" ('{sql_escape(name)}', '{sql_escape(tag)}')"
for name, tags in item_tags.items()
for tag in tags
)
# DELETE-first so this migration is safe to apply regardless of whether
# an earlier seed_items run's data is still sitting in the table --
# useful since this script tends to get rerun a lot during development.
up_sql = (
"DELETE FROM item_tags;\nDELETE FROM tags;\nDELETE FROM items;\n\n"
f"INSERT INTO items (name, source, min_tier, max_tier, min_grade, max_grade, image_url) VALUES\n{item_values};\n\n"
f"INSERT INTO tags (name) VALUES\n{tag_values};\n\n"
"INSERT INTO item_tags (item_id, tag_id)\n"
"SELECT i.id, t.id FROM (VALUES\n"
f"{pair_values}\n"
") AS pairs(item_name, tag_name)\n"
"JOIN items i ON i.name = pairs.item_name\n"
"JOIN tags t ON t.name = pairs.tag_name;\n"
)
down_sql = "DELETE FROM item_tags;\nDELETE FROM tags;\nDELETE FROM items;\n"
MIGRATIONS_DIR.mkdir(exist_ok=True)
up_path.write_text(up_sql, encoding="utf-8")
down_path.write_text(down_sql, encoding="utf-8")
print(f"\nWrote items migration:\n {up_path}\n {down_path}")
def write_enchantments_migration(rows, timestamp):
up_path = MIGRATIONS_DIR / f"{timestamp}_seed_enchantments.up.sql"
down_path = MIGRATIONS_DIR / f"{timestamp}_seed_enchantments.down.sql"
enchant_values = ",\n".join(
" ('{}', {}, {}, '{}')".format(sql_escape(r["name"]), r["min_tier"], r["max_tier"], sql_escape(r["image_url"]))
for r in rows
)
pair_values = ",\n".join(
f" ('{sql_escape(r['name'])}', '{attr}')" for r in rows for attr in r["attributes"]
)
up_sql = (
"DELETE FROM enchantment_attributes;\nDELETE FROM enchantments;\n\n"
f"INSERT INTO enchantments (name, min_tier, max_tier, image_url) VALUES\n{enchant_values};\n\n"
"INSERT INTO enchantment_attributes (enchantment_id, attribute)\n"
"SELECT e.id, pairs.attribute::primary_attribute FROM (VALUES\n"
f"{pair_values}\n"
") AS pairs(enchantment_name, attribute)\n"
"JOIN enchantments e ON e.name = pairs.enchantment_name;\n"
)
down_sql = "DELETE FROM enchantment_attributes;\nDELETE FROM enchantments;\n"
MIGRATIONS_DIR.mkdir(exist_ok=True)
up_path.write_text(up_sql, encoding="utf-8")
down_path.write_text(down_sql, encoding="utf-8")
print(f"Wrote enchantments migration:\n {up_path}\n {down_path}")
def main():
if "YOUR_EMAIL_HERE" in USER_AGENT:
sys.exit("Edit USER_AGENT at the top of this script with real contact info before running (Liquipedia's API terms require it).")
print("Fetching shop item candidates from Item Grid ...")
shop_candidates = fetch_shop_candidates()
print(f" Found {len(shop_candidates)} shop item candidates.")
time.sleep(PARSE_RATE_LIMIT_SECONDS) # second action=parse call, must respect the 30s limit
print("Fetching neutral item/enchantment candidates ...")
neutral_candidates = fetch_neutral_candidates()
print(f" Found {len(neutral_candidates)} neutral candidates (active + removed + unreleased).")
all_candidates = shop_candidates + neutral_candidates
names = [c["name"] for c in all_candidates]
print("Fetching complete item category listing to catch anything Item Grid / Neutral Items missed ...")
category_names = fetch_category_item_names()
known_names = {c["name"] for c in all_candidates}
recovered_names = [n for n in category_names if n not in known_names]
if recovered_names:
print(f" Found {len(recovered_names)} item(s) missing from the curated listings: {', '.join(recovered_names)}")
print("Fetching wikitext in batches (for status/tier/tags/attribute checks) ...")
wikitext_by_name = fetch_wikitext(names + recovered_names)
if recovered_names:
print("Resolving image URLs for recovered items ...")
recovered_filenames = {
name: parse_image_filename(extract_infobox(wikitext_by_name[name]) or "")
for name in recovered_names
if wikitext_by_name.get(name)
}
recovered_image_urls = resolve_image_urls(recovered_filenames.values())
item_rows = []
item_tags = {}
enchantment_rows = []
skipped_missing = []
skipped_inactive = []
skipped_excluded = []
for candidate in shop_candidates:
if candidate["name"] in EXCLUDED_ITEM_NAMES:
skipped_excluded.append(candidate["name"])
continue
wikitext = wikitext_by_name.get(candidate["name"])
if not wikitext:
skipped_missing.append(candidate["name"])
continue
infobox = extract_infobox(wikitext)
if not infobox or not is_active(infobox):
skipped_inactive.append(candidate["name"])
continue
for row in build_shop_item_rows(candidate["name"], infobox, wikitext, candidate["image_url"]):
item_rows.append({k: v for k, v in row.items() if k != "tags"})
item_tags[row["name"]] = row["tags"]
for candidate in neutral_candidates:
if candidate["name"] in EXCLUDED_ITEM_NAMES:
skipped_excluded.append(candidate["name"])
continue
wikitext = wikitext_by_name.get(candidate["name"])
if not wikitext:
skipped_missing.append(candidate["name"])
continue
infobox = extract_infobox(wikitext)
if not infobox or not is_active(infobox):
skipped_inactive.append(candidate["name"])
continue
min_tier, max_tier = parse_tier(infobox)
if min_tier is None:
skipped_missing.append(candidate["name"])
continue
if "enchantment" in parse_type(infobox).lower():
attributes = parse_enchantment_attributes(wikitext, infobox)
enchantment_rows.append({
"name": candidate["name"],
"min_tier": min_tier,
"max_tier": max_tier,
"image_url": candidate["image_url"],
"attributes": attributes,
})
else:
item_rows.append({
"name": candidate["name"],
"source": "neutral",
"min_tier": min_tier,
"max_tier": max_tier,
"min_grade": None,
"max_grade": None,
"image_url": candidate["image_url"],
})
item_tags[candidate["name"]] = infer_tags(infobox, wikitext)
for name in recovered_names:
if name in EXCLUDED_ITEM_NAMES:
skipped_excluded.append(name)
continue
wikitext = wikitext_by_name.get(name)
if not wikitext:
skipped_missing.append(name)
continue
infobox = extract_infobox(wikitext)
if not infobox or not is_active(infobox):
skipped_inactive.append(name)
continue
image_url = recovered_image_urls.get(recovered_filenames.get(name))
if not image_url:
skipped_missing.append(name)
continue
if parse_neutral_flag(infobox):
min_tier, max_tier = parse_tier(infobox)
if min_tier is None:
skipped_missing.append(name)
continue
if "enchantment" in parse_type(infobox).lower():
enchantment_rows.append({
"name": name,
"min_tier": min_tier,
"max_tier": max_tier,
"image_url": image_url,
"attributes": parse_enchantment_attributes(wikitext, infobox),
})
else:
item_rows.append({
"name": name, "source": "neutral", "min_tier": min_tier, "max_tier": max_tier,
"min_grade": None, "max_grade": None, "image_url": image_url,
})
item_tags[name] = infer_tags(infobox, wikitext)
else:
for row in build_shop_item_rows(name, infobox, wikitext, image_url):
item_rows.append({k: v for k, v in row.items() if k != "tags"})
item_tags[row["name"]] = row["tags"]
print(f"\n {len(item_rows)} items and {len(enchantment_rows)} enchantments are currently active.")
if skipped_excluded:
print(f" Deliberately excluded {len(skipped_excluded)} hero/item-specific entries: {', '.join(skipped_excluded)}")
if skipped_inactive:
print(f" Excluded {len(skipped_inactive)} removed/unreleased entries: {', '.join(skipped_inactive)}")
if skipped_missing:
print(f" Skipped {len(skipped_missing)} entries with no usable data: {', '.join(skipped_missing)}")
untagged = [name for name, tags in item_tags.items() if not tags]
if untagged:
print(f" {len(untagged)} items got zero inferred tags (may need a manual look): {', '.join(untagged)}")
no_attribute = [r["name"] for r in enchantment_rows if not r["attributes"]]
if no_attribute:
print(f" {len(no_attribute)} enchantments got no attribute lock detected (may need a manual look): {', '.join(no_attribute)}")
# sqlx uses this timestamp as the migration's primary key, so the two
# migrations below must not share one -- offsetting by a second
# guarantees uniqueness regardless of how fast this function runs.
now = time.time()
items_timestamp = time.strftime("%Y%m%d%H%M%S", time.localtime(now))
enchantments_timestamp = time.strftime("%Y%m%d%H%M%S", time.localtime(now + 1))
write_items_migration(item_rows, item_tags, items_timestamp)
write_enchantments_migration(enchantment_rows, enchantments_timestamp)
print("\nRun `sqlx migrate run` from the project root to apply both.")
if __name__ == "__main__":
main()
+19
View File
@@ -0,0 +1,19 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use sqlx::PgPool;
use crate::models::Build;
pub type BuildsStore = Arc<Mutex<HashMap<String, Build>>>;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub builds: BuildsStore,
/// Probability of winning the "can I even reroll this tier" gate when
/// `neutral_tier_reroll_enabled` is off (1.4.6). This is the only
/// remaining probability in the neutral/enchantment system -- everything
/// else is either guaranteed or a plain uniform pick.
pub neutral_tier_gate_chance: f64,
}
+385
View File
@@ -0,0 +1,385 @@
use axum::extract::{Path, State};
use axum::{Json, Router};
use uuid::Uuid;
use crate::app_state::AppState;
use crate::error_handlers::AppError;
use crate::models::{AghanimKind, Build, BuildConfig, Enchantment, EnchantmentStageResult, Item, NeutralStageResult};
use crate::roll;
pub async fn init_build_handlers(state: AppState) -> Router {
Router::new()
.route("/api/builds", axum::routing::post(post_builds))
.route("/api/builds/{id}/reroll/hero", axum::routing::post(reroll_hero))
.route("/api/builds/{id}/reroll/item-slot/{slot}", axum::routing::post(reroll_item_slot))
.route("/api/builds/{id}/reroll/neutral/{tier}", axum::routing::post(reroll_neutral))
.route("/api/builds/{id}/reroll/enchantment/{tier}", axum::routing::post(reroll_enchantment))
.route("/api/builds/{id}/item-slot/{slot}", axum::routing::patch(patch_item_slot))
.route("/api/builds/{id}/neutral/{tier}", axum::routing::patch(patch_neutral))
.route("/api/builds/{id}/enchantment/{tier}", axum::routing::patch(patch_enchantment))
.with_state(state)
}
fn get_build(state: &AppState, id: &str) -> Result<Build, AppError> {
state.builds.lock().unwrap().get(id).cloned().ok_or(AppError::NotFound)
}
fn save_build(state: &AppState, build: Build) {
state.builds.lock().unwrap().insert(build.id.clone(), build);
}
async fn post_builds(State(state): State<AppState>, Json(config): Json<BuildConfig>) -> Result<Json<Build>, AppError> {
let hero = roll::roll_hero(&state.pool, &config).await?;
let item_slots = roll::roll_item_slots(&state.pool, &config).await?;
let neutral_items = roll::roll_neutral_stages(&state.pool, &config, state.neutral_tier_gate_chance).await?;
let enchantments = roll::roll_enchantment_stages(&state.pool, &config, hero.primary_attribute).await?;
let aghanim_scepter =
roll::derive_aghanim_result(&item_slots, "Aghanim's Scepter", AghanimKind::Scepter, config.aghanim_scepter_timing);
let aghanim_shard =
roll::derive_aghanim_result(&item_slots, "Aghanim's Shard", AghanimKind::Shard, config.aghanim_shard_timing);
let build = Build {
id: Uuid::new_v4().to_string(),
config,
hero,
item_slots,
neutral_items,
enchantments,
aghanim_scepter,
aghanim_shard,
};
save_build(&state, build.clone());
Ok(Json(build))
}
async fn reroll_hero(State(state): State<AppState>, Path(id): Path<String>) -> Result<Json<Build>, AppError> {
let mut build = get_build(&state, &id)?;
build.hero = roll::roll_hero(&state.pool, &build.config).await?;
// Rerolling the hero starts a fresh neutral/enchantment progression too --
// enchantment eligibility depends on primary attribute, and neutral items
// are reset alongside it as a deliberate "new hero, new itemization" reset.
build.neutral_items = roll::roll_neutral_stages(&state.pool, &build.config, state.neutral_tier_gate_chance).await?;
build.enchantments = roll::roll_enchantment_stages(&state.pool, &build.config, build.hero.primary_attribute).await?;
save_build(&state, build.clone());
Ok(Json(build))
}
async fn reroll_item_slot(
State(state): State<AppState>,
Path((id, slot)): Path<(String, i32)>,
) -> Result<Json<Build>, AppError> {
let mut build = get_build(&state, &id)?;
let updated_slot = roll::roll_one_item_slot(&state.pool, &build.config, &build.item_slots, slot).await?;
if let Some(s) = build.item_slots.iter_mut().find(|s| s.slot == slot) {
*s = updated_slot;
}
build.aghanim_scepter = roll::derive_aghanim_result(
&build.item_slots,
"Aghanim's Scepter",
AghanimKind::Scepter,
build.config.aghanim_scepter_timing,
);
build.aghanim_shard = roll::derive_aghanim_result(
&build.item_slots,
"Aghanim's Shard",
AghanimKind::Shard,
build.config.aghanim_shard_timing,
);
save_build(&state, build.clone());
Ok(Json(build))
}
async fn reroll_neutral(
State(state): State<AppState>,
Path((id, tier)): Path<(String, i32)>,
) -> Result<Json<Build>, AppError> {
let mut build = get_build(&state, &id)?;
let stage = if build.config.disable_all_neutral_rolls || build.config.disable_neutral_artifact_randomness {
// Randomness is off for this dimension entirely -- rerolling a single
// tier shouldn't be able to bypass that and roll something anyway.
NeutralStageResult { tier, attempts: vec![], resolved: None, requires_manual_pick: true, tier_gate_won: None }
} else {
let carry = build.neutral_items.iter().find(|s| s.tier == tier - 1).and_then(|s| s.resolved.clone());
let gated = tier > 1 && !build.config.neutral_tier_reroll_enabled;
roll::roll_neutral_stage(&state.pool, tier, carry, gated, state.neutral_tier_gate_chance).await?
};
if let Some(s) = build.neutral_items.iter_mut().find(|s| s.tier == tier) {
*s = stage;
}
save_build(&state, build.clone());
Ok(Json(build))
}
async fn reroll_enchantment(
State(state): State<AppState>,
Path((id, tier)): Path<(String, i32)>,
) -> Result<Json<Build>, AppError> {
let mut build = get_build(&state, &id)?;
let stage = if build.config.disable_all_neutral_rolls || build.config.disable_enchantment_randomness {
EnchantmentStageResult { tier, resolved: None, requires_manual_pick: true }
} else {
roll::roll_enchantment_stage(&state.pool, tier, build.hero.primary_attribute).await?
};
if let Some(s) = build.enchantments.iter_mut().find(|s| s.tier == tier) {
*s = stage;
}
save_build(&state, build.clone());
Ok(Json(build))
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct ItemIdBody {
item_id: i32,
}
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct EnchantmentIdBody {
enchantment_id: i32,
}
async fn patch_item_slot(
State(state): State<AppState>,
Path((id, slot)): Path<(String, i32)>,
Json(body): Json<ItemIdBody>,
) -> Result<Json<Build>, AppError> {
let mut build = get_build(&state, &id)?;
let item: Option<Item> =
sqlx::query_as("SELECT id, name, source, min_tier, max_tier, min_grade, max_grade, image_url FROM items WHERE id = $1")
.bind(body.item_id)
.fetch_optional(&state.pool)
.await?;
let mut item = item.ok_or_else(|| AppError::BadRequest(format!("No item with id {}", body.item_id)))?;
roll::hydrate_item_tags(&state.pool, &mut item).await?;
if let Some(s) = build.item_slots.iter_mut().find(|s| s.slot == slot) {
s.item = Some(item);
s.is_manual = true;
}
build.aghanim_scepter = roll::derive_aghanim_result(
&build.item_slots,
"Aghanim's Scepter",
AghanimKind::Scepter,
build.config.aghanim_scepter_timing,
);
build.aghanim_shard = roll::derive_aghanim_result(
&build.item_slots,
"Aghanim's Shard",
AghanimKind::Shard,
build.config.aghanim_shard_timing,
);
save_build(&state, build.clone());
Ok(Json(build))
}
async fn patch_neutral(
State(state): State<AppState>,
Path((id, tier)): Path<(String, i32)>,
Json(body): Json<ItemIdBody>,
) -> Result<Json<Build>, AppError> {
let mut build = get_build(&state, &id)?;
let item: Option<Item> =
sqlx::query_as("SELECT id, name, source, min_tier, max_tier, min_grade, max_grade, image_url FROM items WHERE id = $1")
.bind(body.item_id)
.fetch_optional(&state.pool)
.await?;
let mut item = item.ok_or_else(|| AppError::BadRequest(format!("No item with id {}", body.item_id)))?;
roll::hydrate_item_tags(&state.pool, &mut item).await?;
if let Some(s) = build.neutral_items.iter_mut().find(|s| s.tier == tier) {
s.resolved = Some(item);
s.requires_manual_pick = false;
}
save_build(&state, build.clone());
Ok(Json(build))
}
async fn patch_enchantment(
State(state): State<AppState>,
Path((id, tier)): Path<(String, i32)>,
Json(body): Json<EnchantmentIdBody>,
) -> Result<Json<Build>, AppError> {
let mut build = get_build(&state, &id)?;
let enchantment: Option<Enchantment> =
sqlx::query_as("SELECT id, name, min_tier, max_tier, image_url FROM enchantments WHERE id = $1")
.bind(body.enchantment_id)
.fetch_optional(&state.pool)
.await?;
let mut enchantment =
enchantment.ok_or_else(|| AppError::BadRequest(format!("No enchantment with id {}", body.enchantment_id)))?;
roll::hydrate_enchantment_attributes(&state.pool, &mut enchantment).await?;
if let Some(s) = build.enchantments.iter_mut().find(|s| s.tier == tier) {
s.resolved = Some(enchantment);
s.requires_manual_pick = false;
}
save_build(&state, build.clone());
Ok(Json(build))
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use sqlx::PgPool;
use super::*;
use crate::models::AghanimTiming;
fn test_config() -> BuildConfig {
BuildConfig {
attack_types: vec![],
attributes: vec![],
item_tags: vec![],
disable_neutral_artifact_randomness: false,
disable_enchantment_randomness: false,
disable_all_neutral_rolls: false,
disabled_item_slots: vec![],
disable_aghanim_scepter_randomness: false,
disable_aghanim_shard_randomness: false,
neutral_tier_reroll_enabled: true,
aghanim_scepter_timing: AghanimTiming::Anytime,
aghanim_shard_timing: AghanimTiming::Anytime,
}
}
fn test_state(pool: PgPool) -> AppState {
AppState { pool, builds: Arc::new(Mutex::new(HashMap::new())), neutral_tier_gate_chance: 1.0 }
}
#[sqlx::test]
async fn post_builds_produces_a_full_build(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool);
let Json(build) =
post_builds(State(state), Json(test_config())).await.expect("build generation should succeed");
assert_eq!(build.item_slots.len(), 6);
assert_eq!(build.neutral_items.len(), 5);
assert_eq!(build.enchantments.len(), 5);
let slot1_name = build.item_slots[0].item.as_ref().unwrap().name.to_lowercase();
assert!(slot1_name.contains("boots") || slot1_name.contains("treads"));
Ok(())
}
#[sqlx::test]
async fn reroll_hero_changes_hero_and_keeps_build_id(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool);
let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap();
let original_id = build.id.clone();
let Json(rerolled) =
reroll_hero(State(state), Path(original_id.clone())).await.expect("reroll should succeed");
assert_eq!(rerolled.id, original_id);
// Enchantments must stay consistent with whichever hero ended up on the build.
for stage in &rerolled.enchantments {
if let Some(resolved) = &stage.resolved {
assert!(resolved.attributes.contains(&rerolled.hero.primary_attribute));
}
}
// Neutral items must have been re-rolled fresh (attempts populated again).
assert!(rerolled.neutral_items.iter().all(|s| s.attempts.len() == 2));
Ok(())
}
#[sqlx::test]
async fn reroll_item_slot_only_touches_the_requested_slot(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool);
let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap();
let Json(rerolled) = reroll_item_slot(State(state), Path((build.id.clone(), 3)))
.await
.expect("reroll should succeed");
for original_slot in &build.item_slots {
if original_slot.slot == 3 {
continue;
}
let same_slot = rerolled.item_slots.iter().find(|s| s.slot == original_slot.slot).unwrap();
assert_eq!(same_slot.item.as_ref().map(|i| i.id), original_slot.item.as_ref().map(|i| i.id));
}
Ok(())
}
#[sqlx::test]
async fn reroll_item_slot_one_stays_boots(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool);
let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap();
let Json(rerolled) =
reroll_item_slot(State(state), Path((build.id.clone(), 1))).await.expect("reroll should succeed");
let name = rerolled.item_slots[0].item.as_ref().unwrap().name.to_lowercase();
assert!(name.contains("boots") || name.contains("treads"));
Ok(())
}
#[sqlx::test]
async fn unknown_build_id_returns_not_found(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool);
let result = reroll_hero(State(state), Path("does-not-exist".to_string())).await;
assert!(matches!(result, Err(AppError::NotFound)));
Ok(())
}
#[sqlx::test]
async fn patch_item_slot_marks_slot_manual(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool.clone());
let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap();
let (item_id,): (i32,) = sqlx::query_as("SELECT id FROM items WHERE source = 'shop' LIMIT 1")
.fetch_one(&pool)
.await?;
let Json(patched) = patch_item_slot(State(state), Path((build.id.clone(), 2)), Json(ItemIdBody { item_id }))
.await
.expect("patch should succeed");
let slot = patched.item_slots.iter().find(|s| s.slot == 2).unwrap();
assert!(slot.is_manual);
assert_eq!(slot.item.as_ref().unwrap().id, item_id);
Ok(())
}
#[sqlx::test]
async fn patch_item_slot_rejects_unknown_item_id(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool);
let Json(build) = post_builds(State(state.clone()), Json(test_config())).await.unwrap();
let result =
patch_item_slot(State(state), Path((build.id.clone(), 1)), Json(ItemIdBody { item_id: -1 })).await;
assert!(matches!(result, Err(AppError::BadRequest(_))));
Ok(())
}
#[sqlx::test]
async fn aghanim_result_reflects_item_slots_after_manual_patch(pool: PgPool) -> sqlx::Result<()> {
let state = test_state(pool.clone());
let mut config = test_config();
config.disable_aghanim_scepter_randomness = false;
let Json(build) = post_builds(State(state.clone()), Json(config)).await.unwrap();
let scepter_id: Option<(i32,)> =
sqlx::query_as("SELECT id FROM items WHERE name = 'Aghanim''s Scepter'").fetch_optional(&pool).await?;
let Some((scepter_id,)) = scepter_id else {
// Scepter isn't seeded in this environment -- nothing to verify.
return Ok(());
};
let Json(patched) =
patch_item_slot(State(state), Path((build.id.clone(), 2)), Json(ItemIdBody { item_id: scepter_id }))
.await
.expect("patch should succeed");
assert!(patched.aghanim_scepter.included);
assert_eq!(patched.aghanim_scepter.item.unwrap().id, scepter_id);
Ok(())
}
}
+31
View File
@@ -0,0 +1,31 @@
use axum::extract::{Query, State};
use axum::{Json, Router};
use crate::app_state::AppState;
use crate::error_handlers::AppError;
use crate::models::{Enchantment, PrimaryAttribute};
use crate::roll;
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct EnchantmentsQuery {
tier: i32,
primary_attribute: PrimaryAttribute,
}
pub async fn init_enchantment_handlers(state: AppState) -> Router {
Router::new()
.route("/api/enchantments", axum::routing::get(get_enchantments))
.with_state(state)
}
async fn get_enchantments(
State(state): State<AppState>,
Query(q): Query<EnchantmentsQuery>,
) -> Result<Json<Vec<Enchantment>>, AppError> {
let mut enchantments = roll::fetch_enchantments_for_tier_and_attribute(&state.pool, q.tier, q.primary_attribute).await?;
for e in enchantments.iter_mut() {
roll::hydrate_enchantment_attributes(&state.pool, e).await?;
}
Ok(Json(enchantments))
}
+79
View File
@@ -0,0 +1,79 @@
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::Serialize;
#[derive(Serialize)]
struct ErrorBody {
error: String,
}
#[derive(Debug)]
pub enum AppError {
Internal,
NotFound,
BadRequest(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::Internal => (StatusCode::INTERNAL_SERVER_ERROR, "internal server error".to_string()),
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
};
(status, Json(ErrorBody { error: message })).into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(e: sqlx::Error) -> Self {
tracing::error!("{e}");
AppError::Internal
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn body_json(error: AppError) -> serde_json::Value {
let response = error.into_response();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn internal_maps_to_500_with_error_body() {
let response = AppError::Internal.into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let body = body_json(AppError::Internal).await;
assert_eq!(body["error"], "internal server error");
}
#[tokio::test]
async fn not_found_maps_to_404_with_error_body() {
let response = AppError::NotFound.into_response();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = body_json(AppError::NotFound).await;
assert_eq!(body["error"], "not found");
}
#[tokio::test]
async fn bad_request_maps_to_400_and_carries_its_message() {
let response = AppError::BadRequest("nope".to_string()).into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = body_json(AppError::BadRequest("nope".to_string())).await;
assert_eq!(body["error"], "nope");
}
#[tokio::test]
async fn sqlx_errors_map_to_internal() {
let sqlx_err = sqlx::Error::RowNotFound;
let app_err: AppError = sqlx_err.into();
assert!(matches!(app_err, AppError::Internal));
}
}
+60
View File
@@ -0,0 +1,60 @@
use axum::extract::{Query, State};
use axum::{Json, Router};
use crate::app_state::AppState;
use crate::error_handlers::AppError;
use crate::models::Item;
use crate::roll;
#[derive(serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct ItemsQuery {
#[serde(default)]
tags: Option<String>,
// Manual picker for item slot 1 (the boots slot) needs boots-only options.
#[serde(default)]
boots_only: bool,
}
#[derive(serde::Deserialize)]
struct NeutralItemsQuery {
tier: i32,
}
pub async fn init_item_handlers(state: AppState) -> Router {
Router::new()
.route("/api/items", axum::routing::get(get_items))
.route("/api/neutral-items", axum::routing::get(get_neutral_items))
.with_state(state)
}
fn parse_tags(raw: Option<String>) -> Vec<String> {
raw.map(|t| t.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
.unwrap_or_default()
}
async fn get_items(State(state): State<AppState>, Query(q): Query<ItemsQuery>) -> Result<Json<Vec<Item>>, AppError> {
let mut items = if q.boots_only {
roll::fetch_boots_items(&state.pool).await?
} else {
let tags = parse_tags(q.tags);
// Manual browsing always shows Aghanim's items regardless of the
// randomness toggles -- those only gate the automatic roll pool.
roll::fetch_eligible_shop_items(&state.pool, &tags, true, true).await?
};
for item in items.iter_mut() {
roll::hydrate_item_tags(&state.pool, item).await?;
}
Ok(Json(items))
}
async fn get_neutral_items(
State(state): State<AppState>,
Query(q): Query<NeutralItemsQuery>,
) -> Result<Json<Vec<Item>>, AppError> {
let mut items = roll::fetch_neutral_items_for_tier(&state.pool, q.tier).await?;
for item in items.iter_mut() {
roll::hydrate_item_tags(&state.pool, item).await?;
}
Ok(Json(items))
}
+87
View File
@@ -0,0 +1,87 @@
mod app_state;
mod build_handlers;
mod enchantment_handlers;
mod error_handlers;
mod item_handlers;
mod models;
mod roll;
mod tag_handlers;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use axum::Router;
use sqlx::PgPool;
use crate::app_state::AppState;
use crate::build_handlers::init_build_handlers;
use crate::enchantment_handlers::init_enchantment_handlers;
use crate::item_handlers::init_item_handlers;
use crate::tag_handlers::init_tag_handlers;
async fn init_env() {
// .env is a local-dev convenience only -- in production (e.g. Docker),
// real env vars are injected directly, so a missing file is expected.
let _ = dotenvy::dotenv();
}
async fn init_tracing() {
tracing_subscriber::fmt::init();
}
async fn init_db_pool(cx_string: &str) -> PgPool {
let pool_options = sqlx::postgres::PgPoolOptions::new();
pool_options.max_connections(20).connect(cx_string).await.expect("Cannot initialize pool!")
}
async fn apply_migrations(pool: PgPool) {
sqlx::migrate!().run(&pool).await.expect("Cannot apply migrations!");
}
fn read_neutral_tier_gate_chance() -> f64 {
std::env::var("NEUTRAL_TIER_GATE_CHANCE")
.ok()
.and_then(|v| v.parse::<f64>().ok())
.filter(|v| (0.0..=1.0).contains(v))
.unwrap_or(0.5)
}
async fn assemble_routers(pool: PgPool) -> Router {
let state = AppState {
pool: pool.clone(),
builds: Arc::new(Mutex::new(HashMap::new())),
neutral_tier_gate_chance: read_neutral_tier_gate_chance(),
};
let tag_router = init_tag_handlers(pool.clone()).await;
let build_router = init_build_handlers(state.clone()).await;
let item_router = init_item_handlers(state.clone()).await;
let enchantment_router = init_enchantment_handlers(state.clone()).await;
Router::new()
.merge(tag_router)
.merge(build_router)
.merge(item_router)
.merge(enchantment_router)
}
async fn init_tcp_listener(app: Router) {
let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await.expect("Cannot initialize listener");
axum::serve(listener, app).await.expect("Cannot bind a router to the listener");
}
#[tokio::main]
async fn main() {
init_env().await;
let cx_string = std::env::var("DATABASE_URL").expect("No DATABASE_URL key found in the env!");
init_tracing().await;
let pool = init_db_pool(&cx_string).await;
apply_migrations(pool.clone()).await;
let app = assemble_routers(pool.clone()).await;
init_tcp_listener(app).await;
}
+142
View File
@@ -0,0 +1,142 @@
#[derive(sqlx::Type, serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[sqlx(type_name = "primary_attribute", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum PrimaryAttribute {
Strength,
Agility,
Intelligence,
Universal,
}
#[derive(sqlx::Type, serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[sqlx(type_name = "attack_type", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum AttackType {
Melee,
Ranged,
}
#[derive(sqlx::Type, serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[sqlx(type_name = "item_source", rename_all = "lowercase")]
#[serde(rename_all = "lowercase")]
pub enum ItemSource {
Shop,
Neutral,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum AghanimKind {
Scepter,
Shard,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AghanimTiming {
Anytime,
AfterCoreSlots,
}
#[derive(serde::Deserialize, serde::Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct BuildConfig {
pub attack_types: Vec<AttackType>,
pub attributes: Vec<PrimaryAttribute>,
pub item_tags: Vec<String>,
pub disable_neutral_artifact_randomness: bool,
pub disable_enchantment_randomness: bool,
pub disable_all_neutral_rolls: bool,
pub disabled_item_slots: Vec<i32>,
pub disable_aghanim_scepter_randomness: bool,
pub disable_aghanim_shard_randomness: bool,
pub neutral_tier_reroll_enabled: bool,
pub aghanim_scepter_timing: AghanimTiming,
pub aghanim_shard_timing: AghanimTiming,
}
#[derive(sqlx::FromRow, serde::Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Hero {
pub id: i32,
pub name: String,
pub primary_attribute: PrimaryAttribute,
pub attack_type: AttackType,
pub image_url: String,
}
#[derive(sqlx::FromRow, serde::Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Item {
pub id: i32,
pub name: String,
pub source: ItemSource,
pub min_tier: Option<i16>,
pub max_tier: Option<i16>,
pub min_grade: Option<i16>,
pub max_grade: Option<i16>,
pub image_url: String,
#[sqlx(skip)]
pub tags: Vec<String>,
#[sqlx(skip)]
pub is_aghanim: Option<AghanimKind>,
}
#[derive(sqlx::FromRow, serde::Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Enchantment {
pub id: i32,
pub name: String,
pub min_tier: i16,
pub max_tier: i16,
pub image_url: String,
#[sqlx(skip)]
pub attributes: Vec<PrimaryAttribute>,
}
#[derive(serde::Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ItemSlot {
pub slot: i32,
pub item: Option<Item>,
pub is_manual: bool,
}
#[derive(serde::Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct NeutralStageResult {
pub tier: i32,
pub attempts: Vec<Item>,
pub resolved: Option<Item>,
pub requires_manual_pick: bool,
pub tier_gate_won: Option<bool>,
}
#[derive(serde::Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct EnchantmentStageResult {
pub tier: i32,
pub resolved: Option<Enchantment>,
pub requires_manual_pick: bool,
}
#[derive(serde::Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct AghanimResult {
pub included: bool,
pub item: Option<Item>,
pub timing: AghanimTiming,
}
#[derive(serde::Serialize, Debug, Clone)]
#[serde(rename_all = "camelCase")]
pub struct Build {
pub id: String,
pub config: BuildConfig,
pub hero: Hero,
pub item_slots: Vec<ItemSlot>,
pub neutral_items: Vec<NeutralStageResult>,
pub enchantments: Vec<EnchantmentStageResult>,
pub aghanim_scepter: AghanimResult,
pub aghanim_shard: AghanimResult,
}
+705
View File
@@ -0,0 +1,705 @@
use std::collections::HashSet;
use rand::seq::IndexedRandom;
use sqlx::PgPool;
use crate::error_handlers::AppError;
use crate::models::{
AghanimKind, AghanimResult, AghanimTiming, BuildConfig, Enchantment, EnchantmentStageResult, Hero, Item,
ItemSlot, NeutralStageResult, PrimaryAttribute,
};
const TIERS: [i32; 5] = [1, 2, 3, 4, 5];
// Boots are a distinct, mutually-exclusive-with-everything-else category:
// slot 1 is always boots, and no boots item can appear in any other slot.
// There's no dedicated DB column for this -- Dota's boots line doesn't share
// a naming convention beyond "boots" or "treads" (Power Treads), so a name
// pattern is the pragmatic match here.
// ---------------------------------------------------------------------------
// Hero
// ---------------------------------------------------------------------------
pub async fn roll_hero(pool: &PgPool, config: &BuildConfig) -> Result<Hero, AppError> {
let heroes: Vec<Hero> = sqlx::query_as(
"SELECT id, name, primary_attribute, attack_type, image_url FROM heroes
WHERE (cardinality($1::attack_type[]) = 0 OR attack_type = ANY($1))
AND (cardinality($2::primary_attribute[]) = 0 OR primary_attribute = ANY($2))",
)
.bind(&config.attack_types)
.bind(&config.attributes)
.fetch_all(pool)
.await?;
heroes
.choose(&mut rand::rng())
.cloned()
.ok_or_else(|| AppError::BadRequest("No heroes match the given scope".to_string()))
}
// ---------------------------------------------------------------------------
// Shop items / item slots
// ---------------------------------------------------------------------------
/// The non-boots shop pool used for slots 2-6. Aghanim's Scepter/Shard are
/// only included when their respective randomness toggle is on -- when it's
/// off they're assumed purchasable separately, outside the roll entirely.
pub async fn fetch_eligible_shop_items(
pool: &PgPool,
tags: &[String],
include_scepter: bool,
include_shard: bool,
) -> Result<Vec<Item>, AppError> {
let items: Vec<Item> = sqlx::query_as(
"SELECT DISTINCT i.id, i.name, i.source, i.min_tier, i.max_tier, i.min_grade, i.max_grade, i.image_url
FROM items i
LEFT JOIN item_tags it ON it.item_id = i.id
LEFT JOIN tags t ON t.id = it.tag_id
WHERE i.source = 'shop'
AND NOT (i.name ILIKE '%boots%' OR i.name ILIKE '%treads%')
AND (i.name != 'Aghanim''s Scepter' OR $2)
AND (i.name != 'Aghanim''s Shard' OR $3)
AND (cardinality($1::text[]) = 0 OR t.name = ANY($1))",
)
.bind(tags)
.bind(include_scepter)
.bind(include_shard)
.fetch_all(pool)
.await?;
Ok(items)
}
/// The boots-only pool used exclusively for slot 1.
pub async fn fetch_boots_items(pool: &PgPool) -> Result<Vec<Item>, AppError> {
let items: Vec<Item> = sqlx::query_as(
"SELECT id, name, source, min_tier, max_tier, min_grade, max_grade, image_url FROM items
WHERE source = 'shop' AND (name ILIKE '%boots%' OR name ILIKE '%treads%')",
)
.fetch_all(pool)
.await?;
Ok(items)
}
fn pick_unused_item(pool_items: &[Item], used_ids: &HashSet<i32>) -> Option<Item> {
let available: Vec<&Item> = pool_items.iter().filter(|i| !used_ids.contains(&i.id)).collect();
let candidates: Vec<&Item> = if available.is_empty() { pool_items.iter().collect() } else { available };
candidates.choose(&mut rand::rng()).map(|i| (*i).clone())
}
async fn shop_pools_for_config(pool: &PgPool, config: &BuildConfig) -> Result<(Vec<Item>, Vec<Item>), AppError> {
let boots_pool = fetch_boots_items(pool).await?;
let regular_pool = fetch_eligible_shop_items(
pool,
&config.item_tags,
!config.disable_aghanim_scepter_randomness,
!config.disable_aghanim_shard_randomness,
)
.await?;
Ok((boots_pool, regular_pool))
}
pub async fn roll_item_slots(pool: &PgPool, config: &BuildConfig) -> Result<Vec<ItemSlot>, AppError> {
let (boots_pool, regular_pool) = shop_pools_for_config(pool, config).await?;
let mut used_ids = HashSet::new();
let mut slots = Vec::with_capacity(6);
for slot in 1..=6 {
if config.disabled_item_slots.contains(&slot) {
slots.push(ItemSlot { slot, item: None, is_manual: true });
continue;
}
let pool_for_slot = if slot == 1 { &boots_pool } else { &regular_pool };
match pick_unused_item(pool_for_slot, &used_ids) {
Some(mut item) => {
used_ids.insert(item.id);
hydrate_item_tags(pool, &mut item).await?;
slots.push(ItemSlot { slot, item: Some(item), is_manual: false });
}
None => slots.push(ItemSlot { slot, item: None, is_manual: false }),
}
}
Ok(slots)
}
pub async fn roll_one_item_slot(
pool: &PgPool,
config: &BuildConfig,
existing_slots: &[ItemSlot],
slot: i32,
) -> Result<ItemSlot, AppError> {
let pool_items = if slot == 1 {
fetch_boots_items(pool).await?
} else {
fetch_eligible_shop_items(
pool,
&config.item_tags,
!config.disable_aghanim_scepter_randomness,
!config.disable_aghanim_shard_randomness,
)
.await?
};
let used_ids: HashSet<i32> = existing_slots
.iter()
.filter(|s| s.slot != slot)
.filter_map(|s| s.item.as_ref().map(|i| i.id))
.collect();
let mut item = pick_unused_item(&pool_items, &used_ids);
if let Some(item) = item.as_mut() {
hydrate_item_tags(pool, item).await?;
}
Ok(ItemSlot { slot, item, is_manual: false })
}
pub async fn hydrate_item_tags(pool: &PgPool, item: &mut Item) -> Result<(), AppError> {
let tags: Vec<(String,)> = sqlx::query_as(
"SELECT t.name FROM item_tags it JOIN tags t ON t.id = it.tag_id WHERE it.item_id = $1",
)
.bind(item.id)
.fetch_all(pool)
.await?;
item.tags = tags.into_iter().map(|(name,)| name).collect();
Ok(())
}
/// Aghanim's Scepter/Shard are now rolled as ordinary slot occupants (see
/// fetch_eligible_shop_items). This just reports, after the fact, whether
/// one ended up in the build -- there's no separate probability roll for it.
pub fn derive_aghanim_result(item_slots: &[ItemSlot], name: &str, kind: AghanimKind, timing: AghanimTiming) -> AghanimResult {
match item_slots.iter().find_map(|s| s.item.as_ref().filter(|i| i.name == name).cloned()) {
Some(mut item) => {
item.is_aghanim = Some(kind);
AghanimResult { included: true, item: Some(item), timing }
}
None => AghanimResult { included: false, item: None, timing },
}
}
// ---------------------------------------------------------------------------
// Neutral artifacts
// ---------------------------------------------------------------------------
pub async fn fetch_neutral_items_for_tier(pool: &PgPool, tier: i32) -> Result<Vec<Item>, AppError> {
let items: Vec<Item> = sqlx::query_as(
"SELECT id, name, source, min_tier, max_tier, min_grade, max_grade, image_url FROM items
WHERE source = 'neutral' AND min_tier <= $1 AND max_tier >= $1",
)
.bind(tier as i16)
.fetch_all(pool)
.await?;
Ok(items)
}
/// Two independent candidate items for the player to look out for -- real
/// in-game drops are random and out of this app's control, so a tier can
/// suggest up to 2 possibilities rather than pretend to know which (if
/// either) actually dropped. Distinct when the pool allows it.
fn roll_two_candidates(pool_items: &[Item]) -> Vec<Item> {
let mut rng = rand::rng();
let Some(first) = pool_items.choose(&mut rng) else { return vec![] };
let mut result = vec![first.clone()];
let remaining: Vec<&Item> = pool_items.iter().filter(|i| i.id != first.id).collect();
if let Some(second) = remaining.choose(&mut rng) {
result.push((*second).clone());
}
result
}
/// Rolls one neutral-artifact tier. `gated` is whether this tier first needs
/// to win the "can I even change my item this tier" coin flip (1.4.6) --
/// losing it just keeps whatever was already resolved for the previous tier.
/// Winning (or not being gated at all) always produces 2 fresh candidates;
/// picking one of them (or anything else from the tier) is always a manual
/// action from here, since the app can't know what actually dropped in-game.
pub async fn roll_neutral_stage(
pool: &PgPool,
tier: i32,
carry: Option<Item>,
gated: bool,
tier_gate_chance: f64,
) -> Result<NeutralStageResult, AppError> {
if gated && !rand::random_bool(tier_gate_chance) {
return Ok(NeutralStageResult {
tier,
attempts: vec![],
requires_manual_pick: carry.is_none(),
resolved: carry,
tier_gate_won: Some(false),
});
}
let pool_items = fetch_neutral_items_for_tier(pool, tier).await?;
let mut attempts = roll_two_candidates(&pool_items);
for item in attempts.iter_mut() {
hydrate_item_tags(pool, item).await?;
}
Ok(NeutralStageResult {
tier,
requires_manual_pick: true,
resolved: None,
attempts,
tier_gate_won: gated.then_some(true),
})
}
pub async fn roll_neutral_stages(
pool: &PgPool,
config: &BuildConfig,
tier_gate_chance: f64,
) -> Result<Vec<NeutralStageResult>, AppError> {
if config.disable_all_neutral_rolls || config.disable_neutral_artifact_randomness {
return Ok(TIERS
.iter()
.map(|&tier| NeutralStageResult {
tier,
attempts: vec![],
resolved: None,
requires_manual_pick: true,
tier_gate_won: None,
})
.collect());
}
let mut stages = Vec::with_capacity(TIERS.len());
let mut carry: Option<Item> = None;
for (i, &tier) in TIERS.iter().enumerate() {
let gated = i > 0 && !config.neutral_tier_reroll_enabled;
let stage = roll_neutral_stage(pool, tier, carry.clone(), gated, tier_gate_chance).await?;
if stage.resolved.is_some() {
carry = stage.resolved.clone();
}
stages.push(stage);
}
Ok(stages)
}
// ---------------------------------------------------------------------------
// Enchantments -- guaranteed once rolling is on; the only choice is whether
// to roll them at all (unlike neutral artifacts, there's no "might not drop
// in-game" uncertainty modeled here).
// ---------------------------------------------------------------------------
pub async fn fetch_enchantments_for_tier_and_attribute(
pool: &PgPool,
tier: i32,
attribute: PrimaryAttribute,
) -> Result<Vec<Enchantment>, AppError> {
let enchantments: Vec<Enchantment> = sqlx::query_as(
"SELECT e.id, e.name, e.min_tier, e.max_tier, e.image_url
FROM enchantments e
JOIN enchantment_attributes ea ON ea.enchantment_id = e.id
WHERE e.min_tier <= $1 AND e.max_tier >= $1 AND ea.attribute = $2",
)
.bind(tier as i16)
.bind(attribute)
.fetch_all(pool)
.await?;
Ok(enchantments)
}
pub async fn hydrate_enchantment_attributes(pool: &PgPool, enchantment: &mut Enchantment) -> Result<(), AppError> {
let attrs: Vec<(PrimaryAttribute,)> =
sqlx::query_as("SELECT attribute FROM enchantment_attributes WHERE enchantment_id = $1")
.bind(enchantment.id)
.fetch_all(pool)
.await?;
enchantment.attributes = attrs.into_iter().map(|(a,)| a).collect();
Ok(())
}
pub async fn roll_enchantment_stage(
pool: &PgPool,
tier: i32,
attribute: PrimaryAttribute,
) -> Result<EnchantmentStageResult, AppError> {
let pool_items = fetch_enchantments_for_tier_and_attribute(pool, tier, attribute).await?;
let mut resolved = pool_items.choose(&mut rand::rng()).cloned();
if let Some(e) = resolved.as_mut() {
hydrate_enchantment_attributes(pool, e).await?;
}
Ok(EnchantmentStageResult { tier, requires_manual_pick: resolved.is_none(), resolved })
}
pub async fn roll_enchantment_stages(
pool: &PgPool,
config: &BuildConfig,
attribute: PrimaryAttribute,
) -> Result<Vec<EnchantmentStageResult>, AppError> {
if config.disable_all_neutral_rolls || config.disable_enchantment_randomness {
return Ok(TIERS
.iter()
.map(|&tier| EnchantmentStageResult { tier, resolved: None, requires_manual_pick: true })
.collect());
}
let mut stages = Vec::with_capacity(TIERS.len());
for &tier in TIERS.iter() {
stages.push(roll_enchantment_stage(pool, tier, attribute).await?);
}
Ok(stages)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::ItemSource;
fn sample_item(id: i32) -> Item {
Item {
id,
name: format!("Item {id}"),
source: ItemSource::Shop,
min_tier: None,
max_tier: None,
min_grade: None,
max_grade: None,
image_url: String::new(),
tags: vec![],
is_aghanim: None,
}
}
fn default_config() -> BuildConfig {
BuildConfig {
attack_types: vec![],
attributes: vec![],
item_tags: vec![],
disable_neutral_artifact_randomness: false,
disable_enchantment_randomness: false,
disable_all_neutral_rolls: false,
disabled_item_slots: vec![],
disable_aghanim_scepter_randomness: false,
disable_aghanim_shard_randomness: false,
neutral_tier_reroll_enabled: true,
aghanim_scepter_timing: AghanimTiming::Anytime,
aghanim_shard_timing: AghanimTiming::Anytime,
}
}
#[test]
fn pick_unused_item_avoids_already_used_ids() {
let pool = vec![sample_item(1), sample_item(2), sample_item(3)];
let mut used = HashSet::new();
used.insert(1);
used.insert(2);
let picked = pick_unused_item(&pool, &used).expect("pool has an unused item");
assert_eq!(picked.id, 3);
}
#[test]
fn pick_unused_item_falls_back_to_full_pool_when_all_used() {
let pool = vec![sample_item(1), sample_item(2)];
let mut used = HashSet::new();
used.insert(1);
used.insert(2);
let picked = pick_unused_item(&pool, &used);
assert!(picked.is_some());
}
#[test]
fn pick_unused_item_returns_none_for_empty_pool() {
let pool: Vec<Item> = vec![];
let picked = pick_unused_item(&pool, &HashSet::new());
assert!(picked.is_none());
}
#[test]
fn roll_two_candidates_are_distinct_when_pool_allows() {
let pool = vec![sample_item(1), sample_item(2), sample_item(3)];
for _ in 0..20 {
let picked = roll_two_candidates(&pool);
assert_eq!(picked.len(), 2);
assert_ne!(picked[0].id, picked[1].id);
}
}
#[test]
fn roll_two_candidates_returns_one_for_single_item_pool() {
let pool = vec![sample_item(1)];
let picked = roll_two_candidates(&pool);
assert_eq!(picked.len(), 1);
}
#[test]
fn roll_two_candidates_returns_empty_for_empty_pool() {
let picked = roll_two_candidates(&[]);
assert!(picked.is_empty());
}
#[sqlx::test]
async fn roll_neutral_stage_carries_forward_exact_item_when_gate_lost(pool: PgPool) -> sqlx::Result<()> {
let carried = sample_item(999);
let stage = roll_neutral_stage(&pool, 3, Some(carried.clone()), true, 0.0).await.expect("roll should succeed");
assert_eq!(stage.tier_gate_won, Some(false));
assert_eq!(stage.resolved.map(|i| i.id), Some(carried.id));
assert!(stage.attempts.is_empty());
assert!(!stage.requires_manual_pick);
Ok(())
}
#[sqlx::test]
async fn roll_hero_respects_attack_type_filter(pool: PgPool) -> sqlx::Result<()> {
let mut config = default_config();
config.attack_types = vec![crate::models::AttackType::Melee];
for _ in 0..10 {
let hero = roll_hero(&pool, &config).await.expect("a melee hero should exist");
assert_eq!(hero.attack_type, crate::models::AttackType::Melee);
}
Ok(())
}
#[sqlx::test]
async fn roll_hero_respects_attribute_filter(pool: PgPool) -> sqlx::Result<()> {
let mut config = default_config();
config.attributes = vec![PrimaryAttribute::Strength];
for _ in 0..10 {
let hero = roll_hero(&pool, &config).await.expect("a strength hero should exist");
assert_eq!(hero.primary_attribute, PrimaryAttribute::Strength);
}
Ok(())
}
#[sqlx::test(migrations = false)]
async fn roll_hero_errors_when_no_heroes_match(pool: PgPool) -> sqlx::Result<()> {
sqlx::raw_sql(
"CREATE TYPE primary_attribute AS ENUM ('strength', 'agility', 'intelligence', 'universal');
CREATE TYPE attack_type AS ENUM ('melee', 'ranged');
CREATE TABLE heroes (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
primary_attribute primary_attribute NOT NULL,
attack_type attack_type NOT NULL,
image_url TEXT NOT NULL
);",
)
.execute(&pool)
.await?;
let config = default_config();
let result = roll_hero(&pool, &config).await;
assert!(matches!(result, Err(AppError::BadRequest(_))));
Ok(())
}
#[sqlx::test]
async fn eligible_shop_items_exclude_boots(pool: PgPool) -> sqlx::Result<()> {
let items = fetch_eligible_shop_items(&pool, &[], true, true).await.expect("query should succeed");
assert!(items.iter().all(|i| !i.name.to_lowercase().contains("boots") && !i.name.to_lowercase().contains("treads")));
Ok(())
}
#[sqlx::test]
async fn eligible_shop_items_respect_aghanim_toggles(pool: PgPool) -> sqlx::Result<()> {
let excluded = fetch_eligible_shop_items(&pool, &[], false, false).await.expect("query should succeed");
assert!(excluded.iter().all(|i| i.name != "Aghanim's Scepter" && i.name != "Aghanim's Shard"));
let included = fetch_eligible_shop_items(&pool, &[], true, true).await.expect("query should succeed");
// Not asserting presence (the seed data may or may not include them),
// just that the toggle doesn't filter them out when both are true.
assert!(included.len() >= excluded.len());
Ok(())
}
#[sqlx::test]
async fn boots_items_are_boots_only(pool: PgPool) -> sqlx::Result<()> {
let items = fetch_boots_items(&pool).await.expect("query should succeed");
assert!(items.iter().all(|i| i.name.to_lowercase().contains("boots") || i.name.to_lowercase().contains("treads")));
Ok(())
}
#[sqlx::test]
async fn eligible_shop_items_filters_by_tag(pool: PgPool) -> sqlx::Result<()> {
let mut all_items = fetch_eligible_shop_items(&pool, &[], true, true).await.expect("query should succeed");
for item in all_items.iter_mut() {
hydrate_item_tags(&pool, item).await.expect("hydrate should succeed");
}
let Some(sample_tag) = all_items.iter().flat_map(|i| i.tags.iter()).next().cloned() else {
return Ok(());
};
let mut filtered = fetch_eligible_shop_items(&pool, std::slice::from_ref(&sample_tag), true, true)
.await
.expect("query should succeed");
assert!(!filtered.is_empty());
for item in filtered.iter_mut() {
hydrate_item_tags(&pool, item).await.expect("hydrate should succeed");
assert!(item.tags.contains(&sample_tag));
}
Ok(())
}
#[sqlx::test]
async fn neutral_items_for_tier_stay_within_their_tier_range(pool: PgPool) -> sqlx::Result<()> {
for tier in 1..=5 {
let items = fetch_neutral_items_for_tier(&pool, tier).await.expect("query should succeed");
for item in items {
assert!(item.min_tier.unwrap() <= tier as i16);
assert!(item.max_tier.unwrap() >= tier as i16);
}
}
Ok(())
}
#[sqlx::test]
async fn enchantments_are_scoped_to_tier_and_attribute(pool: PgPool) -> sqlx::Result<()> {
for tier in 1..=5 {
for attribute in [
PrimaryAttribute::Strength,
PrimaryAttribute::Agility,
PrimaryAttribute::Intelligence,
PrimaryAttribute::Universal,
] {
let mut enchantments = fetch_enchantments_for_tier_and_attribute(&pool, tier, attribute)
.await
.expect("query should succeed");
for enchantment in enchantments.iter_mut() {
assert!(enchantment.min_tier <= tier as i16);
assert!(enchantment.max_tier >= tier as i16);
hydrate_enchantment_attributes(&pool, enchantment).await.expect("hydrate should succeed");
assert!(enchantment.attributes.contains(&attribute));
}
}
}
Ok(())
}
#[test]
fn derive_aghanim_result_finds_item_in_slots() {
let mut item = sample_item(42);
item.name = "Aghanim's Scepter".to_string();
let slots = vec![ItemSlot { slot: 3, item: Some(item), is_manual: false }];
let result = derive_aghanim_result(&slots, "Aghanim's Scepter", AghanimKind::Scepter, AghanimTiming::Anytime);
assert!(result.included);
assert_eq!(result.item.unwrap().id, 42);
}
#[test]
fn derive_aghanim_result_not_included_when_absent() {
let slots = vec![ItemSlot { slot: 3, item: Some(sample_item(1)), is_manual: false }];
let result = derive_aghanim_result(&slots, "Aghanim's Scepter", AghanimKind::Scepter, AghanimTiming::Anytime);
assert!(!result.included);
assert!(result.item.is_none());
}
#[sqlx::test]
async fn roll_item_slots_slot_one_is_always_boots(pool: PgPool) -> sqlx::Result<()> {
let config = default_config();
let slots = roll_item_slots(&pool, &config).await.expect("roll should succeed");
let slot1 = slots.iter().find(|s| s.slot == 1).unwrap();
let name = slot1.item.as_ref().expect("slot 1 should have an item").name.to_lowercase();
assert!(name.contains("boots") || name.contains("treads"), "slot 1 item '{name}' is not boots");
Ok(())
}
#[sqlx::test]
async fn roll_item_slots_never_put_boots_outside_slot_one(pool: PgPool) -> sqlx::Result<()> {
let config = default_config();
let slots = roll_item_slots(&pool, &config).await.expect("roll should succeed");
for slot in slots.iter().filter(|s| s.slot != 1) {
if let Some(item) = &slot.item {
let name = item.name.to_lowercase();
assert!(!name.contains("boots") && !name.contains("treads"), "slot {} got boots item '{}'", slot.slot, item.name);
}
}
Ok(())
}
#[sqlx::test]
async fn roll_item_slots_never_duplicates_when_enough_items_exist(pool: PgPool) -> sqlx::Result<()> {
let config = default_config();
let slots = roll_item_slots(&pool, &config).await.expect("roll should succeed");
assert_eq!(slots.len(), 6);
let ids: Vec<i32> = slots.iter().filter_map(|s| s.item.as_ref().map(|i| i.id)).collect();
let unique: HashSet<i32> = ids.iter().copied().collect();
assert_eq!(ids.len(), unique.len(), "item slots should not repeat the same item when enough distinct items exist");
Ok(())
}
#[sqlx::test]
async fn roll_item_slots_leaves_disabled_slots_empty_and_manual(pool: PgPool) -> sqlx::Result<()> {
let mut config = default_config();
config.disabled_item_slots = vec![3, 5];
let slots = roll_item_slots(&pool, &config).await.expect("roll should succeed");
for slot in &slots {
if slot.slot == 3 || slot.slot == 5 {
assert!(slot.item.is_none());
assert!(slot.is_manual);
} else {
assert!(!slot.is_manual);
}
}
Ok(())
}
#[sqlx::test]
async fn roll_neutral_stages_all_manual_when_disabled(pool: PgPool) -> sqlx::Result<()> {
let mut config = default_config();
config.disable_all_neutral_rolls = true;
let stages = roll_neutral_stages(&pool, &config, 0.5).await.expect("roll should succeed");
assert_eq!(stages.len(), 5);
assert!(stages.iter().all(|s| s.requires_manual_pick && s.resolved.is_none() && s.attempts.is_empty()));
Ok(())
}
#[sqlx::test]
async fn roll_neutral_stages_always_produce_two_attempts_when_gate_always_wins(pool: PgPool) -> sqlx::Result<()> {
let config = default_config();
let stages = roll_neutral_stages(&pool, &config, 1.0).await.expect("roll should succeed");
for stage in stages {
assert_eq!(stage.attempts.len(), 2, "tier {} should have 2 attempts", stage.tier);
assert!(stage.resolved.is_none(), "resolved should stay None until a manual pick");
assert!(stage.requires_manual_pick);
}
Ok(())
}
#[sqlx::test]
async fn roll_neutral_stages_carry_forward_when_gate_always_lost(pool: PgPool) -> sqlx::Result<()> {
let mut config = default_config();
config.neutral_tier_reroll_enabled = false;
let stages = roll_neutral_stages(&pool, &config, 0.0).await.expect("roll should succeed");
// Tier 1 always rolls fresh (not gated); tiers 2-5 always lose the
// gate at chance=0.0, so they should carry tier 1's (unresolved) None.
assert_eq!(stages[0].tier, 1);
assert_eq!(stages[0].attempts.len(), 2);
for stage in &stages[1..] {
assert_eq!(stage.tier_gate_won, Some(false));
assert!(stage.attempts.is_empty());
}
Ok(())
}
#[sqlx::test]
async fn roll_enchantment_stages_are_guaranteed_when_enabled(pool: PgPool) -> sqlx::Result<()> {
let config = default_config();
let stages = roll_enchantment_stages(&pool, &config, PrimaryAttribute::Strength).await.expect("roll should succeed");
for stage in stages {
assert!(stage.resolved.is_some(), "tier {} should have a guaranteed resolved enchantment", stage.tier);
assert!(!stage.requires_manual_pick);
}
Ok(())
}
#[sqlx::test]
async fn roll_enchantment_stages_all_manual_when_disabled(pool: PgPool) -> sqlx::Result<()> {
let mut config = default_config();
config.disable_enchantment_randomness = true;
let stages = roll_enchantment_stages(&pool, &config, PrimaryAttribute::Strength).await.expect("roll should succeed");
assert!(stages.iter().all(|s| s.resolved.is_none() && s.requires_manual_pick));
Ok(())
}
}
+22
View File
@@ -0,0 +1,22 @@
use crate::error_handlers::AppError;
use axum::extract::State;
use axum::{Json, Router};
use sqlx::PgPool;
#[derive(sqlx::FromRow, serde::Serialize)]
struct Tag {
id: i32,
name: String,
}
pub async fn init_tag_handlers(pool: PgPool) -> Router {
Router::new()
.route("/api/tags", axum::routing::get(get_tags))
.with_state(pool)
}
async fn get_tags(pool: State<PgPool>) -> Result<Json<Vec<Tag>>, AppError> {
let tags = sqlx::query_as("SELECT * FROM tags;")
.fetch_all(&pool.0)
.await?;
Ok(Json(tags))
}