Any Language
The catalogs and the formulas are TypeScript, and TypeScript does not run in Python, Rust, Go, C# or Java. TheTowerSDK ships a WebAssembly build so it does not have to: one file, loaded into a runtime you already have, running the same code this project ships to JavaScript.
No port of the numbers, and no service to host. Every catalog, all fifteen builders, 914 formulas, the number formatting and the save decoder run inside your own process.
Get The Module
thetowersdk.wasm is attached to each GitHub release. It is not in the npm
package: it is about 6.6 MB, and a JavaScript project has no use for it — you already have the
library.
curl -LO https://github.com/TmRxJD/TheTowerSDK/releases/latest/download/thetowersdk.wasmThe Protocol
One JSON object in on standard input, one JSON object out on standard output. That is the entire interface, chosen because every language already writes JSON and WASI's stdio is the one thing every runtime exposes identically.
{"op": "calc.run", "id": "thorns.damage", "input": {"baseThorns": 120, "wallThorns": 12}}
{"ok": true, "id": "thorns.damage", "input": {…}, "result": {…}}Failures come back as {"ok": false, "error": "…"}, never as a trap — a trap
reaches a host language as an abort with no message, which in Python or Go is close to unreadable.
Ask {"op": "ops"} and the module lists what it carries, so discovery is part of the
protocol rather than documentation that can fall out of date.
Python
pip install wasmtime, then instantiate the module and write to its stdin. Compile
once and instantiate per call: the guest runs a main and exits, and compilation is the only slow
part.
import json, tempfile
from pathlib import Path
from wasmtime import Engine, Linker, Module, Store, WasiConfig
engine = Engine()
module = Module.from_file(engine, "thetowersdk.wasm")
linker = Linker(engine)
linker.define_wasi()
def call(**request):
with tempfile.TemporaryDirectory() as directory:
work = Path(directory)
(work / "in.json").write_text(json.dumps(request), encoding="utf-8")
(work / "out.json").touch()
config = WasiConfig()
config.stdin_file = str(work / "in.json")
config.stdout_file = str(work / "out.json")
store = Store(engine)
store.set_wasi(config)
linker.instantiate(store, module).exports(store)["_start"](store)
return json.loads((work / "out.json").read_text(encoding="utf-8"))
print(call(op="version"))
# {'ok': True, 'formulas': 914, 'calculators': 15, 'chartDatasets': 48}
print(call(op="calc.run", id="thorns.damage",
input={"baseThorns": 120, "wallThorns": 12, "tier": 14})["result"]["atWallThorns"])A fuller wrapper, with the convenience methods, is in the repository under wasm/examples/python/.
Other Languages
The three steps are the same everywhere: instantiate with WASI, point stdin at your request, read stdout.
| Language | Runtime |
|---|---|
| Python | wasmtime (pip) |
| Rust | wasmtime or wasmer |
| Go | wazero — pure Go, no cgo |
| C# / .NET | Wasmtime.Dotnet |
| Java / Kotlin | chicory or wasmtime-java |
| PHP | wasm extension |
| Command line | wasmtime thetowersdk.wasm < request.json |
What It Can Do
{"op": "ops"} # everything below, from the module itself
{"op": "version"} # counts, read from the package
{"op": "data.list"} # every catalog
{"op": "data.get", "name": "LAB_CATALOG", "offset": 0, "limit": 100}
{"op": "data.find", "name": "LAB_CATALOG", "where": {"category": "Attack"}}
{"op": "calc.list"} # the builders
{"op": "calc.describe", "id": "module.cost"}
{"op": "calc.run", "id": "module.cost", "input": {"currentLevel": 1, "targetLevel": 20}}
{"op": "mechanics.list", "match": "thorn"}
{"op": "mechanics.call", "name": "thornDamageOnHit", "args": [{…}]}
{"op": "format", "value": 4770477147914} # -> "4.77T"
{"op": "format", "value": "4.77T"} # -> 4770000000000
{"op": "charts.list"}
{"op": "charts.rows", "id": "…"}
{"op": "save.decode", "base64": "…"} # gunzipped playerInfo.dat bytes
{"op": "contributions"}Lists are paged, and total is always the real count — so reading 100 rows never looks
like reading all of them. calc.run returns the normalised input beside the result, because
a value quietly replaced by a default is the difference between an answer to your question and an answer
to a different one.
Saves
JSON has no byte type and the module has no zlib, so save.decode takes base64 of the already gunzipped file. Your language has gzip; shipping a second implementation inside
the module would buy nothing.
import base64, gzip
with open("playerInfo.dat", "rb") as handle:
raw = handle.read()
inflated = gzip.decompress(raw) if raw[:2] == b"\x1f\x8b" else raw
print(call(op="save.decode", base64=base64.b64encode(inflated).decode()))
# {'ok': True, 'rootKeys': 611, 'runs': 30, 'sample': [...]}Does It Agree With The Library?
It is tested to. A build that quietly disagrees with the TypeScript would be the exact failure this exists to prevent — the reason to ship a module at all is that re-implementing the numbers in another language guarantees two versions that drift.
The suite compares both builds on the same inputs, and it has caught three real differences. The
JavaScript engine inside the module has no ICU, so formatGroupedNumber(4770477147914) returned the digits ungrouped where Node grouped
them — both look like numbers. Then very large values printed in exponential notation. And atob is a browser API, so the save decoder was the one operation that could not run at
all, while every other answer was correct.