commit edb4cd86135a995b55c5f65444667b6038be3b6e Author: kwiialyssa Date: Sun Sep 13 20:37:48 2026 +0300 feat(DRW-001) initial commit! diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml new file mode 100644 index 0000000..08797ca --- /dev/null +++ b/.gitea/workflows/ci-cd.yml @@ -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. +#================================================================ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..848b12a --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/target +/.claude +.env diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..956eb64 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1773 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "dota-random-web" +version = "1.0.0" +dependencies = [ + "axum", + "dotenvy", + "rand", + "serde", + "serde_json", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1", + "sha2 0.11.0", + "sqlx-core", + "thiserror", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c606413 --- /dev/null +++ b/Cargo.toml @@ -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"] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..67d593a --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..f16cb4c --- /dev/null +++ b/README.md @@ -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. diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -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? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -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 }] + } +} diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..25ccf83 --- /dev/null +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -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. diff --git a/frontend/build.json b/frontend/build.json new file mode 100644 index 0000000..7770ab2 --- /dev/null +++ b/frontend/build.json @@ -0,0 +1 @@ +{"id":"79070614-7a9c-4a9e-bbbb-f4873f957b5c","config":{"attackTypes":[],"attributes":[],"itemTags":[],"disableNeutralArtifactRandomness":false,"disableEnchantmentRandomness":false,"disableAllNeutralRolls":false,"disabledItemSlots":[],"disableAghanimScepterRandomness":false,"disableAghanimShardRandomness":false,"neutralTierRerollEnabled":true,"aghanimScepterTiming":"anytime","aghanimShardTiming":"anytime"},"hero":{"id":175,"name":"Medusa","primaryAttribute":"agility","attackType":"ranged","imageUrl":"https://liquipedia.net/commons/images/thumb/c/c6/Medusa_icon_dota2_gameasset.png/100px-Medusa_icon_dota2_gameasset.png"},"itemSlots":[{"slot":1,"item":{"id":563,"name":"Arcane Boots","source":"shop","minTier":null,"maxTier":null,"imageUrl":"https://liquipedia.net/commons/images/thumb/7/75/Arcane_Boots_itemicon_dota2_gameasset.png/40px-Arcane_Boots_itemicon_dota2_gameasset.png","tags":["Aura","Mobility","Sustain"],"isAghanim":null},"isManual":false},{"slot":2,"item":{"id":506,"name":"Belt of Strength","source":"shop","minTier":null,"maxTier":null,"imageUrl":"https://liquipedia.net/commons/images/thumb/3/3d/Belt_of_Strength_itemicon_dota2_gameasset.png/40px-Belt_of_Strength_itemicon_dota2_gameasset.png","tags":[],"isAghanim":null},"isManual":false},{"slot":3,"item":{"id":543,"name":"Chasm Stone","source":"shop","minTier":null,"maxTier":null,"imageUrl":"https://liquipedia.net/commons/images/thumb/0/0d/Chasm_Stone_itemicon_dota2_gameasset.png/40px-Chasm_Stone_itemicon_dota2_gameasset.png","tags":[],"isAghanim":null},"isManual":false},{"slot":4,"item":{"id":608,"name":"Assault Cuirass","source":"shop","minTier":null,"maxTier":null,"imageUrl":"https://liquipedia.net/commons/images/thumb/9/93/Assault_Cuirass_itemicon_dota2_gameasset.png/40px-Assault_Cuirass_itemicon_dota2_gameasset.png","tags":["Attack Speed","Aura","Survivability"],"isAghanim":null},"isManual":false},{"slot":5,"item":{"id":554,"name":"Mask of Madness","source":"shop","minTier":null,"maxTier":null,"imageUrl":"https://liquipedia.net/commons/images/thumb/6/6a/Mask_of_Madness_itemicon_dota2_gameasset.png/40px-Mask_of_Madness_itemicon_dota2_gameasset.png","tags":["Attack Speed","Physical Damage","Slow","Survivability","Sustain"],"isAghanim":null},"isManual":false},{"slot":6,"item":{"id":632,"name":"Kaya","source":"shop","minTier":null,"maxTier":null,"imageUrl":"https://liquipedia.net/commons/images/thumb/4/4f/Kaya_itemicon_dota2_gameasset.png/40px-Kaya_itemicon_dota2_gameasset.png","tags":["Magic Damage"],"isAghanim":null},"isManual":false}],"neutralItems":[{"tier":1,"attempts":[{"id":671,"name":"Chipped Vest","source":"neutral","minTier":1,"maxTier":1,"imageUrl":"https://liquipedia.net/commons/images/thumb/9/99/Chipped_Vest_itemicon_dota2_gameasset.png/60px-Chipped_Vest_itemicon_dota2_gameasset.png","tags":[],"isAghanim":null},{"id":680,"name":"Stonefeather Satchel","source":"neutral","minTier":1,"maxTier":1,"imageUrl":"https://liquipedia.net/commons/images/thumb/6/62/Stonefeather_Satchel_itemicon_dota2_gameasset.png/60px-Stonefeather_Satchel_itemicon_dota2_gameasset.png","tags":["Mobility","Survivability"],"isAghanim":null}],"resolved":null,"requiresManualPick":true,"tierGateWon":null},{"tier":2,"attempts":[{"id":685,"name":"Mana Draught","source":"neutral","minTier":2,"maxTier":2,"imageUrl":"https://liquipedia.net/commons/images/thumb/d/d2/Mana_Draught_itemicon_dota2_gameasset.png/60px-Mana_Draught_itemicon_dota2_gameasset.png","tags":["Dispel","Sustain"],"isAghanim":null},{"id":682,"name":"Crippling Crossbow","source":"neutral","minTier":2,"maxTier":2,"imageUrl":"https://liquipedia.net/commons/images/thumb/b/b3/Crippling_Crossbow_itemicon_dota2_gameasset.png/60px-Crippling_Crossbow_itemicon_dota2_gameasset.png","tags":["Slow","Sustain"],"isAghanim":null}],"resolved":null,"requiresManualPick":true,"tierGateWon":null},{"tier":3,"attempts":[{"id":697,"name":"Spellslinger","source":"neutral","minTier":3,"maxTier":3,"imageUrl":"https://liquipedia.net/commons/images/thumb/3/38/Spellslinger_itemicon_dota2_gameasset.png/60px-Spellslinger_itemicon_dota2_gameasset.png","tags":[],"isAghanim":null},{"id":693,"name":"Jidi Pollen Bag","source":"neutral","minTier":3,"maxTier":3,"imageUrl":"https://liquipedia.net/commons/images/thumb/7/76/Jidi_Pollen_Bag_itemicon_dota2_gameasset.png/60px-Jidi_Pollen_Bag_itemicon_dota2_gameasset.png","tags":["Sustain"],"isAghanim":null}],"resolved":null,"requiresManualPick":true,"tierGateWon":null},{"tier":4,"attempts":[{"id":708,"name":"Rattlecage","source":"neutral","minTier":4,"maxTier":4,"imageUrl":"https://liquipedia.net/commons/images/thumb/e/ef/Rattlecage_itemicon_dota2_gameasset.png/60px-Rattlecage_itemicon_dota2_gameasset.png","tags":["Attack Speed","Slow"],"isAghanim":null},{"id":702,"name":"Enchanter's Bauble","source":"neutral","minTier":4,"maxTier":4,"imageUrl":"https://liquipedia.net/commons/images/thumb/8/8f/Enchanter%27s_Bauble_itemicon_dota2_gameasset.png/60px-Enchanter%27s_Bauble_itemicon_dota2_gameasset.png","tags":[],"isAghanim":null}],"resolved":null,"requiresManualPick":true,"tierGateWon":null},{"tier":5,"attempts":[{"id":713,"name":"Harmonizer","source":"neutral","minTier":5,"maxTier":5,"imageUrl":"https://liquipedia.net/commons/images/thumb/8/8f/Harmonizer_itemicon_dota2_gameasset.png/60px-Harmonizer_itemicon_dota2_gameasset.png","tags":["Magic Damage"],"isAghanim":null},{"id":711,"name":"Divine Regalia","source":"neutral","minTier":5,"maxTier":5,"imageUrl":"https://liquipedia.net/commons/images/thumb/a/ad/Divine_Regalia_itemicon_dota2_gameasset.png/60px-Divine_Regalia_itemicon_dota2_gameasset.png","tags":[],"isAghanim":null}],"resolved":null,"requiresManualPick":true,"tierGateWon":null}],"enchantments":[{"tier":1,"resolved":{"id":2,"name":"Alert Enchantment","minTier":1,"maxTier":4,"imageUrl":"https://liquipedia.net/commons/images/thumb/0/0b/Alert_Enchantment_itemicon_dota2_gameasset.png/60px-Alert_Enchantment_itemicon_dota2_gameasset.png","attributes":["universal","agility"]},"requiresManualPick":false},{"tier":2,"resolved":{"id":10,"name":"Nimble Enchantment","minTier":2,"maxTier":4,"imageUrl":"https://liquipedia.net/commons/images/thumb/b/be/Nimble_Enchantment_itemicon_dota2_gameasset.png/60px-Nimble_Enchantment_itemicon_dota2_gameasset.png","attributes":["agility"]},"requiresManualPick":false},{"tier":3,"resolved":{"id":2,"name":"Alert Enchantment","minTier":1,"maxTier":4,"imageUrl":"https://liquipedia.net/commons/images/thumb/0/0b/Alert_Enchantment_itemicon_dota2_gameasset.png/60px-Alert_Enchantment_itemicon_dota2_gameasset.png","attributes":["universal","agility"]},"requiresManualPick":false},{"tier":4,"resolved":{"id":2,"name":"Alert Enchantment","minTier":1,"maxTier":4,"imageUrl":"https://liquipedia.net/commons/images/thumb/0/0b/Alert_Enchantment_itemicon_dota2_gameasset.png/60px-Alert_Enchantment_itemicon_dota2_gameasset.png","attributes":["universal","agility"]},"requiresManualPick":false},{"tier":5,"resolved":{"id":13,"name":"Audacious Enchantment","minTier":5,"maxTier":5,"imageUrl":"https://liquipedia.net/commons/images/thumb/5/58/Audacious_Enchantment_itemicon_dota2_gameasset.png/60px-Audacious_Enchantment_itemicon_dota2_gameasset.png","attributes":["agility"]},"requiresManualPick":false}],"aghanimScepter":{"included":false,"item":null,"timing":"anytime"},"aghanimShard":{"included":false,"item":null,"timing":"anytime"}} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..802fb47 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Dota Random + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..e188907 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..38630e0 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1942 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "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" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.149.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz", + "integrity": "sha512-Efcc+iF0j3Bf67YjEqIqWXbX5XddXoK/Mw4K1/JuXwRCZ8N16VR7iT23nlCc9XrveFVh/E5Rqs2StT0V8v9LdA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.82.0.tgz", + "integrity": "sha512-a3LB+C5Dsj5b/qtmG/mv5WrzuiXEpg1KF5nXWcEvaoN5TYAqkIvxPOwTPp3Jy/FoGpRo8zsTFhMElMXfeoOEzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.82.0.tgz", + "integrity": "sha512-OBlhRgNqFblGpGenno/aqOfJLOkQ2B8Ig3iDAalfn0H8hJGZKXPeexCRTDm6uwv6YUjSA9Xnwt1y/Bgj5ZH8uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.82.0.tgz", + "integrity": "sha512-dsopxqtY5ZdyT9uLHyGt1SyiLop6hi7hWI3PKpePodkRQOkLaCm+OE4fR9CAz9qdfjiFO8531tX/QDyP/psjFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.82.0.tgz", + "integrity": "sha512-94Lu0SgTClKColU66g1VDuigV3HkcbkJBnTtZjGYfE8UPugaWDgKrm2icjC6HJVUYler2OXaHP/X0TBy8+CowQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.82.0.tgz", + "integrity": "sha512-hne/V06ewhh1i0w8+l7GDNROAGCGPmyFuOwiP7YTRu0JycyStJ4785dmF8xU5p0uUwt2emvIF9vc7Xjis+cJ0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.82.0.tgz", + "integrity": "sha512-aWY2xtbZf1LneW9Qsv/n2Sp8gOu74JrlQzEtj4coHX2SHFrCfhmAumaU+sI/A5nr+yoTRTSmI/pL2s6ADlNSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.82.0.tgz", + "integrity": "sha512-Fe+TtXCXMh/5f7kWlZ2VAwsMumZWtraFlKVk1NJlL52/beGwfDE7ov+/8gVirHzWokzGu7X65hSPq0ucPDskWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.82.0.tgz", + "integrity": "sha512-6azCZ6OJudlvipNttXCCQcyeFfcJ/NvUZdSN1z8elo73kCHtyQC7WTiUcSjWYvJ1jaq9KDUyMAoAS/vNzhBomA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.82.0.tgz", + "integrity": "sha512-PLEaSD8IAIIlwW4dwOd9YaxuxeOpwiXL4J24rcnE4iNtyM5j9Q9/3+gti08oXpx0u2ygNjRDx9xjWWpQonuJEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.82.0.tgz", + "integrity": "sha512-D94em/BwknNTn4vqxjHh5wb2oL566eFhArabqKIr0cNZMHOJuiraFp1A8tXpH05bbE5tqwEfLXTI0MWEGtn3Dw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.82.0.tgz", + "integrity": "sha512-MOprxBaoYU2D4VgxXCl3ghydThWtx7Um1lL51kGYNeQ5Al7WzsH7/tqGdNtbLrIWnjq3bsm13+nz/gRIxjrOXw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.82.0.tgz", + "integrity": "sha512-5h55QsfJ/luDXZzC20k6SNOY1Az+dCP9WvntKtcUWh2JhckAdwApY2ZusaBTwLENnReXU+A2fJtSrYvZJNKNPg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.82.0.tgz", + "integrity": "sha512-IE8NJNLlHr0CaXyGJPGVn0eTkUyoj1I2UfA8x7I4PSOYKsQ/6btVC7Pywrj5onk0cMH25r6Z38SoN3AvE5Zuog==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.82.0.tgz", + "integrity": "sha512-XUUUxaBo9XKl+J1B9EmP1cTGQPddzeURvoGkfwh/94PGnbW+hBprDljneoI2M1jzC1bzrIV3ihc7iM9UXl8+tg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.82.0.tgz", + "integrity": "sha512-SWLSFulX9TDuH6yvbPYp4+VNn6jkkIvvI+KiujDM5rWBRHEfkesCC/pCneIIUr6ovkxZ5fRtpi2v5Cz5FrMJZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.82.0.tgz", + "integrity": "sha512-BQy35f6ZUdNr9a6c7B7orxQTcLjByGT2z3WAgmRovpRwmPYAaJ+NTplmMzhdjdJ4qSchfMNZy/Ukg+qRg6zseQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.82.0.tgz", + "integrity": "sha512-V4QhSTg5gctZue8RJjsGi7NpQPThr/p1/HfmiMC5kfe1KFEup9SQRVub4A6kijQjdHfxj7bLL1KO3QO7/5bwMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.82.0.tgz", + "integrity": "sha512-TUSCLaKB2yktpFAJ/r3HAUYsaV/3DT7JS4iNKyoh3a9YNwD0UG7Ezh4D8m23654vQcU6P/RQrCAjRPKe4peP/A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.82.0.tgz", + "integrity": "sha512-VTVoRIWJTb+wvUX8EYoPArfFH02whuR10goFXE/LHRRX33ajRrFgqbcONXZMiF4C5rnattfkm87HqYn8jb8hmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.8.tgz", + "integrity": "sha512-tN5aztYkKCte4i5SIrrz5yK/HMjEuCqCSCJa418jOV8tZ1cBY3YF2otxB1ktPxzsLA1BeTqwapK0bfjxNvHJVw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.8.tgz", + "integrity": "sha512-dIYTWl9XprMUiQFoc55KUyk/oS8SKYH3zFl0LTR7RT0Xj4hgSVyuJcroH8JUu8RcpF8fTB6E0aOwCkZoYPcDSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.8.tgz", + "integrity": "sha512-PCSDQGXD2IyTEFrcgPyBM8jJuGmrbCMuoIOXdbEGVemruKACXoLQJrb+A45Z0L5t1RQkdfJprAYPkikbh7dzdA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.8.tgz", + "integrity": "sha512-Uk7lRsGhPFHVX/sAUC6D5H9Ol30dFHd6iquokll2th3LpdJ3F5CzQB+7DHn0Ri2mG+U7k2zXiPHDrwZenXhwSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.8.tgz", + "integrity": "sha512-DjszaTEVogPqA5bYzsEeqDCQxbcp2fexQwKcRspYji2yzR68fCf+e4fx6kBSRDwX5/brZaHw/hWS9+A/+/w9sQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.8.tgz", + "integrity": "sha512-zmwa7FTmdzB6aaEEuuls18H6Ap5JmJPSoPTuXixeJZV6tG40SyLkApQtz1g8ptZtiEKqj9OM0oNLPh1AgvE31Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.8.tgz", + "integrity": "sha512-KdYQDPHwJVnbFwdTGMgxsI9SqblBlz6STGM+w1We/d5B8OWWidYH0MwkU/uA1wM5fIpO2MkOVxXrNzzuZhw9ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.8.tgz", + "integrity": "sha512-jFJTifHnNPY+yzOoNZQfSIysrVyXzEQPhPnOUjmD1bcQGHH6s7c8cViKWar8YplQImE5N9JRqMCLrM2CdxOrZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.8.tgz", + "integrity": "sha512-FhiOziBDWPBjbcmRzfLyIJnaP7AVMFXT7YCXPjXxj7wKU3vx24RjrCNN/zjvVa+N2vVoHJwCoUBvsrN/DG3zIA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.8.tgz", + "integrity": "sha512-WnHfADMzOV2Y55wlx1hzzQnar/wDt/VdvWSD99r18Mz9ylNieIGOkRx3UV21h7m/eJvjySYJkO26VvGNFkwsIQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.8.tgz", + "integrity": "sha512-H9tRr5ibfXFVLxbPOseVewewFpl28zcEdjRDt2FTUZU7odxP0gEv1ki4/kGmcGOh78oRwZuuQllGLZ9zTJp84g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.8.tgz", + "integrity": "sha512-UefiqfM3D6IVNlZ8tSGs9+Ejjud2T+oxO0IHADU45Y+lyEjD2dVFyZHbkfX0LUb5Zugo/oIv1eCO/KVYhgYJYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.8.tgz", + "integrity": "sha512-637Ke4kWSy6rp9cxQ9gMOXlxPgIw/c1beASV4M//3+9I4uwBVOOl74G+e3zyU3u19U7RkRl/HuewixZ/Z6+Rjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.8.tgz", + "integrity": "sha512-xWBkPOF1Q9k/Gv1nQXnVdLxKu74jXppuOM4Z3mnypVUJJJwLsMl7hNJGRAUJoG8A5MgOI1ACKM+wBFxSJzKy4A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.8.tgz", + "integrity": "sha512-uz2ZvfgXbxqNwijjjbxrnvALwpyODDcgc1T1N8N3rf/DXKQmaFwmB4LX4yyjggpwN2obdQLb2rgirX5ffCWYng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/node": { + "version": "24.13.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", + "integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz", + "integrity": "sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.145.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.25.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.25.1.tgz", + "integrity": "sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.82.0.tgz", + "integrity": "sha512-+iFM1BGw1ntYJt3QngbJmjbrGxPaKMUADOXOijpWGnYcBPq8YZnQftSS1C+pVcDYy9YxqDVJKQqQkTazTQMboQ==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.82.0", + "@oxlint/binding-android-arm64": "1.82.0", + "@oxlint/binding-darwin-arm64": "1.82.0", + "@oxlint/binding-darwin-x64": "1.82.0", + "@oxlint/binding-freebsd-x64": "1.82.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.82.0", + "@oxlint/binding-linux-arm-musleabihf": "1.82.0", + "@oxlint/binding-linux-arm64-gnu": "1.82.0", + "@oxlint/binding-linux-arm64-musl": "1.82.0", + "@oxlint/binding-linux-ppc64-gnu": "1.82.0", + "@oxlint/binding-linux-riscv64-gnu": "1.82.0", + "@oxlint/binding-linux-riscv64-musl": "1.82.0", + "@oxlint/binding-linux-s390x-gnu": "1.82.0", + "@oxlint/binding-linux-x64-gnu": "1.82.0", + "@oxlint/binding-linux-x64-musl": "1.82.0", + "@oxlint/binding-openharmony-arm64": "1.82.0", + "@oxlint/binding-win32-arm64-msvc": "1.82.0", + "@oxlint/binding-win32-ia32-msvc": "1.82.0", + "@oxlint/binding-win32-x64-msvc": "1.82.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2001", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", + "integrity": "sha512-Z67nTmhZe7anqnM/EjI392w5i/ANUinjip7QYsOyN37oayduxt3ksdX0hf5OOamkAd53BiIHfbfSzfUmzKFQqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.149.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.8", + "@rolldown/binding-android-arm64": "1.2.8", + "@rolldown/binding-darwin-arm64": "1.2.8", + "@rolldown/binding-darwin-x64": "1.2.8", + "@rolldown/binding-freebsd-x64": "1.2.8", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.8", + "@rolldown/binding-linux-arm64-gnu": "1.2.8", + "@rolldown/binding-linux-arm64-musl": "1.2.8", + "@rolldown/binding-linux-ppc64-gnu": "1.2.8", + "@rolldown/binding-linux-s390x-gnu": "1.2.8", + "@rolldown/binding-linux-x64-gnu": "1.2.8", + "@rolldown/binding-linux-x64-musl": "1.2.8", + "@rolldown/binding-openharmony-arm64": "1.2.8", + "@rolldown/binding-win32-arm64-msvc": "1.2.8", + "@rolldown/binding-win32-x64-msvc": "1.2.8" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..db2d79f --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..3c7cacc --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/rerolled.json b/frontend/rerolled.json new file mode 100644 index 0000000..10fab7f --- /dev/null +++ b/frontend/rerolled.json @@ -0,0 +1 @@ +{"id":"79070614-7a9c-4a9e-bbbb-f4873f957b5c","config":{"attackTypes":[],"attributes":[],"itemTags":[],"disableNeutralArtifactRandomness":false,"disableEnchantmentRandomness":false,"disableAllNeutralRolls":false,"disabledItemSlots":[],"disableAghanimScepterRandomness":false,"disableAghanimShardRandomness":false,"neutralTierRerollEnabled":true,"aghanimScepterTiming":"anytime","aghanimShardTiming":"anytime"},"hero":{"id":232,"name":"Bane","primaryAttribute":"universal","attackType":"ranged","imageUrl":"https://liquipedia.net/commons/images/thumb/1/1c/Bane_icon_dota2_gameasset.png/100px-Bane_icon_dota2_gameasset.png"},"itemSlots":[{"slot":1,"item":{"id":563,"name":"Arcane Boots","source":"shop","minTier":null,"maxTier":null,"imageUrl":"https://liquipedia.net/commons/images/thumb/7/75/Arcane_Boots_itemicon_dota2_gameasset.png/40px-Arcane_Boots_itemicon_dota2_gameasset.png","tags":["Aura","Mobility","Sustain"],"isAghanim":null},"isManual":false},{"slot":2,"item":{"id":506,"name":"Belt of Strength","source":"shop","minTier":null,"maxTier":null,"imageUrl":"https://liquipedia.net/commons/images/thumb/3/3d/Belt_of_Strength_itemicon_dota2_gameasset.png/40px-Belt_of_Strength_itemicon_dota2_gameasset.png","tags":[],"isAghanim":null},"isManual":false},{"slot":3,"item":{"id":543,"name":"Chasm Stone","source":"shop","minTier":null,"maxTier":null,"imageUrl":"https://liquipedia.net/commons/images/thumb/0/0d/Chasm_Stone_itemicon_dota2_gameasset.png/40px-Chasm_Stone_itemicon_dota2_gameasset.png","tags":[],"isAghanim":null},"isManual":false},{"slot":4,"item":{"id":608,"name":"Assault Cuirass","source":"shop","minTier":null,"maxTier":null,"imageUrl":"https://liquipedia.net/commons/images/thumb/9/93/Assault_Cuirass_itemicon_dota2_gameasset.png/40px-Assault_Cuirass_itemicon_dota2_gameasset.png","tags":["Attack Speed","Aura","Survivability"],"isAghanim":null},"isManual":false},{"slot":5,"item":{"id":554,"name":"Mask of Madness","source":"shop","minTier":null,"maxTier":null,"imageUrl":"https://liquipedia.net/commons/images/thumb/6/6a/Mask_of_Madness_itemicon_dota2_gameasset.png/40px-Mask_of_Madness_itemicon_dota2_gameasset.png","tags":["Attack Speed","Physical Damage","Slow","Survivability","Sustain"],"isAghanim":null},"isManual":false},{"slot":6,"item":{"id":632,"name":"Kaya","source":"shop","minTier":null,"maxTier":null,"imageUrl":"https://liquipedia.net/commons/images/thumb/4/4f/Kaya_itemicon_dota2_gameasset.png/40px-Kaya_itemicon_dota2_gameasset.png","tags":["Magic Damage"],"isAghanim":null},"isManual":false}],"neutralItems":[{"tier":1,"attempts":[{"id":680,"name":"Stonefeather Satchel","source":"neutral","minTier":1,"maxTier":1,"imageUrl":"https://liquipedia.net/commons/images/thumb/6/62/Stonefeather_Satchel_itemicon_dota2_gameasset.png/60px-Stonefeather_Satchel_itemicon_dota2_gameasset.png","tags":["Mobility","Survivability"],"isAghanim":null},{"id":671,"name":"Chipped Vest","source":"neutral","minTier":1,"maxTier":1,"imageUrl":"https://liquipedia.net/commons/images/thumb/9/99/Chipped_Vest_itemicon_dota2_gameasset.png/60px-Chipped_Vest_itemicon_dota2_gameasset.png","tags":[],"isAghanim":null}],"resolved":null,"requiresManualPick":true,"tierGateWon":null},{"tier":2,"attempts":[{"id":686,"name":"Medallion of Courage","source":"neutral","minTier":2,"maxTier":2,"imageUrl":"https://liquipedia.net/commons/images/thumb/5/58/Medallion_of_Courage_itemicon_dota2_gameasset.png/60px-Medallion_of_Courage_itemicon_dota2_gameasset.png","tags":["Disable","Survivability"],"isAghanim":null},{"id":683,"name":"Defiant Shell","source":"neutral","minTier":2,"maxTier":2,"imageUrl":"https://liquipedia.net/commons/images/thumb/1/1d/Defiant_Shell_itemicon_dota2_gameasset.png/60px-Defiant_Shell_itemicon_dota2_gameasset.png","tags":[],"isAghanim":null}],"resolved":null,"requiresManualPick":true,"tierGateWon":null},{"tier":3,"attempts":[{"id":694,"name":"Partisan's Brand","source":"neutral","minTier":3,"maxTier":3,"imageUrl":"https://liquipedia.net/commons/images/thumb/0/0f/Partisan%27s_Brand_itemicon_dota2_gameasset.png/60px-Partisan%27s_Brand_itemicon_dota2_gameasset.png","tags":["Magic Damage"],"isAghanim":null},{"id":693,"name":"Jidi Pollen Bag","source":"neutral","minTier":3,"maxTier":3,"imageUrl":"https://liquipedia.net/commons/images/thumb/7/76/Jidi_Pollen_Bag_itemicon_dota2_gameasset.png/60px-Jidi_Pollen_Bag_itemicon_dota2_gameasset.png","tags":["Sustain"],"isAghanim":null}],"resolved":null,"requiresManualPick":true,"tierGateWon":null},{"tier":4,"attempts":[{"id":708,"name":"Rattlecage","source":"neutral","minTier":4,"maxTier":4,"imageUrl":"https://liquipedia.net/commons/images/thumb/e/ef/Rattlecage_itemicon_dota2_gameasset.png/60px-Rattlecage_itemicon_dota2_gameasset.png","tags":["Attack Speed","Slow"],"isAghanim":null},{"id":702,"name":"Enchanter's Bauble","source":"neutral","minTier":4,"maxTier":4,"imageUrl":"https://liquipedia.net/commons/images/thumb/8/8f/Enchanter%27s_Bauble_itemicon_dota2_gameasset.png/60px-Enchanter%27s_Bauble_itemicon_dota2_gameasset.png","tags":[],"isAghanim":null}],"resolved":null,"requiresManualPick":true,"tierGateWon":null},{"tier":5,"attempts":[{"id":713,"name":"Harmonizer","source":"neutral","minTier":5,"maxTier":5,"imageUrl":"https://liquipedia.net/commons/images/thumb/8/8f/Harmonizer_itemicon_dota2_gameasset.png/60px-Harmonizer_itemicon_dota2_gameasset.png","tags":["Magic Damage"],"isAghanim":null},{"id":714,"name":"Minotaur Horn","source":"neutral","minTier":5,"maxTier":5,"imageUrl":"https://liquipedia.net/commons/images/thumb/8/84/Minotaur_Horn_itemicon_dota2_gameasset.png/60px-Minotaur_Horn_itemicon_dota2_gameasset.png","tags":["Dispel","Spell Resistance"],"isAghanim":null}],"resolved":null,"requiresManualPick":true,"tierGateWon":null}],"enchantments":[{"tier":1,"resolved":{"id":4,"name":"Mystical Enchantment","minTier":1,"maxTier":4,"imageUrl":"https://liquipedia.net/commons/images/thumb/0/08/Mystical_Enchantment_itemicon_dota2_gameasset.png/60px-Mystical_Enchantment_itemicon_dota2_gameasset.png","attributes":["universal","intelligence"]},"requiresManualPick":false},{"tier":2,"resolved":{"id":4,"name":"Mystical Enchantment","minTier":1,"maxTier":4,"imageUrl":"https://liquipedia.net/commons/images/thumb/0/08/Mystical_Enchantment_itemicon_dota2_gameasset.png/60px-Mystical_Enchantment_itemicon_dota2_gameasset.png","attributes":["universal","intelligence"]},"requiresManualPick":false},{"tier":3,"resolved":{"id":4,"name":"Mystical Enchantment","minTier":1,"maxTier":4,"imageUrl":"https://liquipedia.net/commons/images/thumb/0/08/Mystical_Enchantment_itemicon_dota2_gameasset.png/60px-Mystical_Enchantment_itemicon_dota2_gameasset.png","attributes":["universal","intelligence"]},"requiresManualPick":false},{"tier":4,"resolved":{"id":4,"name":"Mystical Enchantment","minTier":1,"maxTier":4,"imageUrl":"https://liquipedia.net/commons/images/thumb/0/08/Mystical_Enchantment_itemicon_dota2_gameasset.png/60px-Mystical_Enchantment_itemicon_dota2_gameasset.png","attributes":["universal","intelligence"]},"requiresManualPick":false},{"tier":5,"resolved":{"id":18,"name":"Manic Enchantment","minTier":5,"maxTier":5,"imageUrl":"https://liquipedia.net/commons/images/thumb/c/cf/Manic_Enchantment_itemicon_dota2_gameasset.png/60px-Manic_Enchantment_itemicon_dota2_gameasset.png","attributes":["universal"]},"requiresManualPick":false}],"aghanimScepter":{"included":false,"item":null,"timing":"anytime"},"aghanimShard":{"included":false,"item":null,"timing":"anytime"}} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..e198954 --- /dev/null +++ b/frontend/src/App.tsx @@ -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([]); + const [config, setConfig] = useState(DEFAULT_CONFIG); + const [build, setBuild] = useState(null); + const [generating, setGenerating] = useState(false); + const [busy, setBusy] = useState(false); + + useEffect(() => { + getTags().then(setTags); + }, []); + + function updateConfig(patch: Partial) { + setConfig((prev) => ({ ...prev, ...patch })); + } + + async function handleGenerate() { + setGenerating(true); + try { + setBuild(await generateBuild(config)); + } finally { + setGenerating(false); + } + } + + async function withBusy(fn: () => Promise) { + setBusy(true); + try { + setBuild(await fn()); + } finally { + setBusy(false); + } + } + + return ( +
+
+ +
+

Dota Random

+

Randomized hero + item builds, with full control over what gets rolled.

+
+
+ +
+ + +
+ {!build && !generating && ( +
+ Configure your scope, then generate a build. +
+ )} + {generating && !build && ( +
+ +
+ )} + {build && ( + 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)) + } + /> + )} +
+
+
+ ); +} diff --git a/frontend/src/api.ts b/frontend/src/api.ts new file mode 100644 index 0000000..9fb48d2 --- /dev/null +++ b/frontend/src/api.ts @@ -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(path: string, init?: RequestInit): Promise { + 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; +} + +function tagsQueryParam(tags: string[]): string { + return tags.length ? `?tags=${encodeURIComponent(tags.join(","))}` : ""; +} + +// GET /api/tags +export function getTags(): Promise { + return apiFetch("/tags"); +} + +// POST /api/builds +export function generateBuild(config: BuildConfig): Promise { + return apiFetch("/builds", { method: "POST", body: JSON.stringify(config) }); +} + +// POST /api/builds/{buildId}/reroll/hero +export function rerollHero(buildId: string): Promise { + return apiFetch(`/builds/${buildId}/reroll/hero`, { method: "POST" }); +} + +// POST /api/builds/{buildId}/reroll/item-slot/{slot} +export function rerollItemSlot(buildId: string, slot: number): Promise { + return apiFetch(`/builds/${buildId}/reroll/item-slot/${slot}`, { method: "POST" }); +} + +// POST /api/builds/{buildId}/reroll/neutral/{tier} +export function rerollNeutralStage(buildId: string, tier: number): Promise { + return apiFetch(`/builds/${buildId}/reroll/neutral/${tier}`, { method: "POST" }); +} + +// POST /api/builds/{buildId}/reroll/enchantment/{tier} +export function rerollEnchantmentStage(buildId: string, tier: number): Promise { + return apiFetch(`/builds/${buildId}/reroll/enchantment/${tier}`, { method: "POST" }); +} + +// GET /api/items?tags=&bootsOnly= +export function getManualItemOptions(tags: string[], bootsOnly = false): Promise { + const params = bootsOnly ? "?bootsOnly=true" : tagsQueryParam(tags); + return apiFetch(`/items${params}`); +} + +// GET /api/neutral-items?tier= +export function getManualNeutralOptions(tier: number): Promise { + return apiFetch(`/neutral-items?tier=${tier}`); +} + +// GET /api/enchantments?tier=&primaryAttribute= +export function getManualEnchantmentOptions(tier: number, attribute: PrimaryAttribute): Promise { + return apiFetch(`/enchantments?tier=${tier}&primaryAttribute=${attribute}`); +} + +// PATCH /api/builds/{buildId}/item-slot/{slot} +export function setItemSlotManually(buildId: string, slot: number, itemId: number): Promise { + return apiFetch(`/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 { + return apiFetch(`/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 { + return apiFetch(`/builds/${buildId}/enchantment/${tier}`, { + method: "PATCH", + body: JSON.stringify({ enchantmentId }), + }); +} diff --git a/frontend/src/components/BuildResult.tsx b/frontend/src/components/BuildResult.tsx new file mode 100644 index 0000000..be5ebcb --- /dev/null +++ b/frontend/src/components/BuildResult.tsx @@ -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(null); + + return ( +
+
+
+ {build.hero.name} +
+

{build.hero.name}

+

+ {build.hero.primaryAttribute} · {build.hero.attackType} +

+
+
+
+ +

Also re-rolls neutral items and enchantments.

+
+
+ +
+

Item build

+
+ {build.itemSlots.map((slot) => ( +
+ Slot {slot.slot}{slot.slot === 1 ? " (Boots)" : ""} + {slot.item ? ( + {slot.item.name} + ) : ( +
empty
+ )} + {slot.item?.name ?? "manual"} + {slot.isManual ? ( + + ) : ( + + )} +
+ ))} +
+ +
+ +
+

Neutral items by stage

+

+ Each tier suggests up to 2 candidates -- click whichever one actually dropped in your game, or browse all. +

+
+ {build.neutralItems.map((stage) => ( + onRerollNeutral(stage.tier)} + onQuickPick={(itemId) => onManualPickNeutral(stage.tier, itemId)} + onBrowseAll={() => setPicker({ kind: "neutral", tier: stage.tier })} + /> + ))} +
+
+ +
+

Enchantments by stage

+
+ {build.enchantments.map((stage) => ( + onRerollEnchantment(stage.tier)} + onManualPick={() => setPicker({ kind: "enchantment", tier: stage.tier })} + /> + ))} +
+

+ Guaranteed once rolling is on -- scoped to {build.hero.name}'s primary attribute ({build.hero.primaryAttribute}). +

+
+ + {picker?.kind === "item-slot" && ( + getManualItemOptions(build.config.itemTags, picker.slot === 1)} + onPick={(id) => { + onManualPickItemSlot(picker.slot, id); + setPicker(null); + }} + onClose={() => setPicker(null)} + /> + )} + {picker?.kind === "neutral" && ( + getManualNeutralOptions(picker.tier)} + onPick={(id) => { + onManualPickNeutral(picker.tier, id); + setPicker(null); + }} + onClose={() => setPicker(null)} + /> + )} + {picker?.kind === "enchantment" && ( + getManualEnchantmentOptions(picker.tier, build.hero.primaryAttribute)} + onPick={(id) => { + onManualPickEnchantment(picker.tier, id); + setPicker(null); + }} + onClose={() => setPicker(null)} + /> + )} +
+ ); +} + +/** 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 ( +
+ Tier {tier} + {tierGateWon === false && kept from last tier} + + {resolved ? ( + <> + {resolved.name} + {resolved.name} + + + ) : attempts.length > 0 ? ( + <> +
+ {attempts.map((item) => ( + + ))} +
+ click which you got +
+ + +
+ + ) : ( + <> +
+ pick one +
+ {noCandidates ? "disabled" : "no candidates"} + + + )} +
+ ); +} + +/** 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 ( +
+ Tier {tier} + {resolvedImage ? ( + {resolvedName} + ) : ( +
+ {requiresManualPick ? "pick one" : "—"} +
+ )} + {resolvedName ?? (requiresManualPick ? "disabled" : "—")} + {requiresManualPick ? ( + + ) : ( + + )} +
+ ); +} + +function AghanimRow({ build }: { build: Build }) { + return ( +
+ {(["aghanimScepter", "aghanimShard"] as const).map((key) => { + const result = build[key]; + const label = key === "aghanimScepter" ? "Aghanim's Scepter" : "Aghanim's Shard"; + return ( +
+ {result.item && ( + {label} + )} + + {label}: {result.included ? "in build" : "not in this build"} ·{" "} + {result.timing === "anytime" ? "purchasable anytime" : "buy only after your 6 slots are full"} + +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx new file mode 100644 index 0000000..eb69d04 --- /dev/null +++ b/frontend/src/components/ConfigPanel.tsx @@ -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(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) => void; + tags: Tag[]; + onGenerate: () => void; + generating: boolean; +}) { + return ( +
+
+
+

Attack type

+
+ {ATTACK_TYPES.map((t) => ( + onChange({ attackTypes: toggleInArray(config.attackTypes, t) })} + > + {t} + + ))} +
+
+
+

Primary attribute

+
+ {ATTRIBUTES.map((a) => ( + onChange({ attributes: toggleInArray(config.attributes, a) })} + > + {a} + + ))} +
+
+

No selection = any hero is eligible.

+
+ +
+

Only roll items carrying at least one of these tags

+
+ {tags.map((tag) => ( + onChange({ itemTags: toggleInArray(config.itemTags, tag.name) })} + > + {tag.name} + + ))} +
+

No selection = any tag is eligible.

+
+ +
+

Click a slot to exclude it from randomness (you'll pick it yourself)

+
+ {SLOTS.map((slot) => ( + onChange({ disabledItemSlots: toggleInArray(config.disabledItemSlots, slot) })} + > + Slot {slot} + + ))} +
+
+ +
+ onChange({ disableNeutralArtifactRandomness: !checked })} + /> + onChange({ disableEnchantmentRandomness: !checked })} + /> + onChange({ disableAllNeutralRolls: checked })} + /> + onChange({ neutralTierRerollEnabled: checked })} + /> +
+ +
+ onChange({ disableAghanimScepterRandomness: !checked })} + /> + onChange({ disableAghanimShardRandomness: !checked })} + /> + onChange({ aghanimScepterTiming: v })} + /> + onChange({ aghanimShardTiming: v })} + /> +
+ + +
+ ); +} + +function TimingSelect({ + label, + value, + onChange, +}: { + label: string; + value: AghanimTiming; + onChange: (v: AghanimTiming) => void; +}) { + return ( +
+ {label} + +
+ ); +} diff --git a/frontend/src/components/ManualPicker.tsx b/frontend/src/components/ManualPicker.tsx new file mode 100644 index 0000000..addbfb5 --- /dev/null +++ b/frontend/src/components/ManualPicker.tsx @@ -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; + onPick: (id: number) => void; + onClose: () => void; +}) { + const [options, setOptions] = useState(null); + + useEffect(() => { + let cancelled = false; + loadOptions().then((opts) => { + if (!cancelled) setOptions(opts); + }); + return () => { + cancelled = true; + }; + }, [loadOptions]); + + return ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+ {!options ? ( +
+ +
+ ) : options.length === 0 ? ( +

No eligible options for the current scope.

+ ) : ( +
+ {options.map((opt) => ( + + ))} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx new file mode 100644 index 0000000..a479379 --- /dev/null +++ b/frontend/src/components/ui.tsx @@ -0,0 +1,108 @@ +import type { ReactNode } from "react"; + +export function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +export function Toggle({ + checked, + onChange, + label, + hint, +}: { + checked: boolean; + onChange: (checked: boolean) => void; + label: string; + hint?: string; +}) { + return ( + + ); +} + +export function PillButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +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 ( + + ); +} + +export function Spinner({ small }: { small?: boolean }) { + return ( + + ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..b5fa466 --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + , +) diff --git a/frontend/src/types.ts b/frontend/src/types.ts new file mode 100644 index 0000000..69c42fc --- /dev/null +++ b/frontend/src/types.ts @@ -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; +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -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"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..72eee77 --- /dev/null +++ b/frontend/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, + }, + }, + }, +}) diff --git a/migrations/20260911221509_create_heroes_table.down.sql b/migrations/20260911221509_create_heroes_table.down.sql new file mode 100644 index 0000000..aebd418 --- /dev/null +++ b/migrations/20260911221509_create_heroes_table.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS heroes; +DROP TYPE IF EXISTS primary_attribute; +DROP TYPE IF EXISTS attack_type; diff --git a/migrations/20260911221509_create_heroes_table.up.sql b/migrations/20260911221509_create_heroes_table.up.sql new file mode 100644 index 0000000..8cb2251 --- /dev/null +++ b/migrations/20260911221509_create_heroes_table.up.sql @@ -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 +); diff --git a/migrations/20260911221510_create_tags_table.down.sql b/migrations/20260911221510_create_tags_table.down.sql new file mode 100644 index 0000000..b7898fb --- /dev/null +++ b/migrations/20260911221510_create_tags_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tags; diff --git a/migrations/20260911221510_create_tags_table.up.sql b/migrations/20260911221510_create_tags_table.up.sql new file mode 100644 index 0000000..ddb3009 --- /dev/null +++ b/migrations/20260911221510_create_tags_table.up.sql @@ -0,0 +1,4 @@ +CREATE TABLE tags ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE +); diff --git a/migrations/20260911221511_create_items_table.down.sql b/migrations/20260911221511_create_items_table.down.sql new file mode 100644 index 0000000..520ee12 --- /dev/null +++ b/migrations/20260911221511_create_items_table.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS items; +DROP TYPE IF EXISTS item_source; diff --git a/migrations/20260911221511_create_items_table.up.sql b/migrations/20260911221511_create_items_table.up.sql new file mode 100644 index 0000000..42adb25 --- /dev/null +++ b/migrations/20260911221511_create_items_table.up.sql @@ -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) + ) +); diff --git a/migrations/20260911221512_create_item_tags_table.down.sql b/migrations/20260911221512_create_item_tags_table.down.sql new file mode 100644 index 0000000..b75a1d7 --- /dev/null +++ b/migrations/20260911221512_create_item_tags_table.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS item_tags; diff --git a/migrations/20260911221512_create_item_tags_table.up.sql b/migrations/20260911221512_create_item_tags_table.up.sql new file mode 100644 index 0000000..ec44e2a --- /dev/null +++ b/migrations/20260911221512_create_item_tags_table.up.sql @@ -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) +); diff --git a/migrations/20260913142731_create_enchantments_table.down.sql b/migrations/20260913142731_create_enchantments_table.down.sql new file mode 100644 index 0000000..4e3fd09 --- /dev/null +++ b/migrations/20260913142731_create_enchantments_table.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS enchantment_attributes; +DROP TABLE IF EXISTS enchantments; diff --git a/migrations/20260913142731_create_enchantments_table.up.sql b/migrations/20260913142731_create_enchantments_table.up.sql new file mode 100644 index 0000000..e14141d --- /dev/null +++ b/migrations/20260913142731_create_enchantments_table.up.sql @@ -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) +); diff --git a/migrations/20260913163512_seed_heroes.down.sql b/migrations/20260913163512_seed_heroes.down.sql new file mode 100644 index 0000000..07a8ae4 --- /dev/null +++ b/migrations/20260913163512_seed_heroes.down.sql @@ -0,0 +1 @@ +DELETE FROM heroes; diff --git a/migrations/20260913163512_seed_heroes.up.sql b/migrations/20260913163512_seed_heroes.up.sql new file mode 100644 index 0000000..3b0c2f4 --- /dev/null +++ b/migrations/20260913163512_seed_heroes.up.sql @@ -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'); diff --git a/migrations/20260913170640_add_item_grades.down.sql b/migrations/20260913170640_add_item_grades.down.sql new file mode 100644 index 0000000..ee77828 --- /dev/null +++ b/migrations/20260913170640_add_item_grades.down.sql @@ -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; diff --git a/migrations/20260913170640_add_item_grades.up.sql b/migrations/20260913170640_add_item_grades.up.sql new file mode 100644 index 0000000..a5bfe9a --- /dev/null +++ b/migrations/20260913170640_add_item_grades.up.sql @@ -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) +); diff --git a/migrations/20260913202842_seed_items.down.sql b/migrations/20260913202842_seed_items.down.sql new file mode 100644 index 0000000..3eac87d --- /dev/null +++ b/migrations/20260913202842_seed_items.down.sql @@ -0,0 +1,3 @@ +DELETE FROM item_tags; +DELETE FROM tags; +DELETE FROM items; diff --git a/migrations/20260913202842_seed_items.up.sql b/migrations/20260913202842_seed_items.up.sql new file mode 100644 index 0000000..602911f --- /dev/null +++ b/migrations/20260913202842_seed_items.up.sql @@ -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; diff --git a/migrations/20260913202843_seed_enchantments.down.sql b/migrations/20260913202843_seed_enchantments.down.sql new file mode 100644 index 0000000..10b986e --- /dev/null +++ b/migrations/20260913202843_seed_enchantments.down.sql @@ -0,0 +1,2 @@ +DELETE FROM enchantment_attributes; +DELETE FROM enchantments; diff --git a/migrations/20260913202843_seed_enchantments.up.sql b/migrations/20260913202843_seed_enchantments.up.sql new file mode 100644 index 0000000..36cd476 --- /dev/null +++ b/migrations/20260913202843_seed_enchantments.up.sql @@ -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; diff --git a/scripts/scrape_heroes.py b/scripts/scrape_heroes.py new file mode 100644 index 0000000..2272d9b --- /dev/null +++ b/scripts/scrape_heroes.py @@ -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 /scripts/, migrations live in /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() diff --git a/scripts/scrape_items.py b/scripts/scrape_items.py new file mode 100644 index 0000000..29e1cde --- /dev/null +++ b/scripts/scrape_items.py @@ -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 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() diff --git a/src/app_state.rs b/src/app_state.rs new file mode 100644 index 0000000..f676fc6 --- /dev/null +++ b/src/app_state.rs @@ -0,0 +1,19 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use sqlx::PgPool; + +use crate::models::Build; + +pub type BuildsStore = Arc>>; + +#[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, +} diff --git a/src/build_handlers.rs b/src/build_handlers.rs new file mode 100644 index 0000000..8f8122f --- /dev/null +++ b/src/build_handlers.rs @@ -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 { + 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, Json(config): Json) -> Result, 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, Path(id): Path) -> Result, 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, + Path((id, slot)): Path<(String, i32)>, +) -> Result, 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, + Path((id, tier)): Path<(String, i32)>, +) -> Result, 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, + Path((id, tier)): Path<(String, i32)>, +) -> Result, 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, + Path((id, slot)): Path<(String, i32)>, + Json(body): Json, +) -> Result, AppError> { + let mut build = get_build(&state, &id)?; + let item: Option = + 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, + Path((id, tier)): Path<(String, i32)>, + Json(body): Json, +) -> Result, AppError> { + let mut build = get_build(&state, &id)?; + let item: Option = + 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, + Path((id, tier)): Path<(String, i32)>, + Json(body): Json, +) -> Result, AppError> { + let mut build = get_build(&state, &id)?; + let enchantment: Option = + 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(()) + } +} diff --git a/src/enchantment_handlers.rs b/src/enchantment_handlers.rs new file mode 100644 index 0000000..5fcc4bf --- /dev/null +++ b/src/enchantment_handlers.rs @@ -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, + Query(q): Query, +) -> Result>, 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)) +} diff --git a/src/error_handlers.rs b/src/error_handlers.rs new file mode 100644 index 0000000..62662f9 --- /dev/null +++ b/src/error_handlers.rs @@ -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 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)); + } +} diff --git a/src/item_handlers.rs b/src/item_handlers.rs new file mode 100644 index 0000000..4c8b365 --- /dev/null +++ b/src/item_handlers.rs @@ -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, + // 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) -> Vec { + 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, Query(q): Query) -> Result>, 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, + Query(q): Query, +) -> Result>, 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)) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..1c82cae --- /dev/null +++ b/src/main.rs @@ -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::().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; +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..941f695 --- /dev/null +++ b/src/models.rs @@ -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, + pub attributes: Vec, + pub item_tags: Vec, + pub disable_neutral_artifact_randomness: bool, + pub disable_enchantment_randomness: bool, + pub disable_all_neutral_rolls: bool, + pub disabled_item_slots: Vec, + 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, + pub max_tier: Option, + pub min_grade: Option, + pub max_grade: Option, + pub image_url: String, + #[sqlx(skip)] + pub tags: Vec, + #[sqlx(skip)] + pub is_aghanim: Option, +} + +#[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, +} + +#[derive(serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct ItemSlot { + pub slot: i32, + pub item: Option, + pub is_manual: bool, +} + +#[derive(serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct NeutralStageResult { + pub tier: i32, + pub attempts: Vec, + pub resolved: Option, + pub requires_manual_pick: bool, + pub tier_gate_won: Option, +} + +#[derive(serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct EnchantmentStageResult { + pub tier: i32, + pub resolved: Option, + pub requires_manual_pick: bool, +} + +#[derive(serde::Serialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AghanimResult { + pub included: bool, + pub item: Option, + 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, + pub neutral_items: Vec, + pub enchantments: Vec, + pub aghanim_scepter: AghanimResult, + pub aghanim_shard: AghanimResult, +} diff --git a/src/roll.rs b/src/roll.rs new file mode 100644 index 0000000..0792807 --- /dev/null +++ b/src/roll.rs @@ -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 { + let heroes: Vec = 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, AppError> { + let items: Vec = 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, AppError> { + let items: Vec = 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) -> Option { + 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, Vec), 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, 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 { + 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 = 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, AppError> { + let items: Vec = 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 { + 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, + gated: bool, + tier_gate_chance: f64, +) -> Result { + 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, 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 = 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, AppError> { + let enchantments: Vec = 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 { + 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, 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 = 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 = slots.iter().filter_map(|s| s.item.as_ref().map(|i| i.id)).collect(); + let unique: HashSet = 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(()) + } +} diff --git a/src/tag_handlers.rs b/src/tag_handlers.rs new file mode 100644 index 0000000..29f844e --- /dev/null +++ b/src/tag_handlers.rs @@ -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) -> Result>, AppError> { + let tags = sqlx::query_as("SELECT * FROM tags;") + .fetch_all(&pool.0) + .await?; + Ok(Json(tags)) +}