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