LLM-orchestrated build generator for Ragnarok Online. Given a class, server, playstyle, and a free-form description of what you want, it returns one or more build trajectories: ordered chains of stat / skill / gear checkpoints from early game through max-level endgame, with deterministic combat numbers attached at the load-bearing checkpoints.
The LLM proposes builds and reasons over results; a deterministic calc backend does the math. The split is deliberate: RO has too many special cases (ASPD breakpoints, card stacking, size / element / race mods, refinement ATK, hard-def vs soft-def) for an LLM to compute reliably; it will produce plausible-sounding numbers that don't match in-game. The orchestrator feeds the calc output to the LLM as a structured breakdown (offense, defense, sustain, utility, requirements_met, scenario_fit, uncertainty flags) rather than collapsing to a single score, so the model reasons over the same shape a human would.
The calc backend, the LLM provider, and the server profile are all pluggable. The repo ships a rocalc adapter, Anthropic + OpenAI-compatible providers, and a UARO server profile as defaults; each is replaceable without touching the surrounding code (see Contributing & extending).
Pre-renewal is the only mode shipped today (the calc adapter hasn't been validated for renewal), but the data layer and API surface are mode-aware, so renewal is a backend swap, not an architecture change. The project is most useful on private servers, where rates / custom items / class rules vary widely and automated build exploration earns its keep, but nothing in the architecture is private-server-specific; iRO and other officials work with a server profile authored for them.
┌──────────────────────────────────────────────────────────────┐
│ Go API (cmd/api) │
│ ┌──────────────┐ ┌──────────────────────────────────────┐ │
│ │ HTTP handler │ → │ Orchestrator │ │
│ └──────────────┘ │ ↪ LLM provider (Claude / OpenAI) │ │
│ │ ↪ Tool registry │ │
│ │ ↪ Build library (PostgreSQL) │ │
│ │ ↪ Embedding provider (optional) │ │
│ │ ↪ Scoring client → ─────┐ │ │
│ └───────────────────────────│──────────┘ │
└─────────────────────────────────────────────────│────────────┘
│ HTTP /score
▼
┌─────────────────────────────────────────────┐
│ Calc Sidecar (calc-sidecar/server.ts) │
│ ShimPool of N workers, each holding its │
│ own JSDOM window + calc-backend globals. │
└─────────────────────────────────────────────┘
Two deployable units. Both can be run natively, via docker compose, or via Helm/Tilt on Kubernetes.
Embedding provider and RAG. The build library optionally uses an OpenAI-compatible embedding endpoint (a local LM
Studio, Ollama, or llama.cpp server, or any hosted /v1/embeddings API) to enable semantic retrieval over saved
builds. When configured, the orchestrator embeds the incoming
request, searches the pgvector-backed library for semantically similar past builds, and proactively injects the closest
match as a warm-start for the LLM (Tier A seeding). The LLM can also pull ranked candidates on demand through the
get_similar_past_builds and get_saved_build tools (Tier B). When no embedder is configured, retrieval falls back to
recency ordering and pgvector is not required.
Retrieval only surfaces builds the user has explicitly accepted. A generation that passes quality gates is saved and
fetchable immediately, but it does not seed future generations until you POST /builds/{id}/accept — passing gates is
not the same as being the build you wanted. Un-accepted builds are visible only through the user-facing GET /builds
(filterable with ?accepted=) and GET /builds/{id}, never through the RAG tools.
Directory map:
cmd/ one Go binary per directory
api/ HTTP server
build-catalog/ emulator db → embedded JSON (one-shot)
build-rocalc-mapping/ rocalc-id ↔ iRO-id table (one-shot)
enrich-mapping-rms/ ratemyserver.net lookup helper
verify-mapping/ spot-check tool
backfill-embeddings/ re-embed every saved build after a model change (one-shot)
embedding-recall/ HNSW recall quality probe: approx-vs-exact KNN
internal/
api/ HTTP handlers, OpenAPI types
buildlibrary/ PostgreSQL persistence
catalog/ //go:embed item/mob/skill data
data/ emulator-source parsers
sources/hercules/
sources/rathena/
domain/ pure types: ServerProfile, Trajectory, Snapshot...
llm/ provider interface + registry
anthropic/
openai/
tools/ LLM-facing tools (lookup_item, score_build, ...)
orchestrator/ request → LLM loop → response
scoring/ sidecar client, score result types, gates
calc-sidecar/
server.ts POST /score
src/
types.ts public contract (Go side mirrors this)
shim.ts re-exports active backend via the registry
pool.ts worker-thread pool
backends/
registry.ts backend table + CALC_BACKEND dispatch
rocalc/ rocalc adapter (form-template, mapping.json)
stub/ deterministic fiction (CI / non-calc dev)
vendor/<engine>/ ← you drop the backend's source files here (gitignored;
parent dir tracked via .gitkeep)
configs/servers/ one YAML per server profile (embedded)
deploy/ Helm chart + Tilt + sealed-secrets
scripts/ e2e.sh, generate.sh, docker-e2e.sh, ...
api/openapi.yaml the public HTTP contract
| Variable | Meaning |
|---|---|
EMBEDDING_BASE_URL |
OpenAI-compatible /v1 root; presence enables the feature. Local default: http://localhost:1234/v1 (LM Studio); Ollama is http://localhost:11434/v1. |
EMBEDDING_MODEL |
Model id, e.g. text-embedding-nomic-embed-text-v1.5@q8_0 (LM Studio), nomic-embed-text (Ollama), text-embedding-3-large (OpenAI). |
EMBEDDING_API_KEY |
Optional bearer token (cloud embedders); ignored by local servers. |
EMBEDDING_DIM |
Required when EMBEDDING_BASE_URL is set (default 768). Sets the vector column width; must match the model's output dimension. |
EMBEDDING_SEED_MAX_DISTANCE |
Tier A proactive-seed cosine-distance ceiling (default 0.15). Inject the closest saved build only when it is this close to the request. |
EMBEDDING_SIMILAR_MAX_DISTANCE |
Tier B list floor (default 0.5). Drop get_similar_past_builds results beyond this cosine distance. |
EMBEDDING_HNSW_M / EMBEDDING_HNSW_EF_CONSTRUCTION |
HNSW index build parameters (default pgvector 16 / 64). Baked into the index; changing them needs a reindex. |
EMBEDDING_HNSW_EF_SEARCH |
HNSW query-time candidate-list size (default pgvector 40). Higher = better recall, slower. Applied per query. |
Unset EMBEDDING_BASE_URL means recency-only retrieval, no pgvector required.
Threshold tuning (after a populated corpus exists). Enable the per-firing log; each proactive seed records the
seed id and its cosine distance. Collect a sample of real requests, read the generated build alongside the log to
judge usefulness, and look at where genuinely useful matches cluster versus the noise tail. Then set
EMBEDDING_SEED_MAX_DISTANCE just inside the good band (tight enough to avoid seeding bad matches). The Tier B list
floor EMBEDDING_SIMILAR_MAX_DISTANCE (which bounds what get_similar_past_builds returns) is set looser than the
seed ceiling using the same observations. Re-deploy and re-observe. Distances are model-specific and do not transfer
across a model or dimension change; redo this step after any EMBEDDING_MODEL/EMBEDDING_DIM swap.
Model and HNSW index tuning. Pick a model and dimension (EMBEDDING_MODEL / EMBEDDING_DIM), then run:
# 1. (Re-)embed every saved build with the chosen model.
# If EMBEDDING_MODEL or EMBEDDING_DIM changed on a non-empty corpus, clear
# existing embeddings first so backfill does not exit early with an error:
# UPDATE saved_trajectories SET embedding = NULL;
# Then run backfill:
go run ./cmd/backfill-embeddings
# 2. Check HNSW index recall against exact KNN.
go run ./cmd/embedding-recall --k 10 --sample 50
# 3a. Recall is low (< 0.95): measure a higher query-time ef_search first, then set it
# on the serving path.
go run ./cmd/embedding-recall --k 10 --sample 50 --ef-search 80
# If that helps, set EMBEDDING_HNSW_EF_SEARCH=80 on the API. It is applied per query
# (SET LOCAL inside the retrieval transaction); no reindex needed.
# 3b. Still low: raise the build parameters and rebuild the index. Set EMBEDDING_HNSW_M
# and/or EMBEDDING_HNSW_EF_CONSTRUCTION on the API, then force a fresh index (the
# bootstrap's CREATE INDEX IF NOT EXISTS will not rebuild an existing one):
# DROP INDEX saved_trajectories_embedding_idx;
# and restart the API. The bootstrap recreates the index WITH (m, ef_construction).
# 4. To switch models entirely: change EMBEDDING_MODEL/EMBEDDING_DIM, clear embeddings
# (step 1 above), re-run backfill-embeddings, re-run embedding-recall, then
# re-tune the thresholds from the previous runbook.cmd/embedding-recall reports recall@k = |approx_topk intersect exact_topk| / k. A value >= 0.98 means
the defaults are fine; below 0.85 means the index parameters need raising. Run it against a populated corpus
only; an empty corpus always returns perfect recall.
What's wired up today:
| Aspect | What's shipped |
|---|---|
| Mode | Pre-renewal only today; renewal is rejected at the API boundary |
| Calc backend | rocalc adapter (Node + jsdom sidecar); contract is engine-agnostic |
| Classes | 45 of 46 pre-renewal classes (High Novice / id 34 excluded); prompt-tuned on Taekwon Kid |
| Servers | UARO profile shipped; alternates by dropping a YAML under configs/servers/ |
| Playstyle | PvM tuned; pvp / balanced_* accepted but less validated |
| LLM provider | Anthropic (default) and OpenAI-compatible (LM Studio, llama.cpp, Ollama, vLLM, ...) |
| Persistence | PostgreSQL at DATABASE_URL |
- Go 1.26.2+ (see
.go-version) - Node 24+ (uses Node 24's TypeScript strip mode; no compile step)
- jq (used by
scripts/e2e.shand the curl examples below) - go-task (
taskrunner; install viago install github.com/go-task/task/v3/cmd/task@latestor your package manager) - golangci-lint for
task lint
Optional:
- Docker / docker compose for
task docker:up - Helm 3 + Tilt + kubectl for k8s dev via
task tilt:up - A pre-renewal emulator checkout (Hercules or rAthena) only needed if you want to regenerate the embedded catalog
yourself. A pre-built
internal/catalog/data/catalog.jsonis already committed.
git clone <this-repo> ro-builder
cd ro-builder
task bootstrap # copies .env.example → .env, go mod download, npm installThen edit .env and set LLM_API_KEY (Anthropic by default; switch provider via LLM_PROVIDER / LLM_BASE_URL). The
API still starts and serves /score without a key; /generate returns 503 (LLM provider not configured) until a
key is set.
The calc backend's source files are not bundled. The default adapter is rocalc; drop its JS files into
calc-sidecar/vendor/rocalc/. The expected snapshot date is 2026-04-06 (pinned in the adapter's file list).
mkdir -p calc-sidecar/vendor/rocalc
cd calc-sidecar/vendor/rocalc
DATE=2026-04-06
for f in skill head item etc monster card foot; do
curl -fsSLO "https://rocalc.com/${f}_${DATE}.js"
done
# Page chrome; not loaded by the sidecar, but useful if you want to open
# the vendored copy in a browser for cross-checking.
curl -fsSLO https://rocalc.com/index.html
curl -fsSLO https://rocalc.com/style.css
cd -Notes specific to the rocalc adapter:
- For a different snapshot date, update the
ROCALC_FILESlist incalc-sidecar/src/backends/rocalc/index.tsto match the files you downloaded. - The rocalc-id ↔ iRO-id mapping at
calc-sidecar/src/backends/rocalc/mapping.jsonmay drift across snapshots; regenerate viatask mapping(requires an emulator checkout; see regenerating the catalog below). - Some skill / damage formulas are inexact (left-hand crit on Assassins, Soul Breaker / Shield Boomerang / Shield
Chain / Grimtooth, enemy Grand Cross / Evil Land). The adapter surfaces these through
uncertainty.flagson the score result; the orchestrator passes them to the LLM. - Never modify files under
vendor/. All glue lives in the shim.
Alternatively, set CALC_BACKEND=stub to run the sidecar against the built-in stub backend. It skips vendor entirely
and returns deterministic fiction numbers. Useful for iterating on shim plumbing, the HTTP layer, or the API
without rocalc files on disk. Not useful for any real build calculation.
To use a different calc backend, see Adding a calc backend.
Each e2e task boots the sidecar + API in an isolated environment, sends a /score request and (if LLM_API_KEY is
set) a full /generate cycle, validates the responses, and tears down. Exit 0 means the pipeline is wired. Pick the
flavor that matches how you intend to deploy; or run all three before merging anything that touches packaging.
task e2e # native; boots Node + Go on isolated ports against a temp DB. Fast.
task docker:e2e # docker compose; verifies the shipped images, env wiring, healthchecks. Slower.
task tilt:e2e # kubernetes; helm install in a temp namespace, helm uninstall on exit. Slowest.Two services. They run independently; pick one of the three orchestration paths:
# Terminal A: calc sidecar (port 7401)
task run:sidecar
# Terminal B: Go API (port 8080)
task run:apitask docker:up # builds both images, runs in the foreground
task docker:down # stops; named volume `ro-builder-pgdata` survives
task docker:nuke # stops AND drops the pgdata volumeThe sidecar's calc backend defaults to rocalc. Set CALC_BACKEND=stub in your shell env (or .env) before
task docker:up to use the stub backend instead.
task tilt:bootstrap # one-shot: install sealed-secrets controller, create namespace
task tilt:seal # encrypt LLM_API_KEY from .env into a SealedSecret
task tilt:up # start the full stack under Tilt (Ctrl-C to stop)
task tilt:down # stop Tilt-managed resources; the Postgres PVC survives
task tilt:nuke # tilt down + delete the ro-builder namespace (drops the PVC)
task tilt:bootstrap:down # remove the sealed-secrets controller + sealing keys from kube-systemThe chart's sidecar.calcBackend value defaults to rocalc. Override via values.local.yaml or --set for
stub-backed local runs.
tilt:bootstrap:down (run tilt:nuke first) deletes the controller's generated sealing keys, so the
gitignored local.sealed-secret.yaml can no longer be decrypted; the next task tilt:bootstrap reinstalls
the controller and re-seals from .env (or run task tilt:seal manually).
Full OpenAPI 3.0 contract: api/openapi.yaml. Bruno collection: bruno/.
| Method | Path | Purpose |
|---|---|---|
| POST | /score |
Direct calc; fully-specified Build → derived stats + optional combat sim |
| POST | /generate |
LLM-driven; enqueue a generation job; returns 202 + {id} |
| GET | /generations/{id} |
Poll status of an enqueued generation |
| GET | /builds |
Paginated list of saved trajectories (filter with ?accepted=true|false) |
| GET | /builds/{id} |
One full saved trajectory with item / skill names resolved |
| POST | /builds/{id}/accept |
Accept a build so RAG retrieval can seed future generations from it |
curl -s http://localhost:8080/score \
-H 'content-type: application/json' \
-d '{
"build": {
"class": "swordsman",
"level": {"base": 50, "job": 30},
"stats": {"str": 60, "agi": 30, "vit": 40, "int": 1, "dex": 30, "luk": 1}
}
}' | jq .derived# 1. Enqueue
ID=$(curl -s http://localhost:8080/generate \
-H 'content-type: application/json' \
-d '{
"class": "taekwon_kid",
"server": "uaro",
"playstyle": "pvm",
"description": "viable PvM ranker to 99/50, friendly to a fresh player"
}' | jq -r .id)
# 2. Poll (cadence: ~30s recommended)
while :; do
STATUS=$(curl -s "http://localhost:8080/generations/$ID" | jq -r .status)
echo "$STATUS"
[[ "$STATUS" == "completed" || "$STATUS" == "failed" ]] && break
sleep 30
done
# 3. Fetch enriched build and review it
curl -s "http://localhost:8080/builds/$ID" | jq .
# 4. Accept it so future generations can seed from it (RAG only sees accepted builds)
curl -s -X POST "http://localhost:8080/builds/$ID/accept" | jq .Or use the convenience wrapper:
scripts/generate.sh # default request body
scripts/generate.sh path/to/body.json # custom bodyFive extension points, all additive; no refactors required.
-
Create
internal/llm/<name>/client.go:package myprovider import "github.com/levonn-dev/ro-builder/internal/llm" func init() { llm.Register("myprovider", func(c llm.Config) (llm.Provider, error) { return &client{cfg: c}, nil }) } type client struct{ cfg llm.Config } func (c *client) Complete(ctx context.Context, system string, messages []llm.Message, tools []llm.Tool) (llm.Response, error) { // translate to/from your wire format; preserve tool_use / tool_result blocks }
-
Side-effect-import it from
cmd/api/main.go:import _ "github.com/levonn-dev/ro-builder/internal/llm/myprovider"
-
Set
LLM_PROVIDER=myproviderin.env.
The contract is internal/llm/provider.go; it mirrors a messages-with-tools shape because
that's what the orchestrator iteration loop expects. The OpenAI provider doubles as a working example of adapting a
different wire format and also covers LM Studio, llama.cpp, Ollama, Azure OpenAI, OpenRouter, and vLLM via
LLM_BASE_URL.
The shim contract is in calc-sidecar/src/types.ts; ShimSession, ScoreRequest,
ScoreResponse, slot keys, derived-stats shape. The Go side mirrors it.
-
Create
calc-sidecar/src/backends/<engine>/index.tsexportingcreateShim(): ShimSessionandCALC_VERSION: string. Both must load synchronously: the registry pulls the selected backend in withrequire(), so the backend's module graph must not use top-levelawait, andcreateShimstays synchronous. (A worker whose module graph is async can abort the process if it's terminated mid-load; see the note inregistry.ts.) -
Put engine-specific assets (synthetic HTML form, ID-mapping JSON, etc.) alongside it.
-
Drop the engine's source files under
calc-sidecar/vendor/<engine>/. -
Register your backend in
calc-sidecar/src/backends/registry.tsby adding one line in theBACKENDStable (a module specifier; the registry loads only the selected backend, synchronously viarequire()):const BACKENDS: Record<string, string> = { rocalc: "./rocalc/index.ts", stub: "./stub/index.ts", <engine>: "./<engine>/index.ts", // ← your backend };
-
Select it at runtime via
CALC_BACKEND=<engine>(env var; defaultrocalc). -
Write a boundary translation if your engine doesn't speak iRO IDs. The rocalc adapter keeps a
mapping.jsonand translates per call; seecalc-sidecar/src/backends/rocalc/index.ts.
- Drop a new file at
configs/servers/<key>.yaml. The schema isdomain.ServerProfile(seeinternal/domain/server.go); rates, caps, class-change rules, item-change rules, leveling content, custom mobs, custom items, quality-gate defaults. - Cite the source URL in a comment above each section you change; wikis drift.
- Rebuild; the directory is
//go:embed'd into the binary; no runtime reload. - Pass the key as
serverin the/generaterequest.
configs/servers/uaro.yaml is the reference. ServerProfile.CustomMobs carries
server-specific overlays (e.g. UARO's Old Glast Heim).
- Create
internal/llm/tools/<name>.goimplementingtools.Tool(Definition()for the JSON schema,Execute()for dispatch). Seeinternal/llm/tools/lookup_item.gofor the simplest shape. - Register it where the orchestrator wires the registry; search
tools.NewRegistryincmd/api/main.go. - Write tests alongside; every shipped tool has a
*_test.go.
Tools should be stateless across calls; pass any dependency (scoring client, catalog) in at construction.
internal/data/sources/<name>/ for a new emulator parser. Hercules (libconfig) and rAthena (YAML) are present; either
produces the same iRO-IDed catalog. Mode-aware via data.Mode (PreRenewal | Renewal).
The catalog (items, mobs, skills, cards) is generated from a Hercules or rAthena checkout; both ship pre-renewal item DBs with iRO IDs.
# Default: Hercules at sibling path ../Hercules
git clone https://github.com/HerculesWS/Hercules.git ../Hercules
task catalog
# Alternate: rAthena
git clone https://github.com/rathena/rathena.git ../rathena
task catalog -- -source rathenaThe generated internal/catalog/data/catalog.json is committed (so first-time users don't need the emulator checkout).
Re-run when upstream data shifts.
The rocalc-id ↔ iRO-id mapping is generated separately:
task mapping # rebuild calc-sidecar/src/backends/rocalc/mapping.json
task mapping:verify # cross-check against Hercules + rAthena
task mapping:enrich # query ratemyserver.net for unmatched entriestask fmtbefore committing; Go (gofmt) and TypeScript (prettier).task checkruns the pre-commit gate: fmt-check, lint, build, test, helm lint.task check:fullruns everything plus all three e2e flavors (native, compose, k8s).- Comments: only when WHY is non-obvious. API handlers in
internal/api/are an explicit exception; those carry reference-doc comments. Everywhere else, identifier names do the explaining.
Every PR and push to main runs the CI workflow at .github/workflows/ci.yml. The
workflow mirrors task check (fmt-check, lint, build, unit tests, helm lint) plus native and docker-compose e2e
runs, all against the stub calc backend (no vendor/rocalc/ needed). LLM_API_KEY is not provisioned in CI; the e2e
suite verifies /generate returns 503 (LLM provider not configured) in that mode rather than running the
happy-path LLM loop.
Not yet done, roughly in priority order:
- Add self-buffs to calc
- Add self-buffs to scoring
- Include skill usage/damage in calc and scoring
- Vector search (pgvector) over saved trajectories.
get_similar_past_buildsranks by cosine distance to the embedded request when an embedder is configured; falls back to recency otherwise. - RAG seeding: the orchestrator embeds the incoming request, finds the closest saved build, and injects it as a
warm-start when within
EMBEDDING_SEED_MAX_DISTANCE. - Postgres migration for the build library. The API is now stateless; horizontal scaling is supported via the lease-based generation queue (SKIP LOCKED claim, requeue with cap), configurable replicas, and HPA/PDB in the Helm chart.
- General prompt tuning - less tokens, more progress.
- More golden fixtures beyond Taekwon Kid. The shim handles all 45 supported classes already
- Golden fixtures for Full Support builds
- Renewal mode end-to-end. The API rejects
mode: "renewal"at the boundary today; the data layer is already mode-aware. The calc adapter hasn't been validated for renewal. - Rotation simulator with SP economy. Today's scoring uses single-skill DPS + sustained auto-attack as a proxy.
- HP pot economy in scoring. SP sustain matters more in practice today.
- Multi-target AoE scoring. One target per scored snapshot today.
- Real time-to-level scoring at leveling checkpoints. Currently the leveling target is attached as a proxy for "can you farm here"
- Genetic / beam-search optimizer over gear combinations. Today the LLM proposes; deterministic scoring ranks.
- Custom dungeons (containers) in
ServerProfile. Custom mobs are wired; containers are not. - Model custom skill changes from server profile into calc
- Model custom equipment changes from server profile into calc
Bug reports, missing card effects, server-profile additions, and additional calc backends are all welcome via PR.