feat(DRW-001) initial commit!
This commit is contained in:
@@ -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.
|
||||||
|
#================================================================
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/target
|
||||||
|
/.claude
|
||||||
|
.env
|
||||||
Generated
+1773
File diff suppressed because it is too large
Load Diff
+16
@@ -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
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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?
|
||||||
@@ -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 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1942
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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 { ®ular_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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user