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

This commit is contained in:
kwiialyssa
2026-09-13 20:37:48 +03:00
commit edb4cd8613
59 changed files with 8669 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+14
View File
@@ -0,0 +1,14 @@
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
File diff suppressed because one or more lines are too long
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dota Random</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Same-origin API calls from the SPA get forwarded to the backend
# container by its compose service name -- the frontend never needs
# to know a real hostname/port for the API, and there's no CORS to deal with.
location /api/ {
proxy_pass http://dota-random-web:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
try_files $uri /index.html;
}
}
+1942
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1",
"oxlint": "^1.81.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.2",
"vite": "^8.3.0"
}
}
+29
View File
@@ -0,0 +1,29 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none">
<defs>
<linearGradient id="dice-fire" x1="10" y1="40" x2="38" y2="8" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#6b0f0f"/>
<stop offset="0.5" stop-color="#e2321a"/>
<stop offset="0.8" stop-color="#ff8a1e"/>
<stop offset="1" stop-color="#ffcf4d"/>
</linearGradient>
<radialGradient id="dice-glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(24 24) rotate(90) scale(23)">
<stop offset="0.6" stop-color="#ff5a1f" stop-opacity="0.55"/>
<stop offset="1" stop-color="#ff5a1f" stop-opacity="0"/>
</radialGradient>
</defs>
<circle cx="24" cy="24" r="23" fill="url(#dice-glow)"/>
<g transform="rotate(-8 24 24)">
<rect x="8" y="8" width="32" height="32" rx="8" fill="url(#dice-fire)" stroke="#4a0808" stroke-width="1.5"/>
<circle cx="16.5" cy="16.5" r="2.9" fill="#fff3d6"/>
<circle cx="31.5" cy="16.5" r="2.9" fill="#fff3d6"/>
<circle cx="24" cy="24" r="2.9" fill="#fff3d6"/>
<circle cx="16.5" cy="31.5" r="2.9" fill="#fff3d6"/>
<circle cx="31.5" cy="31.5" r="2.9" fill="#fff3d6"/>
</g>
<path d="M37 6c1.8 2 1.6 3.7.2 5.1-1 1-.7 2.6.8 2.6 2.6 0 4.7-2.1 4.7-4.7 0-2.9-2.7-4.2-5.7-3z" fill="#ffb13c"/>
<path d="M41.5 13.2c1 1 .9 2-.1 2.9-.6.6-.4 1.5.5 1.5 1.5 0 2.7-1.2 2.7-2.7 0-1.6-1.6-2.4-3.1-1.7z" fill="#ff7a1a"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import {
generateBuild,
getTags,
rerollEnchantmentStage,
rerollHero,
rerollItemSlot,
rerollNeutralStage,
setEnchantmentStageManually,
setItemSlotManually,
setNeutralStageManually,
} from "./api";
import { BuildResult } from "./components/BuildResult";
import { ConfigPanel } from "./components/ConfigPanel";
import { Spinner } from "./components/ui";
import type { Build, BuildConfig, Tag } from "./types";
const DEFAULT_CONFIG: BuildConfig = {
attackTypes: [],
attributes: [],
itemTags: [],
disableNeutralArtifactRandomness: false,
disableEnchantmentRandomness: false,
disableAllNeutralRolls: false,
disabledItemSlots: [],
disableAghanimScepterRandomness: false,
disableAghanimShardRandomness: false,
neutralTierRerollEnabled: true,
aghanimScepterTiming: "anytime",
aghanimShardTiming: "anytime",
};
export default function App() {
const [tags, setTags] = useState<Tag[]>([]);
const [config, setConfig] = useState<BuildConfig>(DEFAULT_CONFIG);
const [build, setBuild] = useState<Build | null>(null);
const [generating, setGenerating] = useState(false);
const [busy, setBusy] = useState(false);
useEffect(() => {
getTags().then(setTags);
}, []);
function updateConfig(patch: Partial<BuildConfig>) {
setConfig((prev) => ({ ...prev, ...patch }));
}
async function handleGenerate() {
setGenerating(true);
try {
setBuild(await generateBuild(config));
} finally {
setGenerating(false);
}
}
async function withBusy(fn: () => Promise<Build>) {
setBusy(true);
try {
setBuild(await fn());
} finally {
setBusy(false);
}
}
return (
<div className="mx-auto flex min-h-screen max-w-6xl flex-col gap-6 p-6">
<header className="flex items-center gap-3">
<img src="/favicon.svg" alt="" className="h-10 w-10" />
<div>
<h1 className="text-2xl font-bold text-neutral-100">Dota Random</h1>
<p className="text-sm text-neutral-500">Randomized hero + item builds, with full control over what gets rolled.</p>
</div>
</header>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[320px_1fr]">
<ConfigPanel config={config} onChange={updateConfig} tags={tags} onGenerate={handleGenerate} generating={generating} />
<div>
{!build && !generating && (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-neutral-800 text-neutral-600">
Configure your scope, then generate a build.
</div>
)}
{generating && !build && (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-neutral-800">
<Spinner />
</div>
)}
{build && (
<BuildResult
build={build}
busy={busy}
onRerollHero={() => withBusy(() => rerollHero(build.id))}
onRerollItemSlot={(slot) => withBusy(() => rerollItemSlot(build.id, slot))}
onRerollNeutral={(tier) => withBusy(() => rerollNeutralStage(build.id, tier))}
onRerollEnchantment={(tier) => withBusy(() => rerollEnchantmentStage(build.id, tier))}
onManualPickItemSlot={(slot, itemId) => withBusy(() => setItemSlotManually(build.id, slot, itemId))}
onManualPickNeutral={(tier, itemId) => withBusy(() => setNeutralStageManually(build.id, tier, itemId))}
onManualPickEnchantment={(tier, enchantmentId) =>
withBusy(() => setEnchantmentStageManually(build.id, tier, enchantmentId))
}
/>
)}
</div>
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import type { Build, BuildConfig, Enchantment, Item, PrimaryAttribute, Tag } from "./types";
/**
* Thin fetch wrappers around the real backend. In production nginx proxies
* `/api/*` to the backend container (see frontend/nginx.conf); in dev, Vite's
* server.proxy does the same thing against a locally-running backend (see
* vite.config.ts) -- either way these calls are same-origin, so there's no
* CORS to deal with here.
*/
const API_BASE = "/api";
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error ?? `Request to ${path} failed with status ${res.status}`);
}
return res.json() as Promise<T>;
}
function tagsQueryParam(tags: string[]): string {
return tags.length ? `?tags=${encodeURIComponent(tags.join(","))}` : "";
}
// GET /api/tags
export function getTags(): Promise<Tag[]> {
return apiFetch<Tag[]>("/tags");
}
// POST /api/builds
export function generateBuild(config: BuildConfig): Promise<Build> {
return apiFetch<Build>("/builds", { method: "POST", body: JSON.stringify(config) });
}
// POST /api/builds/{buildId}/reroll/hero
export function rerollHero(buildId: string): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/reroll/hero`, { method: "POST" });
}
// POST /api/builds/{buildId}/reroll/item-slot/{slot}
export function rerollItemSlot(buildId: string, slot: number): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/reroll/item-slot/${slot}`, { method: "POST" });
}
// POST /api/builds/{buildId}/reroll/neutral/{tier}
export function rerollNeutralStage(buildId: string, tier: number): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/reroll/neutral/${tier}`, { method: "POST" });
}
// POST /api/builds/{buildId}/reroll/enchantment/{tier}
export function rerollEnchantmentStage(buildId: string, tier: number): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/reroll/enchantment/${tier}`, { method: "POST" });
}
// GET /api/items?tags=&bootsOnly=
export function getManualItemOptions(tags: string[], bootsOnly = false): Promise<Item[]> {
const params = bootsOnly ? "?bootsOnly=true" : tagsQueryParam(tags);
return apiFetch<Item[]>(`/items${params}`);
}
// GET /api/neutral-items?tier=
export function getManualNeutralOptions(tier: number): Promise<Item[]> {
return apiFetch<Item[]>(`/neutral-items?tier=${tier}`);
}
// GET /api/enchantments?tier=&primaryAttribute=
export function getManualEnchantmentOptions(tier: number, attribute: PrimaryAttribute): Promise<Enchantment[]> {
return apiFetch<Enchantment[]>(`/enchantments?tier=${tier}&primaryAttribute=${attribute}`);
}
// PATCH /api/builds/{buildId}/item-slot/{slot}
export function setItemSlotManually(buildId: string, slot: number, itemId: number): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/item-slot/${slot}`, {
method: "PATCH",
body: JSON.stringify({ itemId }),
});
}
// PATCH /api/builds/{buildId}/neutral/{tier}
export function setNeutralStageManually(buildId: string, tier: number, itemId: number): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/neutral/${tier}`, {
method: "PATCH",
body: JSON.stringify({ itemId }),
});
}
// PATCH /api/builds/{buildId}/enchantment/{tier}
export function setEnchantmentStageManually(buildId: string, tier: number, enchantmentId: number): Promise<Build> {
return apiFetch<Build>(`/builds/${buildId}/enchantment/${tier}`, {
method: "PATCH",
body: JSON.stringify({ enchantmentId }),
});
}
+338
View File
@@ -0,0 +1,338 @@
import { useState } from "react";
import { getManualEnchantmentOptions, getManualItemOptions, getManualNeutralOptions } from "../api";
import type { Build, Item } from "../types";
import { ManualPicker } from "./ManualPicker";
import { Button } from "./ui";
type PickerTarget =
| { kind: "item-slot"; slot: number }
| { kind: "neutral"; tier: number }
| { kind: "enchantment"; tier: number };
export function BuildResult({
build,
busy,
onRerollHero,
onRerollItemSlot,
onRerollNeutral,
onRerollEnchantment,
onManualPickItemSlot,
onManualPickNeutral,
onManualPickEnchantment,
}: {
build: Build;
busy: boolean;
onRerollHero: () => void;
onRerollItemSlot: (slot: number) => void;
onRerollNeutral: (tier: number) => void;
onRerollEnchantment: (tier: number) => void;
onManualPickItemSlot: (slot: number, itemId: number) => void;
onManualPickNeutral: (tier: number, itemId: number) => void;
onManualPickEnchantment: (tier: number, enchantmentId: number) => void;
}) {
const [picker, setPicker] = useState<PickerTarget | null>(null);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
<div className="flex items-center gap-3">
<img
src={build.hero.imageUrl}
alt={build.hero.name}
referrerPolicy="no-referrer"
className="h-12 w-20 rounded object-cover"
/>
<div>
<p className="text-base font-semibold text-neutral-100">{build.hero.name}</p>
<p className="text-xs capitalize text-neutral-500">
{build.hero.primaryAttribute} · {build.hero.attackType}
</p>
</div>
</div>
<div className="flex flex-col items-end gap-1">
<Button variant="ghost" small onClick={onRerollHero} disabled={busy}>
Reroll hero
</Button>
<p className="text-[10px] text-neutral-600">Also re-rolls neutral items and enchantments.</p>
</div>
</div>
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-400">Item build</h3>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">
{build.itemSlots.map((slot) => (
<div key={slot.slot} className="flex flex-col items-center gap-1 rounded-md border border-neutral-800 bg-neutral-800/40 p-2">
<span className="text-[10px] text-neutral-500">Slot {slot.slot}{slot.slot === 1 ? " (Boots)" : ""}</span>
{slot.item ? (
<img
src={slot.item.imageUrl}
alt={slot.item.name}
title={slot.item.name}
referrerPolicy="no-referrer"
className="h-10 w-16 rounded object-cover"
/>
) : (
<div className="flex h-10 w-16 items-center justify-center rounded bg-neutral-800 text-[10px] text-neutral-600">empty</div>
)}
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">{slot.item?.name ?? "manual"}</span>
{slot.isManual ? (
<Button small variant="ghost" onClick={() => setPicker({ kind: "item-slot", slot: slot.slot })} disabled={busy}>
Pick
</Button>
) : (
<Button small variant="ghost" onClick={() => onRerollItemSlot(slot.slot)} disabled={busy}>
Reroll
</Button>
)}
</div>
))}
</div>
<AghanimRow build={build} />
</div>
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-400">Neutral items by stage</h3>
<p className="mb-2 text-xs text-neutral-600">
Each tier suggests up to 2 candidates -- click whichever one actually dropped in your game, or browse all.
</p>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{build.neutralItems.map((stage) => (
<NeutralStageCard
key={stage.tier}
tier={stage.tier}
resolved={stage.resolved}
attempts={stage.attempts}
requiresManualPick={stage.requiresManualPick}
tierGateWon={stage.tierGateWon}
busy={busy}
onReroll={() => onRerollNeutral(stage.tier)}
onQuickPick={(itemId) => onManualPickNeutral(stage.tier, itemId)}
onBrowseAll={() => setPicker({ kind: "neutral", tier: stage.tier })}
/>
))}
</div>
</div>
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-400">Enchantments by stage</h3>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{build.enchantments.map((stage) => (
<EnchantmentStageCard
key={stage.tier}
tier={stage.tier}
resolvedName={stage.resolved?.name}
resolvedImage={stage.resolved?.imageUrl}
requiresManualPick={stage.requiresManualPick}
busy={busy}
onReroll={() => onRerollEnchantment(stage.tier)}
onManualPick={() => setPicker({ kind: "enchantment", tier: stage.tier })}
/>
))}
</div>
<p className="mt-2 text-xs text-neutral-600">
Guaranteed once rolling is on -- scoped to {build.hero.name}'s primary attribute ({build.hero.primaryAttribute}).
</p>
</div>
{picker?.kind === "item-slot" && (
<ManualPicker
title={`Pick an item for slot ${picker.slot}${picker.slot === 1 ? " (Boots)" : ""}`}
loadOptions={() => getManualItemOptions(build.config.itemTags, picker.slot === 1)}
onPick={(id) => {
onManualPickItemSlot(picker.slot, id);
setPicker(null);
}}
onClose={() => setPicker(null)}
/>
)}
{picker?.kind === "neutral" && (
<ManualPicker
title={`Pick a tier ${picker.tier} neutral item`}
loadOptions={() => getManualNeutralOptions(picker.tier)}
onPick={(id) => {
onManualPickNeutral(picker.tier, id);
setPicker(null);
}}
onClose={() => setPicker(null)}
/>
)}
{picker?.kind === "enchantment" && (
<ManualPicker
title={`Pick a tier ${picker.tier} enchantment`}
loadOptions={() => getManualEnchantmentOptions(picker.tier, build.hero.primaryAttribute)}
onPick={(id) => {
onManualPickEnchantment(picker.tier, id);
setPicker(null);
}}
onClose={() => setPicker(null)}
/>
)}
</div>
);
}
/** Neutral artifacts: up to 2 rolled candidates, always a manual confirm
* (the app can't know what actually dropped in-game), plus a full browse
* fallback and a reroll for a fresh pair of candidates. */
function NeutralStageCard({
tier,
resolved,
attempts,
requiresManualPick,
tierGateWon,
busy,
onReroll,
onQuickPick,
onBrowseAll,
}: {
tier: number;
resolved?: Item | null;
attempts: Item[];
requiresManualPick: boolean;
tierGateWon?: boolean;
busy: boolean;
onReroll: () => void;
onQuickPick: (itemId: number) => void;
onBrowseAll: () => void;
}) {
const noCandidates = requiresManualPick && attempts.length === 0 && !resolved;
return (
<div className="flex flex-col items-center gap-1 rounded-md border border-neutral-800 bg-neutral-800/40 p-2">
<span className="text-[10px] text-neutral-500">Tier {tier}</span>
{tierGateWon === false && <span className="text-[10px] text-amber-500">kept from last tier</span>}
{resolved ? (
<>
<img
src={resolved.imageUrl}
alt={resolved.name}
title={resolved.name}
referrerPolicy="no-referrer"
className="h-10 w-16 rounded object-cover"
/>
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">{resolved.name}</span>
<Button small variant="ghost" onClick={onReroll} disabled={busy}>
Reroll
</Button>
</>
) : attempts.length > 0 ? (
<>
<div className="flex gap-1">
{attempts.map((item) => (
<button
key={item.id}
title={`Got ${item.name}?`}
onClick={() => onQuickPick(item.id)}
disabled={busy}
className="rounded border border-neutral-700 hover:border-emerald-600 disabled:opacity-50"
>
<img
src={item.imageUrl}
alt={item.name}
referrerPolicy="no-referrer"
className="h-10 w-8 rounded object-cover"
/>
</button>
))}
</div>
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">click which you got</span>
<div className="flex gap-1">
<Button small variant="ghost" onClick={onReroll} disabled={busy}>
Reroll
</Button>
<Button small variant="ghost" onClick={onBrowseAll} disabled={busy}>
Other
</Button>
</div>
</>
) : (
<>
<div className="flex h-10 w-16 items-center justify-center rounded bg-neutral-800 text-[10px] text-neutral-600">
pick one
</div>
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">{noCandidates ? "disabled" : "no candidates"}</span>
<Button small variant="ghost" onClick={onBrowseAll} disabled={busy}>
Pick
</Button>
</>
)}
</div>
);
}
/** Enchantments: guaranteed once rolling is on for this stage -- the only
* choice is whether to roll at all, so this stays a simple resolved/pick binary. */
function EnchantmentStageCard({
tier,
resolvedName,
resolvedImage,
requiresManualPick,
busy,
onReroll,
onManualPick,
}: {
tier: number;
resolvedName?: string;
resolvedImage?: string;
requiresManualPick: boolean;
busy: boolean;
onReroll: () => void;
onManualPick: () => void;
}) {
return (
<div className="flex flex-col items-center gap-1 rounded-md border border-neutral-800 bg-neutral-800/40 p-2">
<span className="text-[10px] text-neutral-500">Tier {tier}</span>
{resolvedImage ? (
<img
src={resolvedImage}
alt={resolvedName}
title={resolvedName}
referrerPolicy="no-referrer"
className="h-10 w-16 rounded object-cover"
/>
) : (
<div className="flex h-10 w-16 items-center justify-center rounded bg-neutral-800 text-[10px] text-neutral-600">
{requiresManualPick ? "pick one" : "—"}
</div>
)}
<span className="line-clamp-1 text-center text-[10px] text-neutral-400">{resolvedName ?? (requiresManualPick ? "disabled" : "—")}</span>
{requiresManualPick ? (
<Button small variant="ghost" onClick={onManualPick} disabled={busy}>
Pick
</Button>
) : (
<Button small variant="ghost" onClick={onReroll} disabled={busy}>
Reroll
</Button>
)}
</div>
);
}
function AghanimRow({ build }: { build: Build }) {
return (
<div className="mt-3 flex flex-wrap gap-3 border-t border-neutral-800 pt-3">
{(["aghanimScepter", "aghanimShard"] as const).map((key) => {
const result = build[key];
const label = key === "aghanimScepter" ? "Aghanim's Scepter" : "Aghanim's Shard";
return (
<div key={key} className="flex items-center gap-2 rounded-md border border-neutral-800 bg-neutral-800/40 px-3 py-1.5">
{result.item && (
<img
src={result.item.imageUrl}
alt={label}
referrerPolicy="no-referrer"
className="h-6 w-10 rounded object-cover"
/>
)}
<span className="text-xs text-neutral-300">
{label}: {result.included ? "in build" : "not in this build"} ·{" "}
{result.timing === "anytime" ? "purchasable anytime" : "buy only after your 6 slots are full"}
</span>
</div>
);
})}
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
import type { AghanimTiming, AttackType, BuildConfig, PrimaryAttribute, Tag } from "../types";
import { Button, PillButton, Section, Toggle } from "./ui";
const ATTACK_TYPES: AttackType[] = ["melee", "ranged"];
const ATTRIBUTES: PrimaryAttribute[] = ["strength", "agility", "intelligence", "universal"];
const SLOTS = [1, 2, 3, 4, 5, 6];
function toggleInArray<T>(arr: T[], value: T): T[] {
return arr.includes(value) ? arr.filter((v) => v !== value) : [...arr, value];
}
export function ConfigPanel({
config,
onChange,
tags,
onGenerate,
generating,
}: {
config: BuildConfig;
onChange: (patch: Partial<BuildConfig>) => void;
tags: Tag[];
onGenerate: () => void;
generating: boolean;
}) {
return (
<div className="flex flex-col gap-4">
<Section title="Hero scope">
<div className="mb-3">
<p className="mb-1.5 text-xs text-neutral-500">Attack type</p>
<div className="flex flex-wrap gap-1.5">
{ATTACK_TYPES.map((t) => (
<PillButton
key={t}
active={config.attackTypes.includes(t)}
onClick={() => onChange({ attackTypes: toggleInArray(config.attackTypes, t) })}
>
{t}
</PillButton>
))}
</div>
</div>
<div>
<p className="mb-1.5 text-xs text-neutral-500">Primary attribute</p>
<div className="flex flex-wrap gap-1.5">
{ATTRIBUTES.map((a) => (
<PillButton
key={a}
active={config.attributes.includes(a)}
onClick={() => onChange({ attributes: toggleInArray(config.attributes, a) })}
>
{a}
</PillButton>
))}
</div>
</div>
<p className="mt-2 text-xs text-neutral-600">No selection = any hero is eligible.</p>
</Section>
<Section title="Item scope">
<p className="mb-1.5 text-xs text-neutral-500">Only roll items carrying at least one of these tags</p>
<div className="flex flex-wrap gap-1.5">
{tags.map((tag) => (
<PillButton
key={tag.id}
active={config.itemTags.includes(tag.name)}
onClick={() => onChange({ itemTags: toggleInArray(config.itemTags, tag.name) })}
>
{tag.name}
</PillButton>
))}
</div>
<p className="mt-2 text-xs text-neutral-600">No selection = any tag is eligible.</p>
</Section>
<Section title="Item slots">
<p className="mb-1.5 text-xs text-neutral-500">Click a slot to exclude it from randomness (you'll pick it yourself)</p>
<div className="flex flex-wrap gap-1.5">
{SLOTS.map((slot) => (
<PillButton
key={slot}
active={!config.disabledItemSlots.includes(slot)}
onClick={() => onChange({ disabledItemSlots: toggleInArray(config.disabledItemSlots, slot) })}
>
Slot {slot}
</PillButton>
))}
</div>
</Section>
<Section title="Neutral items">
<Toggle
label="Randomize neutral artifacts"
hint="Off = every tier is left for manual pick (1.4.1 / 1.4.3)"
checked={!config.disableAllNeutralRolls && !config.disableNeutralArtifactRandomness}
onChange={(checked) => onChange({ disableNeutralArtifactRandomness: !checked })}
/>
<Toggle
label="Randomize enchantments"
hint="Off = every tier is left for manual pick (1.4.2 / 1.4.3)"
checked={!config.disableAllNeutralRolls && !config.disableEnchantmentRandomness}
onChange={(checked) => onChange({ disableEnchantmentRandomness: !checked })}
/>
<Toggle
label="Disable neutral rolls entirely"
hint="Overrides both toggles above (1.4.3)"
checked={config.disableAllNeutralRolls}
onChange={(checked) => onChange({ disableAllNeutralRolls: checked })}
/>
<Toggle
label="Guaranteed roll every tier"
hint="Off = 50/50 gate per tier decides if you even get to roll (1.4.6)"
checked={config.neutralTierRerollEnabled}
onChange={(checked) => onChange({ neutralTierRerollEnabled: checked })}
/>
</Section>
<Section title="Aghanim's items">
<Toggle
label="Randomize Aghanim's Scepter"
hint="Off = assumed purchasable anytime, outside the roll (1.4.5)"
checked={!config.disableAghanimScepterRandomness}
onChange={(checked) => onChange({ disableAghanimScepterRandomness: !checked })}
/>
<Toggle
label="Randomize Aghanim's Shard"
hint="Off = assumed purchasable anytime, outside the roll (1.4.5)"
checked={!config.disableAghanimShardRandomness}
onChange={(checked) => onChange({ disableAghanimShardRandomness: !checked })}
/>
<TimingSelect
label="Scepter purchase timing"
value={config.aghanimScepterTiming}
onChange={(v) => onChange({ aghanimScepterTiming: v })}
/>
<TimingSelect
label="Shard purchase timing"
value={config.aghanimShardTiming}
onChange={(v) => onChange({ aghanimShardTiming: v })}
/>
</Section>
<Button onClick={onGenerate} disabled={generating}>
{generating ? "Rolling…" : "Generate random build"}
</Button>
</div>
);
}
function TimingSelect({
label,
value,
onChange,
}: {
label: string;
value: AghanimTiming;
onChange: (v: AghanimTiming) => void;
}) {
return (
<div className="mt-2 flex items-center justify-between gap-2">
<span className="text-sm text-neutral-300">{label}</span>
<select
value={value}
onChange={(e) => onChange(e.target.value as AghanimTiming)}
className="rounded-md border border-neutral-700 bg-neutral-800 px-2 py-1 text-xs text-neutral-200"
>
<option value="anytime">Anytime mid-build</option>
<option value="after_core_slots">Only after 6 slots filled</option>
</select>
</div>
);
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useState } from "react";
import { Spinner } from "./ui";
interface PickableOption {
id: number;
name: string;
imageUrl: string;
}
export function ManualPicker({
title,
loadOptions,
onPick,
onClose,
}: {
title: string;
loadOptions: () => Promise<PickableOption[]>;
onPick: (id: number) => void;
onClose: () => void;
}) {
const [options, setOptions] = useState<PickableOption[] | null>(null);
useEffect(() => {
let cancelled = false;
loadOptions().then((opts) => {
if (!cancelled) setOptions(opts);
});
return () => {
cancelled = true;
};
}, [loadOptions]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4" onClick={onClose}>
<div
className="max-h-[80vh] w-full max-w-lg overflow-y-auto rounded-lg border border-neutral-700 bg-neutral-900 p-4"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold text-neutral-200">{title}</h3>
<button onClick={onClose} className="text-neutral-500 hover:text-neutral-300">
✕
</button>
</div>
{!options ? (
<div className="flex justify-center py-8">
<Spinner />
</div>
) : options.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-500">No eligible options for the current scope.</p>
) : (
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{options.map((opt) => (
<button
key={opt.id}
onClick={() => onPick(opt.id)}
className="flex flex-col items-center gap-1 rounded-md border border-neutral-800 bg-neutral-800/60 p-2 text-center hover:border-emerald-600"
>
<img
src={opt.imageUrl}
alt={opt.name}
referrerPolicy="no-referrer"
className="h-10 w-16 rounded object-cover"
/>
<span className="text-[11px] leading-tight text-neutral-300">{opt.name}</span>
</button>
))}
</div>
)}
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import type { ReactNode } from "react";
export function Section({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="rounded-lg border border-neutral-800 bg-neutral-900/60 p-4">
<h3 className="mb-3 text-sm font-semibold uppercase tracking-wide text-neutral-400">{title}</h3>
{children}
</div>
);
}
export function Toggle({
checked,
onChange,
label,
hint,
}: {
checked: boolean;
onChange: (checked: boolean) => void;
label: string;
hint?: string;
}) {
return (
<label className="flex cursor-pointer items-start justify-between gap-3 py-1.5">
<span>
<span className="block text-sm text-neutral-200">{label}</span>
{hint && <span className="block text-xs text-neutral-500">{hint}</span>}
</span>
<span
onClick={() => onChange(!checked)}
className={`relative mt-0.5 h-5 w-9 shrink-0 rounded-full transition-colors ${
checked ? "bg-emerald-600" : "bg-neutral-700"
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-transform ${
checked ? "translate-x-4" : "translate-x-0.5"
}`}
/>
</span>
</label>
);
}
export function PillButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={`rounded-full border px-3 py-1 text-xs font-medium transition-colors ${
active
? "border-emerald-500 bg-emerald-500/15 text-emerald-300"
: "border-neutral-700 bg-neutral-800/60 text-neutral-400 hover:border-neutral-600"
}`}
>
{children}
</button>
);
}
export function Button({
onClick,
children,
variant = "primary",
disabled,
small,
}: {
onClick: () => void;
children: ReactNode;
variant?: "primary" | "ghost" | "danger";
disabled?: boolean;
small?: boolean;
}) {
const base = small ? "px-2.5 py-1 text-xs" : "px-4 py-2 text-sm";
const styles = {
primary: "bg-emerald-600 hover:bg-emerald-500 text-white disabled:bg-neutral-700",
ghost: "bg-neutral-800 hover:bg-neutral-700 text-neutral-200 border border-neutral-700",
danger: "bg-red-700 hover:bg-red-600 text-white",
} as const;
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-md font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50 ${base} ${styles[variant]}`}
>
{children}
</button>
);
}
export function Spinner({ small }: { small?: boolean }) {
return (
<span
className={`inline-block animate-spin rounded-full border-2 border-neutral-600 border-t-emerald-400 ${
small ? "h-3 w-3" : "h-5 w-5"
}`}
/>
);
}
+28
View File
@@ -0,0 +1,28 @@
@import "tailwindcss";
:root {
color-scheme: dark;
}
body {
margin: 0;
background: #0f1115;
color: #e5e7eb;
font-family: system-ui, "Segoe UI", Roboto, sans-serif;
}
#root {
min-height: 100vh;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: #14161c;
}
::-webkit-scrollbar-thumb {
background: #2e313d;
border-radius: 6px;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+94
View File
@@ -0,0 +1,94 @@
export type PrimaryAttribute = "strength" | "agility" | "intelligence" | "universal";
export type AttackType = "melee" | "ranged";
export type AghanimTiming = "anytime" | "after_core_slots";
export interface Hero {
id: number;
name: string;
primaryAttribute: PrimaryAttribute;
attackType: AttackType;
imageUrl: string;
}
export interface Tag {
id: number;
name: string;
}
export interface Item {
id: number;
name: string;
source: "shop" | "neutral";
minTier?: number;
maxTier?: number;
// Grade tracks shop items that upgrade in place rather than being bought
// fresh each time (Dagon 1-5, Boots of Travel 1-2). Null for neutral items
// and 1-1 for a normal single-grade shop item.
minGrade?: number;
maxGrade?: number;
imageUrl: string;
tags: string[];
isAghanim?: "scepter" | "shard";
}
export interface Enchantment {
id: number;
name: string;
minTier: number;
maxTier: number;
attributes: PrimaryAttribute[];
imageUrl: string;
}
/** Everything the user can configure before a build is generated. */
export interface BuildConfig {
attackTypes: AttackType[]; // empty = no restriction
attributes: PrimaryAttribute[]; // empty = no restriction
itemTags: string[]; // empty = no restriction
disableNeutralArtifactRandomness: boolean; // 1.4.1 - keep enchantment rolls
disableEnchantmentRandomness: boolean; // 1.4.2 - keep artifact rolls
disableAllNeutralRolls: boolean; // 1.4.3 - overrides the two above
disabledItemSlots: number[]; // 1.4.4 - slots (1-6) left for manual pick
disableAghanimScepterRandomness: boolean; // 1.4.5
disableAghanimShardRandomness: boolean; // 1.4.5
neutralTierRerollEnabled: boolean; // 1.4.6
aghanimScepterTiming: AghanimTiming; // 2.2.5
aghanimShardTiming: AghanimTiming; // 2.2.6
}
export interface ItemSlotResult {
slot: number; // 1-6
item: Item | null;
isManual: boolean; // true when randomness was disabled for this slot (1.4.4)
}
export interface NeutralStageResult {
tier: number;
attempts: Item[]; // up to 2 rolled candidates (2.2.3)
resolved: Item | null;
requiresManualPick: boolean;
tierGateWon?: boolean; // only meaningful when neutralTierRerollEnabled === false (1.4.6)
}
export interface EnchantmentStageResult {
tier: number;
resolved: Enchantment | null;
requiresManualPick: boolean;
}
export interface AghanimResult {
included: boolean;
item: Item | null;
timing: AghanimTiming;
}
export interface Build {
id: string;
config: BuildConfig;
hero: Hero;
itemSlots: ItemSlotResult[];
neutralItems: NeutralStageResult[];
enchantments: EnchantmentStageResult[];
aghanimScepter: AghanimResult;
aghanimShard: AghanimResult;
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
// Mirrors what nginx does in production (see frontend/nginx.conf) so the
// app talks to a real backend the same way in dev and prod.
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
})