diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4eeab7e..1339d5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,42 @@ jobs: cache: true - name: Build run: go build -trimpath ./cmd/rin + + sdk: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.9" + - name: Test Python SDK + run: python -m unittest discover -s sdk/python/tests -p 'test_*.py' + - uses: actions/setup-node@v4 + with: + node-version: "18" + - name: Test JavaScript SDK + working-directory: sdk/javascript + run: node --test + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + - name: Compile and test Java SDK + run: | + mkdir -p .cache/java-sdk + find sdk/java/src/main/java sdk/java/test -name '*.java' > .cache/java-sdk/sources.txt + javac --add-modules jdk.httpserver -d .cache/java-sdk @.cache/java-sdk/sources.txt + java --add-modules jdk.httpserver -cp .cache/java-sdk io.github.sunrioa.rin.RinClientTest + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "6.0.x" + - name: Build and test C# SDK + run: dotnet run --project sdk/csharp/Rin.Client.Tests/Rin.Client.Tests.csproj --nologo + - name: Update package index for Lua + run: sudo apt-get update + - name: Install Lua + run: sudo apt-get install -y lua5.1 lua5.4 + - name: Test Lua SDK on Lua 5.1 + run: lua5.1 sdk/lua/test_client.lua + - name: Test Lua SDK on Lua 5.4 + run: lua5.4 sdk/lua/test_client.lua diff --git a/.gitignore b/.gitignore index 2d9ddcf..4feb43c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,6 @@ __pycache__/ /rin-data/ /.cache/ *.log +sdk/csharp/**/bin/ +sdk/csharp/**/obj/ +sdk/python/**/*.egg-info/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1dd9569 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 sunrioa + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile index 2385904..f4513e4 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,13 @@ GO ?= go PYTHON ?= python3 +NODE ?= node +DOTNET ?= dotnet +JAVAC ?= javac +JAVA ?= java +LUA ?= lua VERSION ?= dev -.PHONY: fmt test test-go test-adapters race vet build +.PHONY: fmt test test-go test-adapters test-sdks test-sdk-python test-sdk-javascript test-sdk-csharp test-sdk-java test-sdk-lua race vet build fmt: $(GO) fmt ./... @@ -15,6 +20,26 @@ test-go: test-adapters: $(PYTHON) -m unittest discover -s adapters/renpy -p 'test_*.py' +test-sdks: test-sdk-python test-sdk-javascript test-sdk-csharp test-sdk-java test-sdk-lua + +test-sdk-python: + $(PYTHON) -m unittest discover -s sdk/python/tests -p 'test_*.py' + +test-sdk-javascript: + cd sdk/javascript && $(NODE) --test + +test-sdk-csharp: + $(DOTNET) run --project sdk/csharp/Rin.Client.Tests/Rin.Client.Tests.csproj --nologo + +test-sdk-java: + mkdir -p .cache/java-sdk + find sdk/java/src/main/java sdk/java/test -name '*.java' > .cache/java-sdk/sources.txt + $(JAVAC) --add-modules jdk.httpserver -d .cache/java-sdk @.cache/java-sdk/sources.txt + $(JAVA) --add-modules jdk.httpserver -cp .cache/java-sdk io.github.sunrioa.rin.RinClientTest + +test-sdk-lua: + $(LUA) sdk/lua/test_client.lua + race: $(GO) test -race ./... diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000..b2fa2a5 --- /dev/null +++ b/README.en.md @@ -0,0 +1,191 @@ +# Rin + +[简体中文](README.md) | [English](README.en.md) + +Rin is a lightweight agent runtime for game characters. It runs as a sidecar +next to the game process and can also be embedded as a Go package in tooling. +The core uses only the Go standard library and is not tied to visual novels, +RPG engines, or any model provider. + +Current development line: `v0.5.0` (Living Worlds) + +Documentation index: [English](docs/README.md) | +[简体中文](docs/README.zh-CN.md) + +## What it solves + +Rin separates character reasoning from game-world facts: + +- The game submits what a character actually saw as an `Observation` instead + of handing the model an entire save. +- A character creates an `ActionProposal` from memories, goals, boundaries, + and the actions currently allowed by the game. +- A proposal cannot directly change plot, inventory, quests, or + relationships. It takes effect only after the game validates it and calls + `commit`. +- Every state change is written to a hash-chained JSONL event log that can be + replayed and inspected. +- Snapshots bind `game/content/version/hash`; tampered or mismatched saves are + rejected. +- Tick scheduling lets many NPCs think only when needed instead of calling a + model every frame. +- Asynchronous jobs prefetch online-model results so slow requests, + cancellation, and stale state never freeze the game thread. +- Generic structured Generation Jobs route plot, quest descriptions, and + constrained dialogue through the sidecar without storing provider keys in + the game. +- If a model is unavailable, Rin falls back to a deterministic policy and + identifies the source with `policy_source`. +- Ren'Py, Godot 4, and Unity adapters preserve the same + observe/propose/commit authority boundary. +- Python, JavaScript, C#, Java, and Lua SDKs plus Fabric, BepInEx, and Luanti + example mods provide quick integration paths. +- Optional layered memory, conflicting beliefs, candidate subgoals, regional + dormancy, and deterministic multi-actor arbitration are explicitly enabled + through session features. +- A redacted timeline, revision replay, and `rin inspect` make long-running + character behavior reproducible and auditable. + +The same boundary works for Ren'Py characters, RPG NPCs, party companions, +simulation residents, and other AI-driven game entities. + +## Quick start + +Running the sidecar requires Go 1.24 or later. Ren'Py adapter tests also +require Python 3.9+. + +```bash +make test +go run ./cmd/rin serve -data ./rin-data +``` + +The default listener is `127.0.0.1:7374`. Check the service with: + +```bash +curl http://127.0.0.1:7374/health +``` + +Run the complete client example: + +```bash +go run ./examples/basic +``` + +Production integrations should use a dedicated sidecar token: + +```bash +export RIN_TOKEN="$(openssl rand -hex 32)" +go run ./cmd/rin serve +``` + +The client then sends `Authorization: Bearer $RIN_TOKEN`. Tokens, model API +keys, and provider URLs are never written to events, snapshots, or responses. +Generation results may contain only bounded, non-secret operational metadata +such as model name, finish reason, and token counts; games may apply an +additional persistence allowlist. + +## API + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/health` | Unauthenticated health check | +| `POST` | `/v1/session/create` | Create a session bound to a game-content version | +| `POST` | `/v1/session/observe` | Submit events actually observed by one or more actors | +| `POST` | `/v1/agent/propose` | Produce a character proposal from game-allowlisted actions | +| `POST` | `/v1/jobs/propose` | Submit an asynchronous proposal job | +| `GET` | `/v1/jobs/{job_id}` | Read proposal-job status and result | +| `DELETE` | `/v1/jobs/{job_id}` | Cancel a queued or running proposal job | +| `POST` | `/v1/generation/jobs` | Submit an asynchronous structured JSON generation job | +| `GET` | `/v1/generation/jobs/{job_id}` | Read a generation job and safe metadata | +| `DELETE` | `/v1/generation/jobs/{job_id}` | Cancel a generation job | +| `POST` | `/v1/action/commit` | Accept or reject a proposal and record its outcome | +| `POST` | `/v1/action/commit-batch` | Atomically commit multi-actor outcomes at one world revision | +| `POST` | `/v1/session/activity` | Update actor region and awake/dormant state | +| `POST` | `/v1/world/arbitrate` | Deterministically arbitrate conflicting parallel proposals | +| `POST` | `/v1/scheduler/due` | Query actors due to think at the current tick | +| `POST` | `/v1/session/get` | Read session state | +| `POST` | `/v1/session/snapshot` | Create and atomically save a snapshot | +| `POST` | `/v1/session/restore` | Validate and restore a snapshot | +| `POST` | `/v1/session/timeline` | Read the redacted event timeline | +| `POST` | `/v1/session/replay` | Replay to a revision and return a snapshot | + +Every write request carries a caller-generated `request_id`. Repeating a +request returns the same result without mutating state again. Reusing the same +ID for another operation returns a conflict. + +See the [protocol reference](docs/protocol-v1.md) for complete fields and +error semantics, and the [architecture guide](docs/architecture.md) for +responsibility boundaries. + +Inspect a session offline. The command verifies the log and prints only a +redacted timeline: + +```bash +go run ./cmd/rin inspect -data ./rin-data -session playthrough-1 +go run ./cmd/rin inspect -data ./rin-data -session playthrough-1 -revision 42 +``` + +## Game-engine adapters + +- Ren'Py: standard-library Python client, `renpy.invoke_in_thread` bridge, and + authored offline fallback. +- Godot 4: asynchronous `HTTPRequest` signal/timer example. +- Unity: asynchronous `UnityWebRequest` coroutine with bounded response + handling. +- General SDKs: Python 3.9+, Node/Fetch, .NET 6+, Java 17+, and Lua 5.1+. +- Example mods: Fabric server, BepInEx 6, and a loopback-sidecar-only Luanti + server mod. + +See [game adapters](docs/game-adapters.md) for installation, configuration, +and offline semantics. RPG region, visibility, quest, and multi-NPC event +conventions are in [RPG event conventions](docs/rpg-events.md). +Cross-language structure, thread boundaries, credential policy, and mod +installation are covered by [SDK and mod integration kits](docs/sdk-and-mods.md). + +## Optional model policy + +Rin makes no network calls by default. Enable an OpenAI-compatible model with: + +```bash +export RIN_POLICY=model +export RIN_MODEL_BASE_URL="https://provider.example/v1" +export RIN_MODEL="your-model-id" +export RIN_MODEL_API_KEY="..." +go run ./cmd/rin serve +``` + +Remote endpoints must use HTTPS. Models on `127.0.0.1`, `::1`, or `localhost` +may use HTTP without a key. Model calls have independent timeouts, a total +budget, bounded retries, a circuit breaker, and a bounded cache. See +[model policy](docs/model-policy.md) for details. + +## Repository layout + +```text +cmd/rin/ Sidecar command-line program +httpapi/ Strict JSON, authentication, and request-size limits +policy/ Deterministic offline policy with no network dependency +provider/ OpenAI-compatible client, retries, and circuit breaker +jobs/ Bounded asynchronous proposal worker queue +generation/ Bounded structured-generation worker queue and cache +adapters/ Ren'Py Python client and bridge +sdk/ Python, JavaScript, C#, Java, and Lua clients and route contract +compat/ Executable game-protocol compatibility vectors +protocol/ Cross-language v1 data contract +runtime/ Event state machine, proposal validation, snapshots, scheduling +store/ JSONL file store and in-memory store +examples/ Go, Godot, Unity, and Fabric/BepInEx/Luanti mod examples +``` + +## Intentionally out of scope + +`v0.5.0` does not add provider SDKs, a vector database, an ORM, WebSockets, +dynamic plugin execution, or arbitrary file access. Online models remain +optional. If either the provider or sidecar is unavailable, a game can +continue with the deterministic policy or its own offline story. + +Future work is tracked in [ROADMAP.en.md](ROADMAP.en.md). + +## License + +Rin is released under the [MIT License](LICENSE). diff --git a/README.md b/README.md index d92e0e6..84a7e26 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,13 @@ # Rin +[简体中文](README.md) | [English](README.en.md) + Rin 是一个面向游戏角色的轻量级 Agent Runtime。它作为游戏进程旁边的 Sidecar 运行,也可以直接作为 Go 包嵌入工具链。核心只使用 Go 标准库,不绑定视觉小说、RPG 引擎或任何模型供应商。 当前开发线:`v0.5.0`(Living Worlds) +文档索引:[简体中文](docs/README.zh-CN.md) | [English](docs/README.md) + ## 它解决什么 Rin 将“角色思考”和“游戏世界事实”拆开: @@ -18,6 +22,7 @@ Rin 将“角色思考”和“游戏世界事实”拆开: - 通用结构化 Generation Job 让剧情、任务描述和受限对白也经过 Sidecar,而不是让游戏保存供应商 Key。 - 模型不可用时自动回退确定性 Policy,并用 `policy_source` 标明来源。 - Ren'Py、Godot 4 和 Unity 适配器保持同一套 observe / propose / commit 权威边界。 +- Python、JavaScript、C#、Java、Lua SDK 与 Fabric、BepInEx、Luanti 示例 Mod 提供快速接入层。 - 可选分层记忆、冲突认知、候选小目标、区域休眠和确定性多角色仲裁均由 Session feature 显式启用。 - 脱敏 Timeline、指定 revision Replay 和 `rin inspect` 让长流程角色行为可以复现和审计。 @@ -80,7 +85,7 @@ go run ./cmd/rin serve 所有写请求都带调用方生成的 `request_id`,重复请求返回相同结果,不重复修改状态。同一 ID 被用于不同操作时返回冲突。 -完整字段和错误语义见 [协议文档](docs/protocol-v1.md),职责边界见 [架构文档](docs/architecture.md)。 +完整字段和错误语义见 [协议文档](docs/protocol-v1.zh-CN.md),职责边界见 [架构文档](docs/architecture.zh-CN.md)。 离线检查一个会话(会验证日志并只打印脱敏时间线): @@ -94,8 +99,11 @@ go run ./cmd/rin inspect -data ./rin-data -session playthrough-1 -revision 42 - Ren'Py:纯标准库 Python 客户端、`renpy.invoke_in_thread` 桥接与 authored 离线回退。 - Godot 4:基于 `HTTPRequest` signal/timer 的异步客户端。 - Unity:基于 `UnityWebRequest` coroutine 的异步客户端和有界响应处理。 +- 通用 SDK:Python 3.9+、Node/Fetch、.NET 6+、Java 17+ 与 Lua 5.1+。 +- 示例 Mod:Fabric 服务端、BepInEx 6 与本机 Sidecar 限定的 Luanti 服务端 Mod。 -安装、配置和离线语义见 [游戏适配文档](docs/game-adapters.md)。RPG 的区域、可见性、任务和多人 NPC 事件约定见 [RPG 事件约定](docs/rpg-events.md)。 +安装、配置和离线语义见 [游戏适配文档](docs/game-adapters.zh-CN.md)。RPG 的区域、可见性、任务和多人 NPC 事件约定见 [RPG 事件约定](docs/rpg-events.zh-CN.md)。 +跨语言目录规范、线程边界、凭据策略和 Mod 安装步骤见 [SDK 与 Mod 接入文档](docs/sdk-and-mods.zh-CN.md)。 ## 可选模型 Policy @@ -109,7 +117,7 @@ export RIN_MODEL_API_KEY="..." go run ./cmd/rin serve ``` -远程端点必须使用 HTTPS;本机 `127.0.0.1`、`::1`、`localhost` 模型可使用 HTTP 且可不配置 Key。模型调用具有独立超时、总预算、有限重试、熔断和有界缓存。详细配置见 [模型接入文档](docs/model-policy.md)。 +远程端点必须使用 HTTPS;本机 `127.0.0.1`、`::1`、`localhost` 模型可使用 HTTP 且可不配置 Key。模型调用具有独立超时、总预算、有限重试、熔断和有界缓存。详细配置见 [模型接入文档](docs/model-policy.zh-CN.md)。 ## 目录 @@ -121,11 +129,12 @@ provider/ OpenAI-compatible 客户端、重试与熔断 jobs/ 有界异步 Proposal worker queue generation/ 有界结构化 Generation worker queue 与缓存 adapters/ Ren'Py Python 客户端与桥接层 +sdk/ Python、JavaScript、C#、Java、Lua 通用客户端与路由契约 compat/ 可执行的游戏协议兼容向量 protocol/ 可跨语言实现的 v1 数据契约 runtime/ 事件状态机、提案验证、快照和调度 store/ JSONL 文件存储与内存存储 -examples/ Go、Godot 与 Unity 最小接入示例 +examples/ Go、Godot、Unity 与 Fabric/BepInEx/Luanti Mod 示例 ``` ## 当前有意不做 @@ -133,3 +142,7 @@ examples/ Go、Godot 与 Unity 最小接入示例 `v0.5.0` 不引入供应商 SDK、向量数据库、ORM、WebSocket、动态插件执行或任意文件访问。在线模型仍是可选能力;即使供应商或 Sidecar 不可用,游戏仍可继续使用确定性策略或自己的离线剧情。 后续工作记录在 [ROADMAP.md](ROADMAP.md)。 + +## 许可证 + +Rin 以 [MIT License](LICENSE) 发布。 diff --git a/ROADMAP.en.md b/ROADMAP.en.md new file mode 100644 index 0000000..9993f9c --- /dev/null +++ b/ROADMAP.en.md @@ -0,0 +1,68 @@ +# Roadmap + +[简体中文](ROADMAP.md) | [English](ROADMAP.en.md) + +## v0.1.0 - Runtime foundation + +- [x] Go standard-library HTTP sidecar +- [x] Multi-actor sessions, observations, memories, beliefs, and goals +- [x] Character boundaries and candidate-action allowlists +- [x] Propose/commit separation of world authority +- [x] Tick scheduling and urgent proposals +- [x] Idempotent request IDs, revisions, and stale proposals +- [x] Hash-chained JSONL, atomic snapshots, and restore +- [x] Deterministic offline policy +- [x] macOS, Windows, and Linux CI with zero-CGO builds + +## v0.2.0 - Optional model policy + +- [x] Standard-library OpenAI-compatible HTTP provider +- [x] Provider timeout, cancellation, retry budget, and circuit breaker +- [x] Strict structured drafts and prompt-injection data isolation +- [x] Asynchronous prefetch job API; the game thread never waits for a model +- [x] Immutable proposal cache keyed by head hash +- [x] Provider contract fixtures with no real API keys + +## v0.3.0 - Game adapters + +- [x] Ren'Py Python client and offline fallback +- [x] Godot GDScript example +- [x] Unity C# example +- [x] RPG region, visibility, and quest event conventions +- [x] Protocol compatibility vectors for the current `ai-galgame` + +## v0.4.0 - Structured generation integration + +- [x] Generic asynchronous structured Generation Job API +- [x] Request idempotency, semantic cache, cancellation, output-size limit, + and JSON-object validation +- [x] Ren'Py Generation client and end-to-end `ai-galgame` integration +- [x] Move game provider credentials into the independent sidecar +- [x] Compose observation, proposal, commit, snapshot, and story generation + +## v0.5.0 - Living worlds + +- [x] Layered memory summaries and explainable forgetting +- [x] Actor-private knowledge, rumor provenance, and conflicting facts +- [x] Autonomous subgoals and Game Master arbitration +- [x] Multi-agent batching and regional dormancy +- [x] Human-readable debug timeline and decision replay tools + +See +[`docs/living-worlds-v0.5-plan.md`](docs/living-worlds-v0.5-plan.md) +for the full protocol, compatibility strategy, phased commits, and acceptance +matrix. + +## v0.6.0 - Integration kits + +- [x] Dependency-free Python 3.9+ and JavaScript/TypeScript SDKs +- [x] .NET 6, Java 17 with injectable JSON codec, and Lua 5.1 SDKs +- [x] Unified 20-route contract, transport-security constraints, and + cross-language CI +- [x] Fabric, BepInEx 6, and Luanti NPC example mods +- [ ] Complete manual installation and interaction tests in real + Fabric/BepInEx/Luanti game versions +- [x] Release the repository, SDKs, and example mods under the MIT License + +Every phase keeps one principle: a model may propose intent and expression; +the game engine decides what actually happens. diff --git a/ROADMAP.md b/ROADMAP.md index 8413257..bd0dd31 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # Roadmap +[简体中文](ROADMAP.md) | [English](ROADMAP.en.md) + ## v0.1.0 - Runtime foundation - [x] Go 标准库 HTTP Sidecar @@ -46,6 +48,15 @@ - [x] 人工调试时间线和决定回放工具 详细协议、兼容策略、阶段提交与验收矩阵见 -[`docs/living-worlds-v0.5-plan.md`](docs/living-worlds-v0.5-plan.md)。 +[`docs/living-worlds-v0.5-plan.zh-CN.md`](docs/living-worlds-v0.5-plan.zh-CN.md)。 + +## v0.6.0 - Integration kits + +- [x] Python 3.9+ 与 JavaScript/TypeScript 零依赖 SDK +- [x] .NET 6、Java 17 可注入 JSON Codec 与 Lua 5.1 SDK +- [x] 统一 20 路由契约、传输安全约束与跨语言 CI +- [x] Fabric、BepInEx 6、Luanti NPC 示例 Mod +- [ ] 在真实 Fabric/BepInEx/Luanti 游戏版本中完成人工安装与交互验收 +- [x] 以 MIT License 发布仓库、SDK 与示例 Mod 每个阶段继续保持一个原则:模型可以提出意图和表达,游戏引擎决定现实发生了什么。 diff --git a/SECURITY.en.md b/SECURITY.en.md new file mode 100644 index 0000000..88a7349 --- /dev/null +++ b/SECURITY.en.md @@ -0,0 +1,64 @@ +# Security + +[简体中文](SECURITY.md) | [English](SECURITY.en.md) + +## Defaults + +- The service listens only on `127.0.0.1` by default. +- A non-loopback listener requires both `-allow-remote` and `RIN_TOKEN`. +- Rin does not terminate inbound TLS. Remote deployments must place it + behind a TLS reverse proxy on a controlled network. +- Once a token is configured, every endpoint except `/health` uses + constant-time Bearer-token verification. +- JSON bodies are limited to 32 MiB by default, primarily for complete + snapshots. Unknown fields, multiple JSON values, and non-UTF-8 input are + rejected. +- Session IDs use safe identifiers only; HTTP requests cannot provide file + paths. +- Events and snapshots use `0600` permissions; immutable snapshot files are + written atomically. +- API keys, sidecar tokens, and provider configuration are not protocol state + and are never persisted. +- Provider URLs reject userinfo, query strings, fragments, and automatic HTTP + redirects. Remote model endpoints require HTTPS by default. +- Official game adapters also reject redirects. Plaintext sidecar HTTP is + limited to explicit loopback origins, while remote HTTPS requires a token. + +## Trust model + +Policy and model output are untrusted. The runtime accepts only candidate +actions declared by the game for the current request and verifies actor, +goal, memory, boundary, revision, and content binding. Rin does not execute +scripts, shells, dynamic plugins, or model-generated tool calls. + +Online mode sends only the current actor's bounded traits, boundaries, active +goals, relevant memories, beliefs, recent actions, and candidate actions. +Event logs, complete sessions, receipts, snapshots, file paths, tokens, and +API keys do not enter the model packet. All game text is placed under +explicitly marked `untrusted_game_data`, and model output still requires local +allowlist validation. + +Structured Generation sends caller-provided messages to the model but does +not automatically attach sessions, event logs, paths, or credentials. Rin +validates only the top-level JSON object and character/byte limits. The caller +must validate its own field schema, referenced IDs, permissions, and canon, +and must never directly execute generated output. + +Games must keep high-authority operations such as quests, items, combat, +currency, intimacy consent, and critical plot transitions in their own rule +layer. + +Adapter proposals named `offline.*` exist only for a game's own offline +fallback. They are explicitly marked `committable=false` and cannot be +submitted as sidecar proposals. Threads, HTTP objects, and cancellation +handles must not enter Ren'Py saves; only plain JSON results and validated +snapshots may be persisted. + +Only one Rin process may write to a data directory. High-availability or +multi-instance hosts must coordinate a single writer or implement another +store. + +## Reporting + +Use the GitHub repository's private security-reporting channel. Do not attach +tokens, API keys, saves, or complete event logs to a public issue. diff --git a/SECURITY.md b/SECURITY.md index 6950506..a7044fb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,10 +1,12 @@ # Security +[简体中文](SECURITY.md) | [English](SECURITY.en.md) + ## Defaults - 服务默认只监听 `127.0.0.1`。 - 非 loopback 地址必须同时传入 `-allow-remote` 并设置 `RIN_TOKEN`。 -- Rin v0.4 不提供入站 TLS;远程部署必须放在受控网络和 TLS 反向代理之后。 +- Rin 不终止入站 TLS;远程部署必须放在受控网络和 TLS 反向代理之后。 - 除 `/health` 外,配置 Token 后所有端点都使用 constant-time Bearer 校验。 - JSON 正文默认限制为 32 MiB(主要用于完整快照),未知字段、多个 JSON 值和非 UTF-8 内容被拒绝。 - Session ID 只能使用安全标识符,HTTP 请求不能提供文件路径。 diff --git a/compat/documentation_test.go b/compat/documentation_test.go new file mode 100644 index 0000000..621a4e9 --- /dev/null +++ b/compat/documentation_test.go @@ -0,0 +1,110 @@ +package compat_test + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestBilingualDocumentationPairs(t *testing.T) { + pairs := [][2]string{ + {"../README.en.md", "../README.md"}, + {"../ROADMAP.en.md", "../ROADMAP.md"}, + {"../SECURITY.en.md", "../SECURITY.md"}, + {"../docs/README.md", "../docs/README.zh-CN.md"}, + {"../docs/architecture.md", "../docs/architecture.zh-CN.md"}, + {"../docs/game-adapters.md", "../docs/game-adapters.zh-CN.md"}, + {"../docs/living-worlds-v0.5-plan.md", "../docs/living-worlds-v0.5-plan.zh-CN.md"}, + {"../docs/model-policy.md", "../docs/model-policy.zh-CN.md"}, + {"../docs/protocol-v1.md", "../docs/protocol-v1.zh-CN.md"}, + {"../docs/rpg-events.md", "../docs/rpg-events.zh-CN.md"}, + {"../docs/sdk-and-mods.md", "../docs/sdk-and-mods.zh-CN.md"}, + {"../sdk/README.md", "../sdk/README.zh-CN.md"}, + {"../sdk/python/README.md", "../sdk/python/README.zh-CN.md"}, + {"../sdk/javascript/README.md", "../sdk/javascript/README.zh-CN.md"}, + {"../sdk/csharp/README.md", "../sdk/csharp/README.zh-CN.md"}, + {"../sdk/java/README.md", "../sdk/java/README.zh-CN.md"}, + {"../sdk/lua/README.md", "../sdk/lua/README.zh-CN.md"}, + {"../examples/mods/fabric-rin-npc/README.md", "../examples/mods/fabric-rin-npc/README.zh-CN.md"}, + {"../examples/mods/bepinex-rin-npc/README.md", "../examples/mods/bepinex-rin-npc/README.zh-CN.md"}, + {"../examples/mods/luanti-rin-npc/README.md", "../examples/mods/luanti-rin-npc/README.zh-CN.md"}, + } + + for _, pair := range pairs { + for _, path := range pair { + payload, err := os.ReadFile(path) + if err != nil { + t.Errorf("%s: %v", path, err) + continue + } + text := string(payload) + if !strings.Contains(text, "[English]") || !strings.Contains(text, "[简体中文]") { + t.Errorf("%s is missing the bilingual navigation", path) + } + } + } +} + +func TestMarkdownLocalLinksResolve(t *testing.T) { + linkPattern := regexp.MustCompile(`\[[^\]]+\]\(([^)]+)\)`) + err := filepath.WalkDir("..", func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + switch entry.Name() { + case ".git", ".cache", "bin", "obj": + return filepath.SkipDir + } + return nil + } + if filepath.Ext(path) != ".md" { + return nil + } + + payload, readErr := os.ReadFile(path) + if readErr != nil { + return readErr + } + for _, match := range linkPattern.FindAllStringSubmatch(string(payload), -1) { + target := strings.Trim(strings.TrimSpace(match[1]), "<>") + if target == "" || strings.HasPrefix(target, "#") || + strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "http://") || + strings.HasPrefix(target, "mailto:") { + continue + } + target = strings.SplitN(target, "#", 2)[0] + target = strings.SplitN(target, "?", 2)[0] + resolved := filepath.Clean(filepath.Join(filepath.Dir(path), filepath.FromSlash(target))) + if _, statErr := os.Stat(resolved); statErr != nil { + t.Errorf("%s links to missing local target %s", path, target) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +func TestMITLicenseMetadata(t *testing.T) { + required := map[string]string{ + "../LICENSE": "MIT License", + "../sdk/python/pyproject.toml": `license = {text = "MIT"}`, + "../sdk/javascript/package.json": `"license": "MIT"`, + "../sdk/csharp/Rin.Client/Rin.Client.csproj": "MIT", + "../examples/mods/fabric-rin-npc/src/main/resources/fabric.mod.json": `"license": "MIT"`, + } + for path, marker := range required { + payload, err := os.ReadFile(path) + if err != nil { + t.Errorf("%s: %v", path, err) + continue + } + if !strings.Contains(string(payload), marker) { + t.Errorf("%s is missing MIT metadata %q", path, marker) + } + } +} diff --git a/compat/sdk_kits_test.go b/compat/sdk_kits_test.go new file mode 100644 index 0000000..c0bec6f --- /dev/null +++ b/compat/sdk_kits_test.go @@ -0,0 +1,239 @@ +package compat_test + +import ( + "encoding/json" + "os" + "regexp" + "strings" + "testing" + "unicode" +) + +type sdkRouteManifest struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion string `json:"protocol_version"` + Operations []sdkRoute `json:"operations"` +} + +type sdkRoute struct { + Name string `json:"name"` + Method string `json:"method"` + Path string `json:"path"` + Status int `json:"status"` +} + +func TestSDKsCoverTheProtocolRouteManifest(t *testing.T) { + manifest := loadSDKRouteManifest(t) + if manifest.SchemaVersion != 1 || manifest.ProtocolVersion != "rin.protocol/v1" { + t.Fatalf("unexpected SDK manifest header: %+v", manifest) + } + if len(manifest.Operations) != 20 { + t.Fatalf("route manifest has %d operations, want 20", len(manifest.Operations)) + } + seen := make(map[string]bool, len(manifest.Operations)) + for _, operation := range manifest.Operations { + key := operation.Method + " " + operation.Path + if seen[key] || operation.Name == "" { + t.Fatalf("duplicate or unnamed operation %q", key) + } + seen[key] = true + if operation.Status != 200 && operation.Status != 202 { + t.Fatalf("operation %s has unexpected status %d", operation.Name, operation.Status) + } + } + + sdks := []struct { + name string + path string + methodName func(string) string + }{ + {name: "python", path: "../sdk/python/src/rin_sdk/client.py", methodName: func(value string) string { return "def " + value + "(" }}, + {name: "javascript", path: "../sdk/javascript/src/index.js", methodName: func(value string) string { return lowerCamel(value) + "(" }}, + {name: "csharp", path: "../sdk/csharp/Rin.Client/RinClient.cs", methodName: func(value string) string { return upperCamel(value) + "Async(" }}, + {name: "java", path: "../sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java", methodName: func(value string) string { return lowerCamel(value) + "(" }}, + {name: "lua", path: "../sdk/lua/rin.lua", methodName: func(value string) string { return "Client:" + value + "(" }}, + } + for _, sdk := range sdks { + t.Run(sdk.name, func(t *testing.T) { + payload, err := os.ReadFile(sdk.path) + if err != nil { + t.Fatal(err) + } + text := string(payload) + for _, operation := range manifest.Operations { + if !strings.Contains(text, sdk.methodName(operation.Name)) { + t.Errorf("%s is missing operation %s", sdk.path, operation.Name) + } + pathPrefix := strings.TrimSuffix(operation.Path, "{job_id}") + if !strings.Contains(text, pathPrefix) { + t.Errorf("%s is missing route %s", sdk.path, operation.Path) + } + } + }) + } +} + +func TestSDKRouteManifestMatchesHTTPServer(t *testing.T) { + manifest := loadSDKRouteManifest(t) + payload, err := os.ReadFile("../httpapi/server.go") + if err != nil { + t.Fatal(err) + } + matches := regexp.MustCompile(`mux\.HandleFunc\("([A-Z]+) ([^"]+)"`).FindAllStringSubmatch(string(payload), -1) + registered := make(map[string]bool, len(matches)) + for _, match := range matches { + registered[match[1]+" "+match[2]] = true + } + if len(registered) != len(manifest.Operations) { + t.Fatalf("HTTP server has %d routes, SDK manifest has %d", len(registered), len(manifest.Operations)) + } + for _, operation := range manifest.Operations { + key := operation.Method + " " + operation.Path + if !registered[key] { + t.Errorf("SDK route manifest contains unregistered route %s", key) + } + } +} + +func TestSDKTransportSecurityGuardsRemainVisible(t *testing.T) { + tests := []struct { + path string + required []string + forbidden []string + }{ + { + path: "../sdk/python/src/rin_sdk/client.py", + required: []string{"_NoRedirect", "max_response_bytes", "remote Rin endpoints must use HTTPS", "Authorization"}, + forbidden: []string{"import requests", "verify=False", "sk-"}, + }, + { + path: "../sdk/javascript/src/index.js", + required: []string{"redirect: \"error\"", "AbortController", "maxResponseBytes", "remote Rin endpoints must use HTTPS"}, + forbidden: []string{"rejectUnauthorized: false", "sk-"}, + }, + { + path: "../sdk/csharp/Rin.Client/RinClient.cs", + required: []string{"AllowAutoRedirect = false", "ResponseHeadersRead", "maxResponseBytes", "Remote Rin endpoints must use HTTPS"}, + forbidden: []string{"DangerousAcceptAnyServerCertificateValidator", ".Result", "sk-"}, + }, + { + path: "../sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java", + required: []string{"HttpClient.Redirect.NEVER", "BoundedBodySubscriber", "maxResponseBytes", "Remote Rin endpoints must use HTTPS"}, + forbidden: []string{"HostnameVerifier", "get().join()", "sk-"}, + }, + { + path: "../sdk/lua/rin.lua", + required: []string{"follow_redirects = false", "max_response_bytes", "Remote Rin endpoints must use HTTPS", "Authorization"}, + forbidden: []string{"os.execute", "io.popen", "sk-"}, + }, + } + for _, test := range tests { + payload, err := os.ReadFile(test.path) + if err != nil { + t.Fatal(err) + } + text := string(payload) + for _, required := range test.required { + if !strings.Contains(text, required) { + t.Errorf("%s is missing %q", test.path, required) + } + } + for _, forbidden := range test.forbidden { + if strings.Contains(text, forbidden) { + t.Errorf("%s contains forbidden pattern %q", test.path, forbidden) + } + } + } +} + +func TestExampleModsPreserveGameAuthority(t *testing.T) { + tests := []struct { + path string + required []string + forbidden []string + }{ + { + path: "../examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java", + required: []string{"ALLOWED_ACTIONS", "activePlayers", "waitForProposal", "server.execute", "rin.commit", "candidate_actions"}, + forbidden: []string{"Runtime.getRuntime().exec", "ProcessBuilder", ".join()"}, + }, + { + path: "../examples/mods/bepinex-rin-npc/Plugin.cs", + required: []string{"AllowedActions", "WaitForProposalAsync", "mainThread.Enqueue", "CommitAsync", "NpcActionReady"}, + forbidden: []string{"Config.Bind(\"Connection\", \"Token\"", ".Result", ".Wait()"}, + }, + { + path: "../examples/mods/luanti-rin-npc/init.lua", + required: []string{"core.request_http_api", "local_origin", "allowed_actions", "wait_for_proposal", "client:commit"}, + forbidden: []string{"secure.trusted_mods", "request.headers.Authorization =", "os.execute"}, + }, + } + for _, test := range tests { + payload, err := os.ReadFile(test.path) + if err != nil { + t.Fatal(err) + } + text := string(payload) + for _, required := range test.required { + if !strings.Contains(text, required) { + t.Errorf("%s is missing %q", test.path, required) + } + } + for _, forbidden := range test.forbidden { + if strings.Contains(text, forbidden) { + t.Errorf("%s contains forbidden pattern %q", test.path, forbidden) + } + } + } + + sdk, err := os.ReadFile("../sdk/lua/rin.lua") + if err != nil { + t.Fatal(err) + } + vendored, err := os.ReadFile("../examples/mods/luanti-rin-npc/rin.lua") + if err != nil { + t.Fatal(err) + } + if string(sdk) != string(vendored) { + t.Fatal("Luanti vendored rin.lua differs from sdk/lua/rin.lua") + } +} + +func lowerCamel(value string) string { + result := upperCamel(value) + if result == "" { + return result + } + return strings.ToLower(result[:1]) + result[1:] +} + +func upperCamel(value string) string { + var result []rune + upper := true + for _, character := range value { + if character == '_' || character == '-' { + upper = true + continue + } + if upper { + result = append(result, unicode.ToUpper(character)) + upper = false + } else { + result = append(result, character) + } + } + return string(result) +} + +func loadSDKRouteManifest(t *testing.T) sdkRouteManifest { + t.Helper() + payload, err := os.ReadFile("../sdk/conformance/routes.json") + if err != nil { + t.Fatal(err) + } + var manifest sdkRouteManifest + if err := json.Unmarshal(payload, &manifest); err != nil { + t.Fatal(err) + } + return manifest +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..d2a18db --- /dev/null +++ b/docs/README.md @@ -0,0 +1,22 @@ +# Rin Documentation + +[English](README.md) | [简体中文](README.zh-CN.md) + +| Topic | English | 简体中文 | +| --- | --- | --- | +| Architecture and authority boundary | [Architecture](architecture.md) | [架构](architecture.zh-CN.md) | +| HTTP and state contract | [Protocol v1](protocol-v1.md) | [协议 v1](protocol-v1.zh-CN.md) | +| Online-model configuration | [Model policy](model-policy.md) | [模型策略](model-policy.zh-CN.md) | +| Ren'Py, Godot, and Unity | [Game adapters](game-adapters.md) | [游戏适配器](game-adapters.zh-CN.md) | +| Regions, quests, and NPC actions | [RPG event conventions](rpg-events.md) | [RPG 事件约定](rpg-events.zh-CN.md) | +| Cross-language clients and mods | [SDK and mod kits](sdk-and-mods.md) | [SDK 与 Mod 套件](sdk-and-mods.zh-CN.md) | +| v0.5 implementation baseline | [Living Worlds plan](living-worlds-v0.5-plan.md) | [Living Worlds 计划](living-worlds-v0.5-plan.zh-CN.md) | +| Security and reporting | [Security](../SECURITY.en.md) | [安全](../SECURITY.md) | +| Release direction | [Roadmap](../ROADMAP.en.md) | [路线图](../ROADMAP.md) | +| Repository overview | [README](../README.en.md) | [项目说明](../README.md) | + +SDK-specific quick starts are under [`sdk/`](../sdk/README.md). Fabric, +BepInEx, and Luanti installation templates are under +[`examples/mods/`](../examples/mods/). + +The standard [MIT License](../LICENSE) is the authoritative license text. diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md new file mode 100644 index 0000000..686eaca --- /dev/null +++ b/docs/README.zh-CN.md @@ -0,0 +1,21 @@ +# Rin 文档 + +[English](README.md) | [简体中文](README.zh-CN.md) + +| 主题 | 简体中文 | English | +| --- | --- | --- | +| 架构与权威边界 | [架构](architecture.zh-CN.md) | [Architecture](architecture.md) | +| HTTP 与状态契约 | [协议 v1](protocol-v1.zh-CN.md) | [Protocol v1](protocol-v1.md) | +| 在线模型配置 | [模型策略](model-policy.zh-CN.md) | [Model policy](model-policy.md) | +| Ren'Py、Godot 与 Unity | [游戏适配器](game-adapters.zh-CN.md) | [Game adapters](game-adapters.md) | +| 区域、任务与 NPC 动作 | [RPG 事件约定](rpg-events.zh-CN.md) | [RPG event conventions](rpg-events.md) | +| 跨语言客户端与 Mod | [SDK 与 Mod 套件](sdk-and-mods.zh-CN.md) | [SDK and mod kits](sdk-and-mods.md) | +| v0.5 实施基线 | [Living Worlds 计划](living-worlds-v0.5-plan.zh-CN.md) | [Living Worlds plan](living-worlds-v0.5-plan.md) | +| 安全与漏洞报告 | [安全](../SECURITY.md) | [Security](../SECURITY.en.md) | +| 发布方向 | [路线图](../ROADMAP.md) | [Roadmap](../ROADMAP.en.md) | +| 仓库总览 | [项目说明](../README.md) | [README](../README.en.md) | + +各语言 SDK 快速开始位于 [`sdk/`](../sdk/README.zh-CN.md)。Fabric、 +BepInEx 和 Luanti 安装模板位于 [`examples/mods/`](../examples/mods/)。 + +标准 [MIT License](../LICENSE) 英文原文是具有约束力的许可证文本。 diff --git a/docs/architecture.md b/docs/architecture.md index 07b5b3f..ff3d17f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,5 +1,7 @@ # Architecture +[English](architecture.md) | [简体中文](architecture.zh-CN.md) + ## Authority boundary ```mermaid @@ -14,76 +16,147 @@ flowchart LR P -->|"structured draft"| V ``` -游戏引擎始终拥有世界权威。Rin 不直接修改场景、任务、物品、战斗、角色位置、关键选择或存档。Policy 只能从本次请求的 `candidate_actions` 中选择一个动作;运行时还会检查角色、目标、记忆引用、边界、会话 revision 和内容绑定。 +The game engine always owns world authority. Rin never directly changes +scenes, quests, items, combat, character positions, critical choices, or +saves. A policy may choose only from the current request's +`candidate_actions`; the runtime also verifies actor, goal, memory references, +boundaries, session revision, and content binding. ## Components ### Protocol -`protocol` 是唯一需要被其他语言复刻的层。所有请求显式携带 `rin.protocol/v1`,未知 JSON 字段会被 HTTP 层拒绝,标识符禁止路径分隔符。 +`protocol` is the only layer other languages need to reproduce. Every request +explicitly carries `rin.protocol/v1`. The HTTP layer rejects unknown JSON +fields, and identifiers cannot contain path separators. ### Runtime -`runtime.Engine` 是确定性状态机。每个会话单独加锁;Policy 在锁外执行,因此远程模型变慢不会阻塞新的观察或读状态。旧会话继续用 revision/head hash 判断过期;启用 `arbitration-v1` 的会话使用只在世界事实变化时前进的 `world_revision`,因此同一轮多个角色可以并行提出动作。 - -详细记忆保持固定窗口;`memory-archive-v1` 将最旧批次压成带来源 ID、tick 范围和原因的确定性摘要,并在摘要达到上限后继续分层合并。`belief-conflicts-v1` 为每个角色保留最多八条来源声明,同时维持旧 `beliefs` 字段作为当前选中投影。两者都完全由事件重放恢复,不依赖向量数据库。 +`runtime.Engine` is a deterministic state machine. Each session has its own +lock. Policy execution happens outside that lock, so a slow remote model does +not block new observations or state reads. Legacy sessions use revision and +head hash for staleness. Sessions with `arbitration-v1` use a +`world_revision` that advances only when world facts change, allowing several +actors to propose in parallel during one turn. + +Detailed memory keeps a fixed window. `memory-archive-v1` compresses the +oldest batch into a deterministic summary with source IDs, tick range, and +reason, then continues hierarchical merging when summaries reach their cap. +`belief-conflicts-v1` keeps up to eight sourced claims per actor while +retaining the legacy `beliefs` field as the currently selected projection. +Both are reconstructed entirely by event replay and require no vector +database. ### Policy -Policy 接口只返回 `ProposalDraft`。运行时不信任实现:动作必须来自白名单,记忆和目标 ID 必须真实存在,文本长度与 stance 必须合法。 +The policy interface returns only a `ProposalDraft`. The runtime does not +trust its implementation: actions must come from the allowlist, memory and +goal IDs must exist, and text length and stance must be valid. -内置 `policy.Deterministic` 是离线基线: +The built-in `policy.Deterministic` is the offline baseline: -1. 标签命中边界时只选择对应的 `refuse`、`redirect` 或 `wait` 动作。 -2. 否则优先服务高优先级主动目标。 -3. 用重要度、近期性、标签和召回次数选择最多三条记忆。 -4. 对重复动作降权,以固定 seed 和请求上下文确定性打破平局。 +1. If tags trigger a boundary, choose only its matching `refuse`, `redirect`, + or `wait` action. +2. Otherwise, prefer the highest-priority active goal. +3. Select up to three memories by importance, recency, tags, and recall count. +4. Penalize repeated actions and break ties deterministically from a fixed + seed and request context. -在线模型 Policy 只替换第 2–4 步,不绕过运行时验证器。 +The online model policy replaces only steps 2 through 4. It never bypasses the +runtime validator. ### Model policy -模型 Policy 只构造最小上下文包。系统指令与游戏数据分成两个 message,玩家输入、剧情文本和内容包字段全部位于 `untrusted_game_data`;同时给出独立 `contract`,列出唯一合法的 action、memory 和 goal ID。供应商即使不支持严格 JSON Schema,返回结果仍会在本地执行 unknown-field、类型、长度和 ID 白名单校验。 +The model policy builds a minimal context packet. System instructions and +game data are separate messages. Player input, story text, and content-pack +fields all live under `untrusted_game_data`; a separate `contract` lists the +only legal action, memory, and goal IDs. Even when a provider does not support +strict JSON Schema, the result still receives local unknown-field, type, +length, and ID-allowlist validation. -角色边界在调用供应商之前本地处理。触发边界时直接使用 `boundary-guard`,不会依赖模型自行拒绝。 +Character boundaries are handled locally before calling a provider. A +triggered boundary uses `boundary-guard` directly instead of relying on the +model to refuse. ### Provider resilience -OpenAI-compatible 客户端由标准库实现。每次调用具有 attempt timeout 和 total timeout,只重试网络、429、408 和 5xx 等暂时错误;连续失败会打开 circuit breaker,开放期直接进入离线回退。响应正文、Prompt 和 Key 不写入错误、日志或状态。 +The OpenAI-compatible client uses only the standard library. Each call has an +attempt timeout and total timeout. Only temporary failures such as network +errors, 429, 408, and 5xx responses are retried. Repeated failures open a +circuit breaker; while open, calls immediately enter offline fallback. +Response bodies, prompts, and keys are never written to errors, logs, or +state. -模型 Draft 按 Session head hash、Actor 和语义请求建立有界内存缓存。相同 key 的并发调用合并成一次供应商请求;状态变化后 head hash 改变,旧结果不会命中新世界状态。 +Model drafts use a bounded in-memory cache keyed by session head hash, actor, +and semantic request. Concurrent calls with the same key collapse into one +provider request. Once state changes, the head hash changes and an old result +cannot match the new world state. ### Async jobs -`jobs.Manager` 使用有界 worker 和 queue。游戏先提交 `/v1/jobs/propose`,继续渲染与接收输入,再通过 GET 轮询。若思考期间 Session 变化,Job 结束为 `stale`,不会写入旧提案;取消会沿 context 传递到 HTTP Provider。 +`jobs.Manager` uses bounded workers and a bounded queue. A game first submits +`/v1/jobs/propose`, continues rendering and accepting input, then polls with +GET. If the session changes while an actor is thinking, the job ends as +`stale` and no obsolete proposal is written. Cancellation propagates through +context to the HTTP provider. -Job 元数据只在进程内保留,成功 Proposal 本身已进入事件日志。Sidecar 重启后,客户端可用同一 `request_id` 重新提交,Engine 会幂等返回已生成 Proposal。 +Job metadata remains in process memory. A successful proposal is already in +the event log. After a sidecar restart, a client may resubmit the same +`request_id`; the engine idempotently returns the proposal it already +generated. ### Structured generation -`generation.Manager` 为游戏拥有的受限 Prompt 提供另一条有界异步队列。它复用同一个 resilient Provider,但不接触 Session 状态,也不直接写事件日志。请求按完整 payload 幂等、按去掉 request ID 后的语义内容短期缓存;取消沿 context 传播到 Provider。 +`generation.Manager` provides another bounded asynchronous queue for +game-owned constrained prompts. It reuses the resilient provider but does not +read session state or write directly to the event log. Requests are +idempotent over the complete payload and briefly cached by semantic content +after removing the request ID. Cancellation propagates to the provider. -Generation 只保证传输、大小和顶层 JSON Object 合法。各游戏仍必须验证自己的 `ScenePacket`、任务、对白或结局 Schema。若验证失败,游戏丢弃结果并使用本地内容;模型输出永远不会自动成为 Canon。 +Generation guarantees only transport, size, and a valid top-level JSON +object. Each game must still validate its own `ScenePacket`, quest, dialogue, +or ending schema. If validation fails, the game discards the result and uses +local content. Model output never becomes canon automatically. ### Game adapters -Ren'Py、Godot 和 Unity 适配器只转换 JSON/HTTP 与各自的异步机制,不复制 Runtime 状态机。在线结果带 `committable=true`;Sidecar 不可用时,适配器从游戏本次候选列表选择 authored fallback,标记 `committable=false`,游戏不得把本地 `offline.*` ID 发给 `/commit`。 +Ren'Py, Godot, and Unity adapters translate JSON/HTTP and engine-specific +asynchrony without copying the runtime state machine. Online results have +`committable=true`. When the sidecar is unavailable, an adapter chooses an +authored fallback from the current candidate list and marks it +`committable=false`; the game must not send a local `offline.*` ID to +`/commit`. -Ren'Py worker registry、Godot `HTTPRequest` 和 Unity coroutine 都只存在于进程内。游戏存档保存 Snapshot 与普通结果,不保存线程、Future、Socket、HTTP 对象或 API Token。 +The Ren'Py worker registry, Godot `HTTPRequest`, and Unity coroutines exist +only in process memory. A game save stores snapshots and plain results, never +threads, futures, sockets, HTTP objects, or API tokens. ### Multi-actor coordination -候选目标仍由游戏提供上限和语义范围,Policy 只能建议采用;只有 accepted Commit 才把目标写进 Actor。Activity 状态由游戏的区域或模拟系统更新,Dormant 角色不会自行唤醒。Arbitration 对同一 world revision 的 Proposal 做稳定排序并记录冲突,但不执行动作;游戏可以调整、拒绝,再以原子 Batch Commit 汇报实际结果。 +The game supplies the upper bound and semantic scope of candidate goals. A +policy may only recommend adopting one; only an accepted commit writes the +goal into an actor. The game's region or simulation system updates activity +state. Dormant actors never wake themselves. Arbitration stably sorts +proposals at the same world revision and records conflicts, but it does not +execute actions. The game may adjust or reject them and then report actual +outcomes through an atomic batch commit. -这使 Rin 可以服务视觉小说、RPG NPC 和模拟居民,同时不承担寻路、碰撞、任务规则或 Scene Tree 等引擎职责。 +This lets Rin support visual novels, RPG NPCs, and simulation residents +without taking responsibility for pathfinding, collision, quest rules, or a +scene tree. ### Observability -Timeline 只从事件 payload 提取 ID 和枚举状态,不返回玩家原话、剧情摘要、Commit outcome 或模型内容。Replay 则运行同一个 reducer 到指定 revision,生成完整且可验证的 Snapshot,不写回 Store。`rin inspect` 复用这两条路径输出机器可读诊断;打开数据目录时仍会验证全部事件 hash chain。 +Timeline extracts only IDs and enum states from event payloads. It does not +return the player's original words, story summaries, commit outcomes, or +model content. Replay runs the same reducer to a selected revision and +produces a complete, verifiable snapshot without writing to the store. +`rin inspect` reuses both paths for machine-readable diagnostics; opening a +data directory still verifies the entire event hash chain. ### Store -文件存储结构: +File-store layout: ```text rin-data/ @@ -93,25 +166,46 @@ rin-data/ └── snapshot--.json ``` -事件哈希覆盖 sequence、type、request ID、记录时间、上一事件哈希和 payload。启动时完整重放并验证;任何断链、改写或未知事件类型都会阻止会话加载。快照通过同目录临时文件、`fsync` 和 rename 写成按 revision/hash 命名的不可变文件,权限为 `0600`,不依赖各平台不同的覆盖 rename 行为。 +An event hash covers sequence, type, request ID, recorded time, previous event +hash, and payload. Startup fully replays and verifies the chain. A broken +link, rewritten record, or unknown event type prevents session loading. +Snapshots are immutable files named by revision and hash, written through a +temporary file in the same directory, `fsync`, and rename with `0600` +permissions. This avoids relying on platform-specific overwrite-rename +behavior. -文件 Store 是单写者设计:同一数据目录同时只能由一个 Rin 进程使用。需要多实例时应实现外部协调的 Store,而不是共享 JSONL 目录。 +The file store is single-writer. Only one Rin process may use a data directory +at a time. Multi-instance deployments should implement an externally +coordinated store instead of sharing a JSONL directory. ## NPC scheduling -每个 Actor 声明 `think_every_ticks`。动作被接受后,`next_think_tick = commit.tick + think_every_ticks`。游戏可在区域进入、回合结束、分钟推进或关键事件后调用 `/v1/scheduler/due`,不应在渲染帧中轮询模型。 +Each actor declares `think_every_ticks`. After an action is accepted, +`next_think_tick = commit.tick + think_every_ticks`. A game may call +`/v1/scheduler/due` when entering a region, ending a turn, advancing time, or +handling a critical event. It should never poll a model from render frames. -紧急事件可在 propose 请求中设置 `urgent: true`,但它只绕过调度时间,不绕过边界和动作白名单。 +An urgent event may set `urgent: true` on a propose request. Urgency bypasses +only scheduling time, never boundaries or the action allowlist. ## Save and rollback -- 游戏存档应保存 Rin 返回的 Snapshot,而不是内部文件路径。 -- Snapshot 带内容包 Binding 和状态哈希。 -- Restore 会清空未提交 Proposal,避免读档后执行旧世界状态上的动作。 -- 已提交事件、记忆、事实、目标进度和调度 tick 会恢复。 -- 新数据目录可以导入 Snapshot;此时本地事件链从一条 restore 事件开始。 -- 重复载入同一存档时,调用方应让 restore request ID 同时绑定 Snapshot hash 与当前 Sidecar head,以区分网络重试和真正的再次回档。 +- Game saves should store snapshots returned by Rin, not internal file paths. +- A snapshot carries the content-pack binding and state hash. +- Restore clears uncommitted proposals so an old-world action cannot execute + after loading. +- Committed events, memories, facts, goal progress, and scheduling ticks are + restored. +- A new data directory may import a snapshot; its local event chain then + begins with a restore event. +- When loading the same save repeatedly, callers should bind the restore + request ID to both the saved snapshot hash and current sidecar head. This + distinguishes a network retry from a real second rollback. ## Model integration rule -推荐把模型调用实现为另一个 `Policy`,或由上层 Showrunner 先生成结构化 Draft。供应商请求必须有超时和取消,API Key 只从进程环境或宿主安全存储读取。模型不接触事件文件、快照路径、游戏脚本和任意工具执行。 +Implement model access as another `Policy`, or let a higher-level showrunner +produce a structured draft first. Provider requests must have timeouts and +cancellation. Read API keys only from the process environment or secure host +storage. Models receive no event files, snapshot paths, game scripts, or +arbitrary tool execution. diff --git a/docs/architecture.zh-CN.md b/docs/architecture.zh-CN.md new file mode 100644 index 0000000..b4a1186 --- /dev/null +++ b/docs/architecture.zh-CN.md @@ -0,0 +1,119 @@ +# 架构 + +[English](architecture.md) | [简体中文](architecture.zh-CN.md) + +## 权威边界 + +```mermaid +flowchart LR + G["Game engine\nworld authority"] -->|Observation| R["Rin runtime\nmemory + goals + policy"] + R -->|ActionProposal| V["Schema + boundary + freshness validation"] + V -->|candidate action only| G + G -->|Commit accepted/rejected| R + R --> E["Hash-chained event log"] + R --> S["Verified snapshot"] + R -->|"bounded prompt packet"| P["Optional model provider"] + P -->|"structured draft"| V +``` + +游戏引擎始终拥有世界权威。Rin 不直接修改场景、任务、物品、战斗、角色位置、关键选择或存档。Policy 只能从本次请求的 `candidate_actions` 中选择一个动作;运行时还会检查角色、目标、记忆引用、边界、会话 revision 和内容绑定。 + +## 组件 + +### 协议 + +`protocol` 是唯一需要被其他语言复刻的层。所有请求显式携带 `rin.protocol/v1`,未知 JSON 字段会被 HTTP 层拒绝,标识符禁止路径分隔符。 + +### 运行时 + +`runtime.Engine` 是确定性状态机。每个会话单独加锁;Policy 在锁外执行,因此远程模型变慢不会阻塞新的观察或读状态。旧会话继续用 revision/head hash 判断过期;启用 `arbitration-v1` 的会话使用只在世界事实变化时前进的 `world_revision`,因此同一轮多个角色可以并行提出动作。 + +详细记忆保持固定窗口;`memory-archive-v1` 将最旧批次压成带来源 ID、tick 范围和原因的确定性摘要,并在摘要达到上限后继续分层合并。`belief-conflicts-v1` 为每个角色保留最多八条来源声明,同时维持旧 `beliefs` 字段作为当前选中投影。两者都完全由事件重放恢复,不依赖向量数据库。 + +### 策略 + +Policy 接口只返回 `ProposalDraft`。运行时不信任实现:动作必须来自白名单,记忆和目标 ID 必须真实存在,文本长度与 stance 必须合法。 + +内置 `policy.Deterministic` 是离线基线: + +1. 标签命中边界时只选择对应的 `refuse`、`redirect` 或 `wait` 动作。 +2. 否则优先服务高优先级主动目标。 +3. 用重要度、近期性、标签和召回次数选择最多三条记忆。 +4. 对重复动作降权,以固定 seed 和请求上下文确定性打破平局。 + +在线模型 Policy 只替换第 2–4 步,不绕过运行时验证器。 + +### 模型策略 + +模型 Policy 只构造最小上下文包。系统指令与游戏数据分成两个 message,玩家输入、剧情文本和内容包字段全部位于 `untrusted_game_data`;同时给出独立 `contract`,列出唯一合法的 action、memory 和 goal ID。供应商即使不支持严格 JSON Schema,返回结果仍会在本地执行 unknown-field、类型、长度和 ID 白名单校验。 + +角色边界在调用供应商之前本地处理。触发边界时直接使用 `boundary-guard`,不会依赖模型自行拒绝。 + +### 供应商韧性 + +OpenAI-compatible 客户端由标准库实现。每次调用具有 attempt timeout 和 total timeout,只重试网络、429、408 和 5xx 等暂时错误;连续失败会打开 circuit breaker,开放期直接进入离线回退。响应正文、Prompt 和 Key 不写入错误、日志或状态。 + +模型 Draft 按 Session head hash、Actor 和语义请求建立有界内存缓存。相同 key 的并发调用合并成一次供应商请求;状态变化后 head hash 改变,旧结果不会命中新世界状态。 + +### 异步任务 + +`jobs.Manager` 使用有界 worker 和 queue。游戏先提交 `/v1/jobs/propose`,继续渲染与接收输入,再通过 GET 轮询。若思考期间 Session 变化,Job 结束为 `stale`,不会写入旧提案;取消会沿 context 传递到 HTTP Provider。 + +Job 元数据只在进程内保留,成功 Proposal 本身已进入事件日志。Sidecar 重启后,客户端可用同一 `request_id` 重新提交,Engine 会幂等返回已生成 Proposal。 + +### 结构化生成 + +`generation.Manager` 为游戏拥有的受限 Prompt 提供另一条有界异步队列。它复用同一个 resilient Provider,但不接触 Session 状态,也不直接写事件日志。请求按完整 payload 幂等、按去掉 request ID 后的语义内容短期缓存;取消沿 context 传播到 Provider。 + +Generation 只保证传输、大小和顶层 JSON Object 合法。各游戏仍必须验证自己的 `ScenePacket`、任务、对白或结局 Schema。若验证失败,游戏丢弃结果并使用本地内容;模型输出永远不会自动成为 Canon。 + +### 游戏适配器 + +Ren'Py、Godot 和 Unity 适配器只转换 JSON/HTTP 与各自的异步机制,不复制 Runtime 状态机。在线结果带 `committable=true`;Sidecar 不可用时,适配器从游戏本次候选列表选择 authored fallback,标记 `committable=false`,游戏不得把本地 `offline.*` ID 发给 `/commit`。 + +Ren'Py worker registry、Godot `HTTPRequest` 和 Unity coroutine 都只存在于进程内。游戏存档保存 Snapshot 与普通结果,不保存线程、Future、Socket、HTTP 对象或 API Token。 + +### 多角色协调 + +候选目标仍由游戏提供上限和语义范围,Policy 只能建议采用;只有 accepted Commit 才把目标写进 Actor。Activity 状态由游戏的区域或模拟系统更新,Dormant 角色不会自行唤醒。Arbitration 对同一 world revision 的 Proposal 做稳定排序并记录冲突,但不执行动作;游戏可以调整、拒绝,再以原子 Batch Commit 汇报实际结果。 + +这使 Rin 可以服务视觉小说、RPG NPC 和模拟居民,同时不承担寻路、碰撞、任务规则或 Scene Tree 等引擎职责。 + +### 可观测性 + +Timeline 只从事件 payload 提取 ID 和枚举状态,不返回玩家原话、剧情摘要、Commit outcome 或模型内容。Replay 则运行同一个 reducer 到指定 revision,生成完整且可验证的 Snapshot,不写回 Store。`rin inspect` 复用这两条路径输出机器可读诊断;打开数据目录时仍会验证全部事件 hash chain。 + +### 存储 + +文件存储结构: + +```text +rin-data/ +└── sessions/ + └── session.id/ + ├── events.jsonl + └── snapshot--.json +``` + +事件哈希覆盖 sequence、type、request ID、记录时间、上一事件哈希和 payload。启动时完整重放并验证;任何断链、改写或未知事件类型都会阻止会话加载。快照通过同目录临时文件、`fsync` 和 rename 写成按 revision/hash 命名的不可变文件,权限为 `0600`,不依赖各平台不同的覆盖 rename 行为。 + +文件 Store 是单写者设计:同一数据目录同时只能由一个 Rin 进程使用。需要多实例时应实现外部协调的 Store,而不是共享 JSONL 目录。 + +## NPC 调度 + +每个 Actor 声明 `think_every_ticks`。动作被接受后,`next_think_tick = commit.tick + think_every_ticks`。游戏可在区域进入、回合结束、分钟推进或关键事件后调用 `/v1/scheduler/due`,不应在渲染帧中轮询模型。 + +紧急事件可在 propose 请求中设置 `urgent: true`,但它只绕过调度时间,不绕过边界和动作白名单。 + +## 存档与回滚 + +- 游戏存档应保存 Rin 返回的 Snapshot,而不是内部文件路径。 +- Snapshot 带内容包 Binding 和状态哈希。 +- Restore 会清空未提交 Proposal,避免读档后执行旧世界状态上的动作。 +- 已提交事件、记忆、事实、目标进度和调度 tick 会恢复。 +- 新数据目录可以导入 Snapshot;此时本地事件链从一条 restore 事件开始。 +- 重复载入同一存档时,调用方应让 restore request ID 同时绑定 Snapshot hash 与当前 Sidecar head,以区分网络重试和真正的再次回档。 + +## 模型接入规则 + +推荐把模型调用实现为另一个 `Policy`,或由上层 Showrunner 先生成结构化 Draft。供应商请求必须有超时和取消,API Key 只从进程环境或宿主安全存储读取。模型不接触事件文件、快照路径、游戏脚本和任意工具执行。 diff --git a/docs/game-adapters.md b/docs/game-adapters.md index 0885690..1a3562d 100644 --- a/docs/game-adapters.md +++ b/docs/game-adapters.md @@ -1,5 +1,7 @@ # Game Adapters +[English](game-adapters.md) | [简体中文](game-adapters.zh-CN.md) + Rin adapters keep the same authority split on every engine: 1. The game sends only events an actor actually observed. diff --git a/docs/game-adapters.zh-CN.md b/docs/game-adapters.zh-CN.md new file mode 100644 index 0000000..b8724e6 --- /dev/null +++ b/docs/game-adapters.zh-CN.md @@ -0,0 +1,143 @@ +# 游戏适配器 + +[English](game-adapters.md) | [简体中文](game-adapters.zh-CN.md) + +Rin 适配器在所有引擎上保持相同的权威边界: + +1. 游戏只发送角色确实观察到的事件。 +2. 游戏提供一组当前合法且数量有限的动作。 +3. Rin 返回提案,但不会移动角色或修改世界。 +4. 游戏执行自己的规则,并提交接受或拒绝后的真实结果。 + +适配器会在协议提案外增加两个本地字段: + +- `committable=true`:提案来自当前 Sidecar 会话,游戏应用后可以发送到 + `/v1/action/commit`。 +- `committable=false`:游戏使用了自己编写的离线回退。可以在本地应用, + 但不能把 `offline.*` ID 发送给 Rin。Sidecar 恢复后,应通过 `observe` + 报告实际产生的事件。 + +## Ren'Py + +将以下文件复制到游戏的 `game/` 目录: + +```text +adapters/renpy/rin_client.py +adapters/renpy/rin_bridge.rpy +``` + +客户端只使用 Python 标准库。需要显式启用: + +```bash +export RIN_ENABLED=1 +export RIN_BASE_URL="http://127.0.0.1:7374" +``` + +远程 TLS 反向代理需要设置 `RIN_TOKEN`;适配器拒绝非 loopback HTTP +以及无 Token 的远程端点。可选设置: + +| 变量 | 默认值 | 含义 | +| --- | --- | --- | +| `RIN_TIMEOUT_SECONDS` | `5` | 单次适配器 HTTP 请求 | +| `RIN_JOB_DEADLINE_SECONDS` | `25` | 异步提案总等待时间 | +| `RIN_POLL_INTERVAL_SECONDS` | `0.1` | Job 轮询间隔 | +| `RIN_LIVE_TEST_ENABLED` | `0` | 显式允许 Ren'Py 原生测试访问网络 | + +在脚本中安排请求,继续渲染,再从 timer 或 call screen 消费结果: + +```python +request_id = rin_schedule_proposal({ + "protocol_version": "rin.protocol/v1", + "session_id": "playthrough-1", + "request_id": "propose.scene-12.lin", + "actor_id": "npc.lin", + "tick": 12, + "intent": "Choose how to answer.", + "tags": ["conversation"], + "candidate_actions": [ + {"id": "respond.honest", "kind": "dialogue", "description": "Answer honestly."}, + {"id": "respond.wait", "kind": "wait", "description": "Wait for now."}, + ], +}, fallback_action_id="respond.wait") +``` + +`rin_proposal_status(request_id)` 返回 `pending`、`ready` 或 `missing`; +`rin_consume_proposal(request_id)` 返回一个普通 JSON 兼容结果; +`rin_cancel_proposal` 会把取消传递给 Job API。 + +Python 客户端还提供 `commit_batch`、`set_actor_activity`、`arbitrate`、 +`timeline`、`replay` 和结构化生成方法。Generation 必须与 Proposal 一样 +使用进程内后台模式。`generate_json` 只接受不含供应商信息的 Rin 请求契约, +返回一个解码后的 JSON Object 和受长度限制的运维元数据。若游戏持久化请求 +记录,应只允许所需字段;供应商模型名可用于显式探测,但不应写入玩法存档。 + +线程、取消事件、HTTP 对象和注册表都只属于当前进程。不要把它们赋给 +`default`、persistent 数据、rollback 状态或存档对象。只保存已接受的协议 +Snapshot 和普通结果字典。 + +即使开发者 shell 配置了端点,Ren'Py 原生测试也默认离线;只有 +`RIN_LIVE_TEST_ENABLED=1` 才允许真实网络。 + +## Godot 4 + +将[客户端](../examples/godot/rin_client.gd)添加为节点或 autoload。 +`propose_with_fallback` 等待 `HTTPRequest` signal 和 timer tick,不会阻塞 +渲染。[NPC 示例](../examples/godot/example_npc.gd)展示完整的提案、游戏应用 +和提交顺序。 + +Godot 负责导航、动画、战斗、背包和对白渲染。Activity、到期角色、仲裁、 +批量提交、时间线和回放 helper 都是 coroutine;只在模拟或区域变化时更新 +Activity,不要每帧调用。适配器限制响应字节、禁用重定向,并只对精确的 +loopback 主机和合法端口接受明文 HTTP。 + +## Unity + +将 [RinClient.cs](../examples/unity/RinClient.cs) 挂载到 GameObject。它使用 +`UnityWebRequest` coroutine 和有上限的流式下载处理器,不需要额外 JSON +或网络包。[RinNpcExample.cs](../examples/unity/RinNpcExample.cs)展示同样的 +先应用、后提交流程。 + +Unity 的 `JsonUtility` 适配器为 Activity、调度、仲裁、批量提交和时间线 +提供可序列化 DTO。由于 `JsonUtility` 无法表示以 Actor ID 为键的 map, +Replay helper 只返回已验证的 Snapshot header;需要完整回放状态的项目应 +使用现有的字典型 JSON 包解析同一端点。使用动作参数 map 的游戏也可扩展 +可序列化请求类,无需修改线上协议。 + +## ai-galgame 兼容性 + +`compat/ai-galgame/vectors.json` 基于 `unsent-letters.rebuild` `1.2.0`, +覆盖: + +- 私密信件权限压力触发本地边界拒绝; +- 角色特定的观察和认知可见性; +- 目标驱动的可选 Storylet; +- 接受提交、冷却调度和过早提案拒绝。 + +游戏专用 `rin_story.py` 层还组合了: + +- 内容包绑定与每周目一个 Rin Session; +- 将 CanonLedger 事件转为 Actor 范围的 Observation; +- 将玩家自由文本转为显式 Observation; +- 在场景、自由回应、Storylet 和结局生成前提供仅候选的女主方向; +- 接受方向并 Commit,再把 Snapshot 存入 Ren'Py 存档; +- 同时根据已存 Snapshot 与当前 Sidecar head 派生 Restore ID; +- Sidecar Generation 不可用时使用确定性的 authored fallback。 + +游戏设置只包含 `RIN_BASE_URL`、可选 `RIN_TOKEN` 和请求期限。供应商端点、 +模型 ID 和供应商 API Key 保留在 Rin 进程内。 + +验证本地 checkout 和全部内容哈希: + +```bash +python3 compat/ai-galgame/check_source.py --game-root /path/to/ai-galgame +go test ./compat +``` + +本地 Sidecar 运行时,通过真实 Python 适配器执行同一组向量: + +```bash +python3 compat/ai-galgame/run_adapter_smoke.py +``` + +向量只包含 ID、契约、哈希和短测试事件,不包含游戏的完整受版权保护剧情 +文本或任何供应商凭据。 diff --git a/docs/living-worlds-v0.5-plan.md b/docs/living-worlds-v0.5-plan.md index aa67502..bfa2cfc 100644 --- a/docs/living-worlds-v0.5-plan.md +++ b/docs/living-worlds-v0.5-plan.md @@ -1,5 +1,7 @@ # Rin v0.5 Living Worlds Implementation Plan +[English](living-worlds-v0.5-plan.md) | [简体中文](living-worlds-v0.5-plan.zh-CN.md) + Status: approved implementation baseline ## 1. Objective diff --git a/docs/living-worlds-v0.5-plan.zh-CN.md b/docs/living-worlds-v0.5-plan.zh-CN.md new file mode 100644 index 0000000..307e12f --- /dev/null +++ b/docs/living-worlds-v0.5-plan.zh-CN.md @@ -0,0 +1,297 @@ +# Rin v0.5 Living Worlds 实施计划 + +[English](living-worlds-v0.5-plan.md) | [简体中文](living-worlds-v0.5-plan.zh-CN.md) + +状态:已批准的实施基线 + +## 1. 目标 + +Rin v0.5 将当前兼容单角色的运行时扩展为一个小型、与引擎无关的 Living +World 基础,同时不把游戏世界权威交给模型。该版本必须支持长期角色记忆、 +互相冲突的私有认知、有界自主目标、区域感知的 Actor 调度、多角色仲裁和 +可检查回放。 + +不变量保持为: + +```text +模型或确定性策略 -> 提案 +游戏规则 -> 应用或拒绝 +Rin -> 记录观察到的结果 +``` + +首个生产消费者仍是 `ai-galgame`,但每个新契约都以与引擎无关的方式定义, +并在游戏适配器使用前由 Go 测试覆盖。 + +## 2. 约束 + +- 保持 `rin.protocol/v1`;新增内容使用可选字段和新端点。 +- 现有 create/observe/propose/commit/snapshot 请求继续有效。 +- 新状态字段使用 `omitempty`,使旧 Snapshot hash 仍可验证。 +- Living World 行为通过 Session feature flag 启用。旧 Session 保留 v0.4 + 的保留和调度行为。 +- 核心继续只使用 Go 标准库并保持无 CGO。 +- 模型不能在游戏提供的契约外创造可执行动作、目标、Goal、文件、工具或 + 游戏状态修改。 +- 玩家文本、Prompt、供应商响应和凭据不会写入运维日志或错误消息。 +- 游戏渲染、导航、物理、战斗、背包、任务、同意、购买和 Canon 剧情状态 + 继续由引擎拥有。 + +## 3. Feature 协商 + +`CreateSessionRequest.features` 接受一组有界标识符: + +| Feature | 用途 | +| --- | --- | +| `memory-archive-v1` | 确定性情节记忆压缩与摘要召回 | +| `belief-conflicts-v1` | 保留互相矛盾的 Actor 本地说法 | +| `goal-candidates-v1` | 允许 Policy 选择有界候选 Goal | +| `actor-activity-v1` | 持久化区域与 dormant/awake Actor 活动 | +| `arbitration-v1` | 记录确定性的多 Proposal 仲裁 | + +未知 Feature 会让 Session 创建失败。`/health` 会公布支持列表,使适配器 +可以关闭失败或省略不支持的 Feature。 + +当前 `ai-galgame` 接入启用 Memory Archive 与 Belief Conflict。在内容包 +提供显式候选目标和多角色场景前,不启用自主候选 Goal 或 Arbitration。 + +## 4. 记忆模型 + +### 4.1 情节记忆 + +`ActorState.memories` 保留近期、可带 Quote 的事件流。现有按重要度、 +近期性、Tag、Quote 和 Recall Count 的检索评分继续可用。 + +启用 `memory-archive-v1` 且超出情节记忆上限时: + +1. 从记忆较旧的一半中确定性选择一批低显著性项。 +2. 在仍有较低显著性候选时保留重要度为五的事件。 +3. 创建一级 `MemorySummary`,包含有界拼接摘要、合并 Tag、来源事件 ID、 + Tick 范围、重要度和压缩原因。 +4. 只删除该摘要所代表的来源情节。 +5. 摘要容量超限时,把最旧摘要合并到更高层级,而不是静默删除。 + +Summary ID 是内容 Hash,因此 Replay 不受 Map 遍历顺序或墙上时间影响, +会生成同一 Archive。 + +`MemorySummary.reason` 解释为何细节被压缩。来源事件 ID 和 Tick 范围让 +开发者追踪保留内容,而无需存储无限原文。Policy 检索可返回 Episode 或 +Summary ID;接受 Commit 会更新两类记忆的 Recall Counter。 + +### 4.2 兼容性 + +未启用 `memory-archive-v1` 的 Session 继续像 v0.4 一样保留最新 128 条 +情节记忆。旧事件日志保持历史 Replay 语义,除非新建 Session 显式选择。 + +## 5. Actor 本地认知 + +Observation 可见性仍是主要隐私边界:只有位于 `observer_ids` 以及 Fact +可选 Visibility List 中的 Actor 才能获得对应 Memory 或 Claim。 + +启用 `belief-conflicts-v1` 后,每个 `(subject_id, predicate)` 保存一个 +有界 `BeliefSet`: + +- 所有不同的近期 Claim 及其来源事件 ID; +- Confidence 和观察到的 Revision; +- 当前选中的 Claim; +- 不同 Object 共存时的显式 `conflicted` 标记。 + +现有 `ActorState.beliefs` Map 保留为选中 Claim 的兼容投影。选择过程确定: +先比较更高 Confidence,再比较更新 Revision,最后按 Object 字典序。 +Rin 不会悄悄把 Rumor 变成世界真相,也不会把一个 Actor 的 Claim 复制给 +另一个 Actor。 + +模型 Prompt 只获得请求 Actor 的有界 Selected Belief 和 Conflict Summary, +不会引入全知全局状态。 + +## 6. 有界自主 Goal + +`ProposeRequest.candidate_goals` 可以包含零个或多个完整 `Goal` 模板。 +Policy 可以引用: + +- Actor 现有的 Active Goal;或 +- 本次请求提供的一个 Candidate Goal。 + +选中 Candidate 后,`ActionProposal.proposed_goal` 嵌入完全相同的模板。 +只有游戏接受关联 Action Commit 后,Goal 才进入 Actor 状态。被拒绝或过期 +的 Proposal 永远不会创建 Goal。 + +这让角色可以主动,同时保持权威。游戏可以提供“询问损坏的相机”或“完成 +桥梁维修”等 Goal,但模型不能创建游戏未公布的购买、亲密升级、Quest、 +Target 或不可逆目标。 + +## 7. World Revision 与多角色仲裁 + +### 7.1 World Revision + +Event Log Revision 在每个持久化事件(包括 Proposal)后变化。多角色工作 +还需要一个只在可观察世界状态变化时改变的 Revision,因此引入 +`SessionState.world_revision`: + +- 在 Create、Observe、接受或拒绝 Commit、Actor Activity 和 Restore 时 + 递增; +- 不会仅因另一个 Actor 创建 Proposal 或 Arbitration Record 而递增; +- 复制到每个新 Proposal。 + +这样,多个 Actor 可以针对一个稳定世界状态并行提案。普通单 Commit 在 +无关 Proposal 之后仍有效,但在 Observation、Activity 变化、Restore 或 +另一个已提交结果后变为过期。 + +### 7.2 仲裁 + +`POST /v1/world/arbitrate` 接收 Pending Proposal ID 和一组有界 Exclusive +Target ID。Rin 按 Active Goal Priority、Proposal Tick、Actor ID 和 +Proposal ID 确定性排序,返回: + +- `selected`:没有排名更高的 Proposal 占用同一 Exclusive Target; +- `deferred`:更早的 Winner 已占用至少一个 Target; +- 面向玩家的原因和冲突 Proposal ID。 + +Arbitration 是持久化的调试建议,不会执行 Action 或解决 Proposal。 + +`POST /v1/action/commit-batch` 在一个原子事件中记录基于同一 World +Revision 的 Proposal 结果。游戏必须先通过自己的系统应用所有选中动作, +再 Commit。任何 Item 无效或过期都会拒绝整个 Batch。 + +## 8. 区域活动与调度 + +`POST /v1/session/activity` 持久化有界 Actor 更新: + +- Actor ID; +- Region ID; +- `awake` 或 `dormant` 状态; +- 游戏编写的原因和 Tick。 + +Dormant Actor 不会出现在 `/v1/scheduler/due`,游戏唤醒前也不能 Propose。 +`DueAgentsRequest.region_ids` 可选地把查询限制到当前加载区域。空 Region +Filter 保持现有行为。 + +游戏在区域加载/卸载或模拟日程变化时更新 Activity,而不是每个渲染帧。 +人群可继续使用 Deterministic Policy,附近具名 Actor 使用 Model Policy。 + +## 9. Timeline 与 Replay + +两个只读操作支持调试: + +- `/v1/session/timeline`:有界事件 Header 和安全结构元数据; +- `/v1/session/replay`:重建并验证指定 Revision 的状态。 + +Timeline 响应省略 Observation Summary、Quote、Prompt、Provider Content、 +Token 和 Credential。Replay 返回协议状态,可能暴露已存在于经过鉴权 +Session 中的剧情数据,因此远程端点必须沿用现有 Bearer Token 边界。 + +`rin inspect` 打开数据目录,通过正常 Runtime Replay 验证每条 Hash Chain, +并输出 JSON Session Summary。可选 Revision 使用与 HTTP 端点相同的 Replay +实现。 + +## 10. 引擎适配器 + +### Ren'Py + +- 为 Activity、Arbitration、Batch Commit、Timeline 和 Replay 增加普通 + Dictionary 方法。 +- 所有 HTTP 和 Polling 对象只保留在进程内。 +- `ai-galgame` v1.2 内容只启用 Memory 和 Belief Feature。 +- Rin 禁用或不可用时保留 Authored Fallback。 + +### Godot 4 + +- 为 Activity、Due-Agent Query、Arbitration 和 Batch Commit 增加 + Coroutine Helper。 +- 导航、动画、战斗、背包和 Scene Tree 修改保留在 Godot。 + +### Unity + +- 为相同端点增加可序列化 Request/Response DTO 和 Coroutine 方法。 +- 继续使用 `UnityWebRequest` 和无额外 Package 的有界下载。 + +适配器不会每帧运行 Agent Loop。引擎拥有 Simulation Tick,并决定 Actor +何时值得提交 Proposal Job。 + +## 11. 实施阶段与 Commit + +### 阶段 A:计划与兼容契约 + +- 添加本文档并更新 Roadmap。 +- 记录基线 Go 和游戏测试结果。 +- Commit:`docs: plan living worlds runtime`。 + +### 阶段 B:认知 + +- 添加 Feature 协商和可选协议字段。 +- 实现 Memory Archive 压缩、Summary Retrieval、Snapshot 验证和确定性 + Replay 测试。 +- 实现 Belief Set 和冲突 Claim Prompt Projection。 +- Commit:`feat: add long-term actor cognition`。 + +### 阶段 C:自主与世界协调 + +- 添加 Candidate Goal 与 Commit 时采用。 +- 添加 World Revision 语义。 +- 添加 Actor Activity、Region Filter、Arbitration 与原子 Batch Commit。 +- Commit:`feat: coordinate living world actors`。 + +### 阶段 D:可观测性与适配器 + +- 添加 Timeline/Replay API 和 `rin inspect`。 +- 扩展 Ren'Py、Godot、Unity 适配器与示例。 +- 更新 Protocol、Architecture、RPG、Model Policy 和 Security 文档。 +- Commit:`feat: add living world tooling and adapters`。 + +### 阶段 E:游戏接入 + +- 在 `ai-galgame` 创建新 Rin 周目 Session 时启用兼容认知 Feature。 +- 扩展 Compatibility Vector 和进程级 Integration Check。 +- 保持旧存档和 Classic Mode 不变。 +- 在游戏仓库 Commit:`feat: enable Rin living memory`。 + +## 12. 自动验证 + +Rin 验收要求: + +- `go test ./...`; +- `go test -race ./...`; +- `go vet ./...`; +- 确定性 Replay 生成相同状态和 Summary ID; +- 旧 v0.4 Fixture 与 Snapshot 仍可验证; +- Memory 不超过 Episode 或 Archive 上限; +- 私有 Claim 不出现在未列出的 Actor; +- 冲突 Claim 经过 Snapshot/Restore 后仍存在; +- Candidate Goal 只由 Accepted Commit 添加; +- Dormant Actor 不会到期也不允许 Propose; +- Arbitration 在打乱输入时仍保持确定顺序; +- Batch Commit 原子执行并拒绝混合 Revision; +- Timeline 输出不包含 Observation Quote 或 Summary; +- macOS arm64/amd64、Windows amd64、Linux amd64 构建成功; +- Ren'Py 适配器测试和 Compatibility Vector 通过。 + +游戏验收要求: + +- 完整 Python Suite; +- Rin Boundary 和 Source Scan; +- 无 Key 的真实进程 Session -> Observation -> Proposal -> Arbitration -> + Commit -> Snapshot -> Restore 检查; +- SDK 可用时运行 Ren'Py lint 和 compile。 + +## 13. 因锁屏延期的人工验证 + +- 在支持的桌面分辨率检查 Memory 与 Relationship Screen。 +- 跨多个章节在线游玩,确认召回台词自然。 +- 存档、创建不同未来、读档,确认 Memory 回退。 +- 请求期间停止 Rin,确认离线继续仍然响应。 +- 评估自主问题是否多样且不过度打扰。 +- 运行至少有三个竞争 NPC 的小型 Godot 或 Unity 场景。 + +## 14. 发布与回滚 + +- 现有 Session 不会自动获得 Living World Feature。 +- 游戏移除 Feature Identifier 后,新 Session 恢复 v0.4 语义。 +- 不通过 Migration 原地重写 JSONL 事件。 +- 新端点失败不会破坏现有 Session,因为所有写入都先验证,再原子追加一条 + Hash-Chained Event。 +- 游戏可随时禁用 Rin 并继续使用 Authored Content。 + +## 15. 停止条件 + +当全部自动检查通过、每阶段都有本地 Commit、`ai-galgame` 可以选择启用 +认知而不改变 Canon 剧情权威,并且只剩 GUI、跨引擎场景、长时间试玩和 +人工质量检查时,实施完成。 diff --git a/docs/model-policy.md b/docs/model-policy.md index 2ae98f4..7f14ff9 100644 --- a/docs/model-policy.md +++ b/docs/model-policy.md @@ -1,8 +1,11 @@ # Model Policy +[English](model-policy.md) | [简体中文](model-policy.zh-CN.md) + ## Enable -Rin 默认使用 `deterministic`,不会产生任何模型网络请求。在线模式需要显式配置: +Rin uses `deterministic` by default and makes no model network requests. +Online mode must be enabled explicitly: ```bash export RIN_POLICY=model @@ -12,49 +15,52 @@ export RIN_MODEL_API_KEY="..." rin serve ``` -Rin 使用 OpenAI-compatible `POST /chat/completions`。默认请求严格 `json_schema`;若供应商只支持 JSON Object: +Rin calls the OpenAI-compatible +`POST /chat/completions` endpoint. Requests use strict +`json_schema` by default. If a provider supports only JSON Object mode: ```bash export RIN_MODEL_RESPONSE_FORMAT=json_object ``` -也可设为 `none`,但返回文本仍必须是单个、严格、无额外字段的 JSON Object。 +The value may also be `none`, but returned text must still be one strict JSON +object with no extra fields. ## Environment | Variable | Default | Meaning | | --- | --- | --- | -| `RIN_POLICY` | `deterministic` | `deterministic` 或 `model` | -| `RIN_MODEL_BASE_URL` | - | OpenAI-compatible `/v1` 基地址 | -| `RIN_MODEL` | - | 供应商模型 ID | -| `RIN_MODEL_API_KEY` | - | 只从进程环境读取的 Bearer Key | -| `RIN_MODEL_RESPONSE_FORMAT` | `json_schema` | `json_schema`、`json_object`、`none` | -| `RIN_MODEL_ATTEMPT_TIMEOUT` | `15s` | 单次 HTTP 尝试上限 | -| `RIN_MODEL_TOTAL_TIMEOUT` | `25s` | 包含重试与退避的总上限 | -| `RIN_MODEL_MAX_ATTEMPTS` | `2` | 最大尝试次数,上限 5 | -| `RIN_MODEL_INITIAL_BACKOFF` | `150ms` | 初始退避 | -| `RIN_MODEL_MAX_BACKOFF` | `2s` | 退避及 Retry-After 上限 | -| `RIN_MODEL_BREAKER_FAILURES` | `3` | 打开熔断器前的失败调用数 | -| `RIN_MODEL_BREAKER_OPEN` | `20s` | 熔断开放时间 | -| `RIN_MODEL_CACHE_ENTRIES` | `256` | 内存 Draft 缓存条数 | -| `RIN_MODEL_CACHE_TTL` | `10m` | 相同 head hash 缓存寿命 | -| `RIN_JOB_WORKERS` | `2` | 异步 Proposal worker 数 | -| `RIN_JOB_QUEUE_SIZE` | `64` | 等待队列大小 | -| `RIN_JOB_MAX_RETAINED` | `512` | 包含完成项的最大 Job 数 | -| `RIN_JOB_TTL` | `30m` | 完成 Job 的内存保留时间 | -| `RIN_GENERATION_WORKERS` | `2` | 结构化生成 worker 数 | -| `RIN_GENERATION_QUEUE_SIZE` | `64` | 生成等待队列大小 | -| `RIN_GENERATION_MAX_RETAINED` | `512` | 包含完成项的最大生成 Job 数 | -| `RIN_GENERATION_JOB_TTL` | `30m` | 完成生成 Job 的内存保留时间 | -| `RIN_GENERATION_CACHE_ENTRIES` | `256` | 语义生成缓存条数 | -| `RIN_GENERATION_CACHE_TTL` | `30m` | 语义生成缓存寿命 | -| `RIN_GENERATION_MAX_OUTPUT_BYTES` | `524288` | 单个结构化结果最大字节数 | - -时长采用 Go duration,例如 `250ms`、`15s`、`2m`。 +| `RIN_POLICY` | `deterministic` | `deterministic` or `model` | +| `RIN_MODEL_BASE_URL` | - | OpenAI-compatible `/v1` base URL | +| `RIN_MODEL` | - | Provider model ID | +| `RIN_MODEL_API_KEY` | - | Bearer key read only from process environment | +| `RIN_MODEL_RESPONSE_FORMAT` | `json_schema` | `json_schema`, `json_object`, or `none` | +| `RIN_MODEL_ATTEMPT_TIMEOUT` | `15s` | Maximum time for one HTTP attempt | +| `RIN_MODEL_TOTAL_TIMEOUT` | `25s` | Total budget including retry and backoff | +| `RIN_MODEL_MAX_ATTEMPTS` | `2` | Maximum attempts, capped at 5 | +| `RIN_MODEL_INITIAL_BACKOFF` | `150ms` | Initial backoff | +| `RIN_MODEL_MAX_BACKOFF` | `2s` | Maximum backoff and Retry-After | +| `RIN_MODEL_BREAKER_FAILURES` | `3` | Failed calls before opening the breaker | +| `RIN_MODEL_BREAKER_OPEN` | `20s` | Circuit-breaker open duration | +| `RIN_MODEL_CACHE_ENTRIES` | `256` | In-memory draft-cache entries | +| `RIN_MODEL_CACHE_TTL` | `10m` | Cache lifetime for the same head hash | +| `RIN_JOB_WORKERS` | `2` | Asynchronous proposal workers | +| `RIN_JOB_QUEUE_SIZE` | `64` | Proposal waiting-queue capacity | +| `RIN_JOB_MAX_RETAINED` | `512` | Maximum jobs including completed entries | +| `RIN_JOB_TTL` | `30m` | In-memory lifetime for completed jobs | +| `RIN_GENERATION_WORKERS` | `2` | Structured-generation workers | +| `RIN_GENERATION_QUEUE_SIZE` | `64` | Generation waiting-queue capacity | +| `RIN_GENERATION_MAX_RETAINED` | `512` | Maximum generation jobs including completed entries | +| `RIN_GENERATION_JOB_TTL` | `30m` | In-memory lifetime for completed generation jobs | +| `RIN_GENERATION_CACHE_ENTRIES` | `256` | Semantic generation-cache entries | +| `RIN_GENERATION_CACHE_TTL` | `30m` | Semantic generation-cache lifetime | +| `RIN_GENERATION_MAX_OUTPUT_BYTES` | `524288` | Maximum bytes for one structured result | + +Durations use Go syntax such as `250ms`, `15s`, and `2m`. ## Local models -Loopback 地址允许 HTTP 和空 Key: +Loopback addresses may use HTTP and an empty key: ```bash export RIN_POLICY=model @@ -62,19 +68,25 @@ export RIN_MODEL_BASE_URL="http://127.0.0.1:11434/v1" export RIN_MODEL="local-model" ``` -非 loopback HTTP 默认拒绝。只有受控测试网络才应显式设置 `RIN_MODEL_ALLOW_INSECURE=true`。 +Non-loopback HTTP is rejected by default. Set +`RIN_MODEL_ALLOW_INSECURE=true` only on a controlled test network. ## Runtime behavior -1. 游戏提交异步 Proposal Job。 -2. 本地 Boundary Guard 先处理必须拒绝或重定向的情况。 -3. Cache 按当前 Session head hash 查找不可变 Draft。 -4. 未命中时构造最小、数据隔离的模型 Packet。 -5. Provider 在总预算内调用、重试或熔断。 -6. JSON Draft 经本地白名单验证。 -7. 任一步失败时使用确定性 Policy,`policy_source=deterministic-fallback`。 -8. Engine 再检查当前 revision/head hash;变化则 Job 为 `stale`。 - -模型只决定“建议执行哪个允许动作以及如何表达”,不能直接 commit,也不能改变世界状态。 - -结构化 Generation API 复用相同 Provider 与熔断预算,但它不使用确定性 Policy 回退。调用方必须自己准备离线文本,并在接受结果前执行领域 Schema 与 Canon 校验。 +1. The game submits an asynchronous proposal job. +2. The local boundary guard first handles mandatory refusal or redirection. +3. The cache looks up an immutable draft by the current session head hash. +4. On a miss, Rin builds a minimal, data-isolated model packet. +5. The provider calls, retries, or opens its breaker within the total budget. +6. The JSON draft receives local allowlist validation. +7. If any step fails, Rin uses the deterministic policy and reports + `policy_source=deterministic-fallback`. +8. The engine rechecks revision and head hash. If either changed, the job is + `stale`. + +The model decides only which allowed action to recommend and how to express +it. It cannot commit or change world state. + +The structured Generation API reuses the same provider and breaker budget, +but it has no deterministic-policy fallback. The caller must provide offline +text and validate domain schema and canon before accepting a result. diff --git a/docs/model-policy.zh-CN.md b/docs/model-policy.zh-CN.md new file mode 100644 index 0000000..46f1a36 --- /dev/null +++ b/docs/model-policy.zh-CN.md @@ -0,0 +1,82 @@ +# 模型策略 + +[English](model-policy.md) | [简体中文](model-policy.zh-CN.md) + +## 启用 + +Rin 默认使用 `deterministic`,不会产生任何模型网络请求。在线模式需要显式配置: + +```bash +export RIN_POLICY=model +export RIN_MODEL_BASE_URL="https://provider.example/v1" +export RIN_MODEL="your-model-id" +export RIN_MODEL_API_KEY="..." +rin serve +``` + +Rin 使用 OpenAI-compatible `POST /chat/completions`。默认请求严格 `json_schema`;若供应商只支持 JSON Object: + +```bash +export RIN_MODEL_RESPONSE_FORMAT=json_object +``` + +也可设为 `none`,但返回文本仍必须是单个、严格、无额外字段的 JSON Object。 + +## 环境变量 + +| 变量 | 默认值 | 含义 | +| --- | --- | --- | +| `RIN_POLICY` | `deterministic` | `deterministic` 或 `model` | +| `RIN_MODEL_BASE_URL` | - | OpenAI-compatible `/v1` 基地址 | +| `RIN_MODEL` | - | 供应商模型 ID | +| `RIN_MODEL_API_KEY` | - | 只从进程环境读取的 Bearer Key | +| `RIN_MODEL_RESPONSE_FORMAT` | `json_schema` | `json_schema`、`json_object`、`none` | +| `RIN_MODEL_ATTEMPT_TIMEOUT` | `15s` | 单次 HTTP 尝试上限 | +| `RIN_MODEL_TOTAL_TIMEOUT` | `25s` | 包含重试与退避的总上限 | +| `RIN_MODEL_MAX_ATTEMPTS` | `2` | 最大尝试次数,上限 5 | +| `RIN_MODEL_INITIAL_BACKOFF` | `150ms` | 初始退避 | +| `RIN_MODEL_MAX_BACKOFF` | `2s` | 退避及 Retry-After 上限 | +| `RIN_MODEL_BREAKER_FAILURES` | `3` | 打开熔断器前的失败调用数 | +| `RIN_MODEL_BREAKER_OPEN` | `20s` | 熔断开放时间 | +| `RIN_MODEL_CACHE_ENTRIES` | `256` | 内存 Draft 缓存条数 | +| `RIN_MODEL_CACHE_TTL` | `10m` | 相同 head hash 缓存寿命 | +| `RIN_JOB_WORKERS` | `2` | 异步 Proposal worker 数 | +| `RIN_JOB_QUEUE_SIZE` | `64` | 等待队列大小 | +| `RIN_JOB_MAX_RETAINED` | `512` | 包含完成项的最大 Job 数 | +| `RIN_JOB_TTL` | `30m` | 完成 Job 的内存保留时间 | +| `RIN_GENERATION_WORKERS` | `2` | 结构化生成 worker 数 | +| `RIN_GENERATION_QUEUE_SIZE` | `64` | 生成等待队列大小 | +| `RIN_GENERATION_MAX_RETAINED` | `512` | 包含完成项的最大生成 Job 数 | +| `RIN_GENERATION_JOB_TTL` | `30m` | 完成生成 Job 的内存保留时间 | +| `RIN_GENERATION_CACHE_ENTRIES` | `256` | 语义生成缓存条数 | +| `RIN_GENERATION_CACHE_TTL` | `30m` | 语义生成缓存寿命 | +| `RIN_GENERATION_MAX_OUTPUT_BYTES` | `524288` | 单个结构化结果最大字节数 | + +时长采用 Go duration,例如 `250ms`、`15s`、`2m`。 + +## 本地模型 + +Loopback 地址允许 HTTP 和空 Key: + +```bash +export RIN_POLICY=model +export RIN_MODEL_BASE_URL="http://127.0.0.1:11434/v1" +export RIN_MODEL="local-model" +``` + +非 loopback HTTP 默认拒绝。只有受控测试网络才应显式设置 `RIN_MODEL_ALLOW_INSECURE=true`。 + +## 运行时行为 + +1. 游戏提交异步 Proposal Job。 +2. 本地 Boundary Guard 先处理必须拒绝或重定向的情况。 +3. Cache 按当前 Session head hash 查找不可变 Draft。 +4. 未命中时构造最小、数据隔离的模型 Packet。 +5. Provider 在总预算内调用、重试或熔断。 +6. JSON Draft 经本地白名单验证。 +7. 任一步失败时使用确定性 Policy,`policy_source=deterministic-fallback`。 +8. Engine 再检查当前 revision/head hash;变化则 Job 为 `stale`。 + +模型只决定“建议执行哪个允许动作以及如何表达”,不能直接 commit,也不能改变世界状态。 + +结构化 Generation API 复用相同 Provider 与熔断预算,但它不使用确定性 Policy 回退。调用方必须自己准备离线文本,并在接受结果前执行领域 Schema 与 Canon 校验。 diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index 2066f9b..77ef682 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -1,14 +1,18 @@ # Rin Protocol v1 +[English](protocol-v1.md) | [简体中文](protocol-v1.zh-CN.md) + ## Envelope -请求使用 `Content-Type: application/json`,默认最大 32 MiB,以容纳完整存档快照;各类数组和字段仍有更小的结构上限。成功响应: +Requests use `Content-Type: application/json`. The default maximum body is +32 MiB so a complete save snapshot can fit; individual fields and arrays have +smaller structural limits. A successful response is: ```json {"ok":true,"data":{}} ``` -失败响应: +An error response is: ```json { @@ -21,13 +25,16 @@ } ``` -除无请求体的 Job 查询与取消接口外,每个 JSON 请求体都必须包含: +Except for bodyless job query and cancellation endpoints, every JSON request +body must contain: ```json {"protocol_version":"rin.protocol/v1"} ``` -ID 长度为 1–96,只允许字母、数字、`.`、`_`、`-`,从源头阻止路径穿越并保持 Windows 文件名兼容。 +IDs are 1 to 96 characters and may contain only letters, digits, `.`, `_`, +and `-`. This prevents path traversal at the source and remains compatible +with Windows file names. ## Create session @@ -78,17 +85,24 @@ ID 长度为 1–96,只允许字母、数字、`.`、`_`、`-`,从源头阻 } ``` -Binding 防止另一版本剧情或 Mod 的状态被静默恢复到当前游戏。 +The binding prevents state from another story or mod version from being +silently restored into the current game. -`features` 是新会话显式选择的兼容开关,可用值由 `/health` 的 `features` 返回: +`features` contains compatibility switches explicitly selected for a new +session. `/health` returns the supported values: -- `memory-archive-v1`:将超出详细窗口的记忆压缩为确定性分层摘要; -- `belief-conflicts-v1`:保留角色私有的互相矛盾说法及来源; -- `goal-candidates-v1`:允许 Policy 从本次请求给出的候选小目标中提出一个; -- `actor-activity-v1`:启用区域和 awake/dormant 生命周期; -- `arbitration-v1`:启用 world revision、多角色仲裁与原子批量 commit。 +- `memory-archive-v1`: compress memories outside the detailed window into + deterministic hierarchical summaries; +- `belief-conflicts-v1`: retain actor-private conflicting claims and their + sources; +- `goal-candidates-v1`: allow a policy to propose one bounded subgoal supplied + by the current request; +- `actor-activity-v1`: enable region and awake/dormant lifecycle; +- `arbitration-v1`: enable world revision, multi-actor arbitration, and atomic + batch commit. -省略该字段的旧 Session 保持 v0.4 行为,重放 hash 和 JSON 形状不变。 +Legacy sessions that omit this field keep v0.4 behavior, including replay +hashes and JSON shape. ## Observe @@ -120,7 +134,9 @@ Binding 防止另一版本剧情或 Mod 的状态被静默恢复到当前游戏 } ``` -只有 `observer_ids` 中的角色获得这段记忆。Fact 若带 `visibility`,只写入名单中的观察者,避免 NPC 知道未见过的事情。 +Only actors in `observer_ids` receive the memory. If a fact has a +`visibility` list, it is written only to observers on that list, preventing +NPCs from learning events they did not perceive. ## Propose @@ -153,28 +169,36 @@ Binding 防止另一版本剧情或 Mod 的状态被静默恢复到当前游戏 } ``` -返回的 Proposal 带: +The returned proposal includes: -- `based_on_revision` 和 `based_on_head_hash`:生成依据。 -- `action`:原样取自游戏候选动作,Policy 不能添权。 -- `recalled_memory_ids`、`goal_id`:可审计依据。 -- `rationale`:给 UI 使用的一句角色化说明,不是模型隐藏推理。 -- `status: pending`:必须 commit 才生效。 -- `policy_source`:`model`、`model-cache`、`boundary-guard`、`deterministic-fallback` 或离线来源。 +- `based_on_revision` and `based_on_head_hash`: state used to generate it; +- `action`: copied from the game's candidate actions; the policy cannot grant + new authority; +- `recalled_memory_ids` and `goal_id`: auditable evidence; +- `rationale`: one character-facing sentence for UI, not hidden model + reasoning; +- `status: pending`: the proposal has no effect until committed; +- `policy_source`: `model`, `model-cache`, `boundary-guard`, + `deterministic-fallback`, or an offline source. -Policy 运行期间不会持有会话锁。如果新观察先到达,调用返回 `state_changed`;客户端应以新的 `request_id` 重试。 +Policy execution does not hold the session lock. If a new observation arrives +first, the call returns `state_changed`; retry with a new `request_id`. -候选目标只在启用 `goal-candidates-v1` 时允许,最多 8 个。Policy 不能凭空创建目标,只能选已有目标或本次候选目标;候选目标随 Proposal 返回,只有 Proposal 被接受后才进入 Actor 状态,拒绝或过期不会留下目标。 +Candidate goals require `goal-candidates-v1` and are limited to eight. A +policy cannot invent a goal; it may select an existing goal or one supplied by +this request. A candidate goal travels with the proposal and enters actor +state only after acceptance. Rejection or staleness leaves no goal behind. -在线模型不建议由游戏主线程直接调用本端点,应使用异步 Job API。 +Games using an online model should not call this synchronous endpoint from +their main thread. Use the asynchronous job API. ## Async proposal jobs -提交使用与 Propose 相同的请求体: +Submission uses the same body as Propose: `POST /v1/jobs/propose` -服务立即返回 `202 Accepted`: +The service immediately returns `202 Accepted`: ```json { @@ -188,21 +212,27 @@ Policy 运行期间不会持有会话锁。如果新观察先到达,调用返 } ``` -查询不需要请求体: +Query requires no body: `GET /v1/jobs/{job_id}` -状态为 `queued`、`running`、`succeeded`、`failed`、`stale` 或 `canceled`。成功时 `proposal` 字段包含正常 ActionProposal;失败时只返回安全错误码,不包含供应商正文。 +Status is `queued`, `running`, `succeeded`, `failed`, `stale`, or `canceled`. +On success, `proposal` contains a normal ActionProposal. Failure returns only +a safe error code, never a provider response body. -取消: +Cancel with: `DELETE /v1/jobs/{job_id}` -相同 Session 和 `request_id` 的重复提交返回同一个 Job。若 payload 不同则返回 `request_id_conflict`。Job 队列有界,满载时返回 `429 jobs_queue_full`。 +Repeated submissions with the same session and `request_id` return the same +job. A different payload returns `request_id_conflict`. The queue is bounded; +when full it returns `429 jobs_queue_full`. ## Structured generation jobs -结构化生成用于受限对白、场景、任务文本或结局呈现。它不读取或修改 Session,不产生世界事实,也不能替代 Proposal / Commit 权威边界。 +Structured generation is for constrained dialogue, scenes, quest text, or +ending presentation. It neither reads nor modifies sessions, creates no world +facts, and cannot replace the Proposal/Commit authority boundary. `POST /v1/generation/jobs` @@ -222,18 +252,30 @@ Policy 运行期间不会持有会话锁。如果新观察先到达,调用返 } ``` -`kind` 允许 `director`、`story`、`scene`、`decision`、`ending`、`free-response`、`storylet-selection`。消息为 1–8 条,每条和总字符数有界;`context_hash` 是调用方对语义上下文生成的 SHA-256 标识,用于诊断和一致性检查。 +Allowed `kind` values are `director`, `story`, `scene`, `decision`, `ending`, +`free-response`, and `storylet-selection`. There must be 1 to 8 messages, with +per-message and total character limits. `context_hash` is a caller-generated +SHA-256 identifier for semantic context, diagnostics, and consistency checks. -提交立即返回 `202 Accepted`。查询与取消: +Submission immediately returns `202 Accepted`. Query and cancel: ```text GET /v1/generation/jobs/{job_id} DELETE /v1/generation/jobs/{job_id} ``` -状态为 `queued`、`running`、`succeeded`、`failed` 或 `canceled`。成功结果包含 JSON Object 原文以及模型名、finish reason、token usage、`cache_hit` 等有界元数据。Rin 会再次解析输出,数组、纯文本、空内容、非法 UTF-8、NUL 和超出大小限制的内容均失败。 +Status is `queued`, `running`, `succeeded`, `failed`, or `canceled`. A +successful result contains the raw JSON object plus bounded metadata such as +model name, finish reason, token usage, and `cache_hit`. Rin parses output +again; arrays, plain text, empty content, invalid UTF-8, NUL, and oversized +content fail. -同一 `request_id` 与相同 payload 返回同一 Job;相同语义但不同 ID 可以命中短期缓存。Generation 任务不写入事件日志,游戏应先按自己的内容契约验证结果,再决定是否接受到 Canon。供应商失败不会自动生成替代剧情,调用方必须提供离线内容。 +The same `request_id` and payload return the same job. Semantically identical +requests with different IDs may hit the short-lived cache. Generation jobs do +not enter the event log. A game must validate the result against its own +content contract before accepting it into canon. Provider failure never +generates replacement story automatically; callers must supply offline +content. ## Commit @@ -255,11 +297,14 @@ DELETE /v1/generation/jobs/{job_id} } ``` -接受提案会记录行动结果、更新调度、标记记忆被召回,并让关联目标自动前进 1。拒绝提案不会修改角色记忆、事实和目标。 +Accepting a proposal records the action outcome, updates scheduling, marks +recalled memories, and advances the associated goal by one. Rejecting a +proposal does not modify actor memories, facts, or goals. ## Living-world coordination -启用 `actor-activity-v1` 后,游戏在区域载入、卸载或模拟层级变化时调用: +With `actor-activity-v1`, the game calls this endpoint when regions load, +unload, or change simulation level: `POST /v1/session/activity` @@ -276,9 +321,12 @@ DELETE /v1/generation/jobs/{job_id} } ``` -`state` 只能为 `awake` 或 `dormant`。Dormant 角色不会出现在 scheduler 中,也不能 propose。`/v1/scheduler/due` 可增加 `region_ids` 过滤。 +`state` is either `awake` or `dormant`. Dormant actors do not appear in the +scheduler and cannot propose. `/v1/scheduler/due` accepts an optional +`region_ids` filter. -启用 `arbitration-v1` 后,同一 world revision 可以为多个角色分别产生 Proposal,再调用 `POST /v1/world/arbitrate`: +With `arbitration-v1`, several actors may produce proposals at the same world +revision before calling `POST /v1/world/arbitrate`: ```json { @@ -291,7 +339,12 @@ DELETE /v1/generation/jobs/{job_id} } ``` -结果以目标优先级、tick、actor ID、proposal ID 确定性排序,给出 `selected` 或 `deferred`。仲裁是建议记录,不直接改变游戏世界。游戏应用选中动作后,可用 `POST /v1/action/commit-batch` 一次提交每个角色最多一个结果;任何一项失效都会拒绝整个批次,不产生部分修改。 +Results are deterministically ordered by target priority, tick, actor ID, and +proposal ID, then marked `selected` or `deferred`. Arbitration records a +recommendation and never changes the game world directly. After applying +selected actions, the game may use `POST /v1/action/commit-batch` to commit at +most one result per actor. If any entry is stale or invalid, the entire batch +is rejected without partial mutation. ## Scheduler @@ -307,17 +360,18 @@ DELETE /v1/generation/jobs/{job_id} } ``` -按 `next_think_tick` 和 actor ID 稳定排序,便于回合制、区域制和时间片游戏使用。 +Results are stably sorted by `next_think_tick` and actor ID for turn-based, +regional, and time-sliced games. ## Snapshot and restore -Snapshot 请求和 Session State 请求结构相同: +Snapshot and Session State requests use the same shape: ```json {"protocol_version":"rin.protocol/v1","session_id":"playthrough-1"} ``` -Restore: +Restore: ```json { @@ -328,43 +382,53 @@ Restore: } ``` -Restore 拒绝 hash 错误、Session ID 不同或 Binding 不同的快照,并清空 pending Proposal。 +Restore rejects snapshots with an invalid hash, different session ID, or +different binding, and clears pending proposals. -当游戏反复载入同一存档时,Restore `request_id` 应同时绑定目标 Snapshot hash 和 Sidecar 当前 head hash。这样一次网络重试仍然幂等,而从后来状态再次读档会产生新的 Restore 事件并真正回退。 +When a game repeatedly loads the same save, the restore `request_id` should +bind both the target snapshot hash and the sidecar's current head hash. A +network retry remains idempotent, while loading the old save again from a +later state creates a new restore event and performs a real rollback. ## Timeline and replay -`POST /v1/session/timeline` 返回分页的事件类型、revision、hash、请求 ID、角色/实体 ID 和状态,不返回 Observation summary/quote、Commit outcome、Prompt 或模型正文: +`POST /v1/session/timeline` returns paginated event type, revision, hash, +request ID, actor/entity IDs, and status. It never returns observation +summary/quote, commit outcome, prompt, or model body: ```json {"protocol_version":"rin.protocol/v1","session_id":"playthrough-1","after_revision":0,"limit":50} ``` -响应中的 `next_after_revision` 可用于下一页,`limit` 为 1–256。 +Use `next_after_revision` for the next page. `limit` is 1 to 256. -`POST /v1/session/replay` 使用正常 reducer 和 hash-chain 校验重建指定 revision,并返回不落盘的 Snapshot: +`POST /v1/session/replay` runs the normal reducer and hash-chain verification +to rebuild a selected revision, then returns an in-memory snapshot: ```json {"protocol_version":"rin.protocol/v1","session_id":"playthrough-1","revision":42} ``` -Replay 会包含该 revision 已存在的角色记忆和剧情状态,因此沿用 Session API 的鉴权边界,不能当作脱敏日志接口。 +Replay includes actor memories and story state present at that revision, so +it keeps the Session API authentication boundary and is not a redacted log +endpoint. ## Common errors | HTTP | Code | Meaning | | --- | --- | --- | -| `400` | `invalid_json` / `invalid_request` | JSON 或字段契约错误 | -| `401` | `unauthorized` | Bearer Token 缺失或错误 | -| `404` | `session_not_found` / `unknown_actor` | 实体不存在 | -| `404` | `revision_not_found` | Replay revision 不存在 | -| `409` | `state_changed` / `proposal_stale` | 基础状态已改变 | -| `409` | `actor_not_due` | 尚未到该角色的思考 tick | -| `422` | `no_safe_action` | 边界触发但游戏没提供安全动作 | -| `413` | `body_too_large` | 请求超过大小限制 | -| `429` | `jobs_queue_full` / `jobs_capacity` | 异步队列或保留区已满 | -| `429` | `generation_queue_full` / `generation_capacity` | 生成队列或保留区已满 | -| `503` | `jobs_unavailable` / `jobs_closed` | Proposal Job 服务未启用或正在关闭 | -| `503` | `generation_unavailable` / `generation_closed` | 生成服务未启用或正在关闭 | - -服务从不把事件 payload、Token、内部路径或模型响应原文放入错误消息。 +| `400` | `invalid_json` / `invalid_request` | JSON or field-contract error | +| `401` | `unauthorized` | Missing or incorrect Bearer token | +| `404` | `session_not_found` / `unknown_actor` | Entity does not exist | +| `404` | `revision_not_found` | Replay revision does not exist | +| `409` | `state_changed` / `proposal_stale` | Base state changed | +| `409` | `actor_not_due` | Actor has not reached its thinking tick | +| `422` | `no_safe_action` | Boundary triggered without a safe candidate | +| `413` | `body_too_large` | Request exceeds the body limit | +| `429` | `jobs_queue_full` / `jobs_capacity` | Proposal queue or retention is full | +| `429` | `generation_queue_full` / `generation_capacity` | Generation queue or retention is full | +| `503` | `jobs_unavailable` / `jobs_closed` | Proposal jobs are disabled or closing | +| `503` | `generation_unavailable` / `generation_closed` | Generation is disabled or closing | + +The service never places event payloads, tokens, internal paths, or raw model +responses in error messages. diff --git a/docs/protocol-v1.zh-CN.md b/docs/protocol-v1.zh-CN.md new file mode 100644 index 0000000..9a7a3bc --- /dev/null +++ b/docs/protocol-v1.zh-CN.md @@ -0,0 +1,372 @@ +# Rin Protocol v1 + +[English](protocol-v1.md) | [简体中文](protocol-v1.zh-CN.md) + +## Envelope 封装 + +请求使用 `Content-Type: application/json`,默认最大 32 MiB,以容纳完整存档快照;各类数组和字段仍有更小的结构上限。成功响应: + +```json +{"ok":true,"data":{}} +``` + +失败响应: + +```json +{ + "ok": false, + "error": { + "code": "invalid_request", + "message": "must be between 1 and 5", + "field": "importance" + } +} +``` + +除无请求体的 Job 查询与取消接口外,每个 JSON 请求体都必须包含: + +```json +{"protocol_version":"rin.protocol/v1"} +``` + +ID 长度为 1–96,只允许字母、数字、`.`、`_`、`-`,从源头阻止路径穿越并保持 Windows 文件名兼容。 + +## 创建会话 + +`POST /v1/session/create` + +```json +{ + "protocol_version": "rin.protocol/v1", + "request_id": "create.playthrough-1", + "session_id": "playthrough-1", + "binding": { + "game_id": "my-game", + "content_id": "base-story", + "content_version": "1.0.0", + "content_hash": "sha256:..." + }, + "seed": 42, + "features": ["memory-archive-v1", "belief-conflicts-v1"], + "actors": [ + { + "id": "npc.mira", + "kind": "npc", + "display_name": "Mira", + "traits": ["curious", "careful"], + "boundaries": [ + { + "id": "boundary.privacy", + "description": "Do not reveal private letters.", + "trigger_tags": ["private"], + "response": "refuse" + } + ], + "goals": [ + { + "id": "goal.connect", + "description": "Build trust through specific actions.", + "priority": 4, + "preferred_actions": ["talk"], + "progress": 0, + "target_progress": 3, + "status": "active" + } + ], + "think_every_ticks": 5, + "enabled": true + } + ] +} +``` + +Binding 防止另一版本剧情或 Mod 的状态被静默恢复到当前游戏。 + +`features` 是新会话显式选择的兼容开关,可用值由 `/health` 的 `features` 返回: + +- `memory-archive-v1`:将超出详细窗口的记忆压缩为确定性分层摘要; +- `belief-conflicts-v1`:保留角色私有的互相矛盾说法及来源; +- `goal-candidates-v1`:允许 Policy 从本次请求给出的候选小目标中提出一个; +- `actor-activity-v1`:启用区域和 awake/dormant 生命周期; +- `arbitration-v1`:启用 world revision、多角色仲裁与原子批量 commit。 + +省略该字段的旧 Session 保持 v0.4 行为,重放 hash 和 JSON 形状不变。 + +## 提交观察 + +`POST /v1/session/observe` + +```json +{ + "protocol_version": "rin.protocol/v1", + "session_id": "playthrough-1", + "request_id": "observe.event-18", + "event_id": "event-18", + "tick": 18, + "observer_ids": ["npc.mira"], + "source": "game", + "kind": "dialogue", + "summary": "The player waited instead of demanding an answer.", + "quote": "Take your time.", + "tags": ["conversation", "trust"], + "importance": 4, + "facts": [ + { + "subject_id": "player", + "predicate": "respected_boundary", + "object": "event-18", + "visibility": ["npc.mira"], + "confidence": 100 + } + ] +} +``` + +只有 `observer_ids` 中的角色获得这段记忆。Fact 若带 `visibility`,只写入名单中的观察者,避免 NPC 知道未见过的事情。 + +## 生成提案 + +`POST /v1/agent/propose` + +```json +{ + "protocol_version": "rin.protocol/v1", + "session_id": "playthrough-1", + "request_id": "propose.turn-19.mira", + "actor_id": "npc.mira", + "tick": 19, + "intent": "Choose how to respond.", + "tags": ["conversation"], + "candidate_actions": [ + {"id":"talk","kind":"dialogue","description":"ask one honest question"}, + {"id":"refuse","kind":"refuse","description":"protect a private boundary"}, + {"id":"wait","kind":"wait","description":"stay silent for now"} + ], + "candidate_goals": [ + { + "id": "goal.ask-about-photo", + "description": "Find a calm moment to ask about the old photograph.", + "priority": 2, + "progress": 0, + "target_progress": 2, + "status": "active" + } + ] +} +``` + +返回的 Proposal 带: + +- `based_on_revision` 和 `based_on_head_hash`:生成依据。 +- `action`:原样取自游戏候选动作,Policy 不能添权。 +- `recalled_memory_ids`、`goal_id`:可审计依据。 +- `rationale`:给 UI 使用的一句角色化说明,不是模型隐藏推理。 +- `status: pending`:必须 commit 才生效。 +- `policy_source`:`model`、`model-cache`、`boundary-guard`、`deterministic-fallback` 或离线来源。 + +Policy 运行期间不会持有会话锁。如果新观察先到达,调用返回 `state_changed`;客户端应以新的 `request_id` 重试。 + +候选目标只在启用 `goal-candidates-v1` 时允许,最多 8 个。Policy 不能凭空创建目标,只能选已有目标或本次候选目标;候选目标随 Proposal 返回,只有 Proposal 被接受后才进入 Actor 状态,拒绝或过期不会留下目标。 + +在线模型不建议由游戏主线程直接调用本端点,应使用异步 Job API。 + +## 异步提案任务 + +提交使用与 Propose 相同的请求体: + +`POST /v1/jobs/propose` + +服务立即返回 `202 Accepted`: + +```json +{ + "ok": true, + "data": { + "protocol_version": "rin.protocol/v1", + "job_id": "job....", + "status": "queued", + "duplicate": false + } +} +``` + +查询不需要请求体: + +`GET /v1/jobs/{job_id}` + +状态为 `queued`、`running`、`succeeded`、`failed`、`stale` 或 `canceled`。成功时 `proposal` 字段包含正常 ActionProposal;失败时只返回安全错误码,不包含供应商正文。 + +取消: + +`DELETE /v1/jobs/{job_id}` + +相同 Session 和 `request_id` 的重复提交返回同一个 Job。若 payload 不同则返回 `request_id_conflict`。Job 队列有界,满载时返回 `429 jobs_queue_full`。 + +## 结构化生成任务 + +结构化生成用于受限对白、场景、任务文本或结局呈现。它不读取或修改 Session,不产生世界事实,也不能替代 Proposal / Commit 权威边界。 + +`POST /v1/generation/jobs` + +```json +{ + "protocol_version": "rin.protocol/v1", + "request_id": "generation.scene-12", + "kind": "scene", + "context_hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "messages": [ + {"role":"system","content":"Return one bounded scene JSON object."}, + {"role":"user","content":"{\"storylet_id\":\"scene-12\"}"} + ], + "temperature": 0.6, + "max_tokens": 1024, + "response_format": "json_object" +} +``` + +`kind` 允许 `director`、`story`、`scene`、`decision`、`ending`、`free-response`、`storylet-selection`。消息为 1–8 条,每条和总字符数有界;`context_hash` 是调用方对语义上下文生成的 SHA-256 标识,用于诊断和一致性检查。 + +提交立即返回 `202 Accepted`。查询与取消: + +```text +GET /v1/generation/jobs/{job_id} +DELETE /v1/generation/jobs/{job_id} +``` + +状态为 `queued`、`running`、`succeeded`、`failed` 或 `canceled`。成功结果包含 JSON Object 原文以及模型名、finish reason、token usage、`cache_hit` 等有界元数据。Rin 会再次解析输出,数组、纯文本、空内容、非法 UTF-8、NUL 和超出大小限制的内容均失败。 + +同一 `request_id` 与相同 payload 返回同一 Job;相同语义但不同 ID 可以命中短期缓存。Generation 任务不写入事件日志,游戏应先按自己的内容契约验证结果,再决定是否接受到 Canon。供应商失败不会自动生成替代剧情,调用方必须提供离线内容。 + +## 提交结果 + +`POST /v1/action/commit` + +```json +{ + "protocol_version": "rin.protocol/v1", + "session_id": "playthrough-1", + "request_id": "commit.turn-19.mira", + "proposal_id": "proposal....", + "event_id": "event-19", + "tick": 19, + "accepted": true, + "outcome": "Mira asked what the player wanted remembered.", + "tags": ["conversation"], + "facts": [], + "goal_updates": [] +} +``` + +接受提案会记录行动结果、更新调度、标记记忆被召回,并让关联目标自动前进 1。拒绝提案不会修改角色记忆、事实和目标。 + +## Living World 协调 + +启用 `actor-activity-v1` 后,游戏在区域载入、卸载或模拟层级变化时调用: + +`POST /v1/session/activity` + +```json +{ + "protocol_version": "rin.protocol/v1", + "session_id": "playthrough-1", + "request_id": "activity.school-day-2", + "tick": 80, + "updates": [ + {"actor_id":"npc.mira","region_id":"school.roof","state":"awake"}, + {"actor_id":"npc.teacher","region_id":"school.office","state":"dormant"} + ] +} +``` + +`state` 只能为 `awake` 或 `dormant`。Dormant 角色不会出现在 scheduler 中,也不能 propose。`/v1/scheduler/due` 可增加 `region_ids` 过滤。 + +启用 `arbitration-v1` 后,同一 world revision 可以为多个角色分别产生 Proposal,再调用 `POST /v1/world/arbitrate`: + +```json +{ + "protocol_version": "rin.protocol/v1", + "session_id": "playthrough-1", + "request_id": "arbitrate.turn-81", + "tick": 81, + "proposal_ids": ["proposal.mira", "proposal.teacher"], + "exclusive_target_ids": ["prop.camera-1"] +} +``` + +结果以目标优先级、tick、actor ID、proposal ID 确定性排序,给出 `selected` 或 `deferred`。仲裁是建议记录,不直接改变游戏世界。游戏应用选中动作后,可用 `POST /v1/action/commit-batch` 一次提交每个角色最多一个结果;任何一项失效都会拒绝整个批次,不产生部分修改。 + +## 调度器 + +`POST /v1/scheduler/due` + +```json +{ + "protocol_version": "rin.protocol/v1", + "session_id": "playthrough-1", + "tick": 24, + "limit": 16, + "region_ids": ["school.roof"] +} +``` + +按 `next_think_tick` 和 actor ID 稳定排序,便于回合制、区域制和时间片游戏使用。 + +## Snapshot 与 Restore + +Snapshot 请求和 Session State 请求结构相同: + +```json +{"protocol_version":"rin.protocol/v1","session_id":"playthrough-1"} +``` + +Restore: + +```json +{ + "protocol_version": "rin.protocol/v1", + "session_id": "playthrough-1", + "request_id": "restore.save-slot-2", + "snapshot": {"protocol_version":"rin.protocol/v1","state_hash":"...","state":{}} +} +``` + +Restore 拒绝 hash 错误、Session ID 不同或 Binding 不同的快照,并清空 pending Proposal。 + +当游戏反复载入同一存档时,Restore `request_id` 应同时绑定目标 Snapshot hash 和 Sidecar 当前 head hash。这样一次网络重试仍然幂等,而从后来状态再次读档会产生新的 Restore 事件并真正回退。 + +## Timeline 与 Replay + +`POST /v1/session/timeline` 返回分页的事件类型、revision、hash、请求 ID、角色/实体 ID 和状态,不返回 Observation summary/quote、Commit outcome、Prompt 或模型正文: + +```json +{"protocol_version":"rin.protocol/v1","session_id":"playthrough-1","after_revision":0,"limit":50} +``` + +响应中的 `next_after_revision` 可用于下一页,`limit` 为 1–256。 + +`POST /v1/session/replay` 使用正常 reducer 和 hash-chain 校验重建指定 revision,并返回不落盘的 Snapshot: + +```json +{"protocol_version":"rin.protocol/v1","session_id":"playthrough-1","revision":42} +``` + +Replay 会包含该 revision 已存在的角色记忆和剧情状态,因此沿用 Session API 的鉴权边界,不能当作脱敏日志接口。 + +## 常见错误 + +| HTTP | 错误码 | 含义 | +| --- | --- | --- | +| `400` | `invalid_json` / `invalid_request` | JSON 或字段契约错误 | +| `401` | `unauthorized` | Bearer Token 缺失或错误 | +| `404` | `session_not_found` / `unknown_actor` | 实体不存在 | +| `404` | `revision_not_found` | Replay revision 不存在 | +| `409` | `state_changed` / `proposal_stale` | 基础状态已改变 | +| `409` | `actor_not_due` | 尚未到该角色的思考 tick | +| `422` | `no_safe_action` | 边界触发但游戏没提供安全动作 | +| `413` | `body_too_large` | 请求超过大小限制 | +| `429` | `jobs_queue_full` / `jobs_capacity` | 异步队列或保留区已满 | +| `429` | `generation_queue_full` / `generation_capacity` | 生成队列或保留区已满 | +| `503` | `jobs_unavailable` / `jobs_closed` | Proposal Job 服务未启用或正在关闭 | +| `503` | `generation_unavailable` / `generation_closed` | 生成服务未启用或正在关闭 | + +服务从不把事件 payload、Token、内部路径或模型响应原文放入错误消息。 diff --git a/docs/rpg-events.md b/docs/rpg-events.md index f5c7789..b975906 100644 --- a/docs/rpg-events.md +++ b/docs/rpg-events.md @@ -1,5 +1,7 @@ # RPG Event Conventions +[English](rpg-events.md) | [简体中文](rpg-events.zh-CN.md) + These conventions let RPGs, simulations, tactics games, and open-area NPC systems use Rin without giving an agent world authority. ## Identity and ticks diff --git a/docs/rpg-events.zh-CN.md b/docs/rpg-events.zh-CN.md new file mode 100644 index 0000000..bc0a700 --- /dev/null +++ b/docs/rpg-events.zh-CN.md @@ -0,0 +1,108 @@ +# RPG 事件约定 + +[English](rpg-events.md) | [简体中文](rpg-events.zh-CN.md) + +这些约定让 RPG、模拟、战术游戏和开放区域 NPC 系统使用 Rin,同时不把 +世界权威交给 Agent。 + +## 身份与 Tick + +- 将 `session_id` 绑定到一个周目和一个内容/Mod 指纹。 +- 使用 `npc.harbor.blacksmith` 这类稳定 Actor ID,不要把显示名称当作身份。 +- 在游戏拥有的时钟上推进 `tick`,例如回合、分钟、日程槽或模拟步;不要用 + 渲染帧。 +- 根据玩法重要性设置 `think_every_ticks`。远处或未加载的 NPC 应休眠, + 不应轮询模型。 +- 传送、重新加载、回滚或任务状态重写都属于新的 Observation/revision, + 会让基于旧 head hash 的 Proposal 失效。 + +## 区域与可见性 + +区域成员关系由游戏决定。推荐的 Observation kind 和 tag: + +| 事件 | `kind` | 示例 tag | +| --- | --- | --- | +| Actor 进入已加载区域 | `region-enter` | `region.harbor`, `visibility.direct` | +| Actor 离开已加载区域 | `region-exit` | `region.harbor` | +| 可见的世界动作 | `world-action` | `visibility.direct`, `combat` | +| 听见但未看见的事件 | `sound` | `visibility.heard`, `region.market` | +| 对白 | `dialogue` | `conversation`, `speaker.player` | +| 私密发现 | `discovery` | `visibility.private`, `quest.relic` | + +只有位于 `observer_ids` 的 Actor 才会获得记忆。距离近并不等于可观察; +构造列表前应考虑墙壁、潜行、失聪、语言、无线电频道、过场和暂时失能。 + +Fact 使用自己的 `visibility` 白名单。这样,听到声音的角色不会同时知道 +隐藏攻击者的身份。不要发送带有“hidden”标签的删减秘密文本;在事实真正 +可观察前应完全省略。 + +## 任务与 Quest + +任务状态保留在游戏中。Rin 可以记住有限事实,例如: + +```json +{ + "subject_id": "quest.repair-bridge", + "predicate": "stage", + "object": "materials-delivered", + "visibility": ["npc.harbor.foreman"], + "confidence": 100, + "source_event_id": "event.quest.repair-bridge.12" +} +``` + +任务变化时发送 Observation,然后只提供当前阶段合法的动作。 +`offer-next-step` 这类 Proposal 只是对白意图;是否推进任务、发放奖励或 +修改背包仍由游戏决定。 + +传闻应作为低置信度并带来源事件的 Fact。两个角色意见冲突时保留两条 +Observation,不要悄悄把其中一条提升为世界真相。 + +## 候选动作 + +动作应描述游戏能够验证和应用的能力: + +- `dialogue`:说话、询问、警告、议价、拒绝; +- `move`:前往当前可达目标; +- `interact`:使用可用物体或工作台; +- `combat`:防御、撤退、使用已装备能力; +- `social`:邀请、解散、请求帮助; +- `wait`、`redirect`、`refuse`:安全且不升级冲突的结果。 + +把目标 ID 和有界参数放进动作 spec。不要提供当前导航网格、任务阶段、 +冷却、背包、同意状态或战斗规则会拒绝的动作。Rin 的白名单是安全边界, +不只是 Prompt 提示。 + +高影响动作应提供 `request-trade` 或 `attempt-attack` 这类意图;权威游戏 +系统在 Proposal 验证后计算价格、命中、伤害、所有权和后果。 + +## 应用与提交 + +1. 若目标移动、死亡、离开可见范围、改变阵营或失去所需资源,拒绝过期提案。 +2. 通过正常玩法系统应用选定动作。 +3. Commit 实际观察到的结果,包括失败或拒绝。 +4. 只向确实感知结果的 Actor 发送后续 Observation。 + +被拒绝的 Proposal 仍是有价值的角色历史。若动作作为角色意图仍然有效, +只是被游戏规则拒绝,应以 `accepted=false` Commit。不要 Commit 适配器 +本地的 `offline.*` Proposal;之后通过 `observe` 报告它们的实际结果。 + +## 边界与玩家安全 + +模型侧意图永远不能覆盖本地的同意、骚扰、购买、不可逆任务选择、PvP、 +账户操作或用户生成内容规则。任何可能触发边界的请求都应包含安全的 +`refuse`、`redirect` 或 `wait` 动作。 + +NPC 可以拒绝、误解、延迟或追求小目标,但不能创建新的合法目标、泄露 +未观察事实、消费货币或重写其他 Actor 的状态。 + +## 扩展到大量 Actor + +- 在模拟 tick 或区域激活时查询 `/v1/scheduler/due`,不要每帧查询。 +- 只为已加载且相关的 Actor 提交 Job,并在游戏侧和 Rin 侧都限制并发。 +- 若所有列出的 Observer 都感知了同一结果,把世界事件合成一条简洁 + Observation。 +- 重要具名 NPC 使用较高频率;人群使用确定性策略。 +- 在游戏存档边界创建 Snapshot;仅在 game/content binding 匹配时 Restore。 + +这样,模型成本与有意义的决定数量成正比,而不是与人口或帧率成正比。 diff --git a/docs/sdk-and-mods.md b/docs/sdk-and-mods.md new file mode 100644 index 0000000..8cf8968 --- /dev/null +++ b/docs/sdk-and-mods.md @@ -0,0 +1,116 @@ +# SDK and mod integration kits + +[English](sdk-and-mods.md) | [简体中文](sdk-and-mods.zh-CN.md) + +Rin remains a game-neutral sidecar. These SDKs remove repetitive HTTP, +timeout, envelope, and job-polling code; they do not move world authority into +the sidecar or model. + +## Support matrix + +| Language | Minimum runtime | Delivery model | JSON boundary | Typical host | +| --- | --- | --- | --- | --- | +| Python | 3.9 | synchronous | standard library | Ren'Py, tools, servers | +| JavaScript | Node 18 / Fetch host | Promise | built in | Electron, web bridges, Node | +| C# | .NET 6 | Task | `System.Text.Json` | BepInEx 6, modern .NET games | +| Java | 17 | `CompletableFuture` | injected `JsonCodec` | Fabric, JVM servers | +| Lua | 5.1 | callback | injected codec and transport | Luanti, embedded Lua engines | + +Every implementation covers the 20 routes in +[`sdk/conformance/routes.json`](../sdk/conformance/routes.json). Python and +JavaScript have no runtime dependencies. C# uses only framework APIs. Java +reuses the host's JSON library through a two-method codec. Lua injects all +host-specific services because Lua engines expose incompatible HTTP and JSON +APIs. + +## Directory contract + +```text +sdk/ + conformance/ language-neutral route inventory + / source, language README, tests, optional quickstart +examples/mods/ + fabric-rin-npc/ source overlay for the official Fabric template + bepinex-rin-npc/ BepInEx 6 source overlay + luanti-rin-npc/ complete server mod with vendored Lua SDK +``` + +The SDKs are source-first and are not published to language registries yet. +Vendor a tagged Rin revision or reference the source project directly. Do not +copy a single client file without its README and conformance version. + +## Integration lifecycle + +1. Capture a bounded game-owned event and call `observe`. +2. Give Rin only candidate actions the game can safely implement. +3. Use the asynchronous Proposal Job API from real-time games. +4. Validate the returned action ID and payload against a local allowlist. +5. Marshal to the engine's owning thread and apply the action. +6. Call `commit` with the actual outcome, including a rejection when needed. +7. Keep an authored or deterministic fallback when Rin is unavailable. + +Never call online proposal or generation endpoints from a render/update loop. +One player interaction may start one job; ordinary frames should only poll a +local future, coroutine, timer, or main-thread queue. + +## Credentials and transport + +- Keep model-provider credentials in the Rin sidecar only. +- A game may hold `RIN_TOKEN`, which authenticates the game to Rin; it is not a + provider API key and must not be written to saves, logs, or mod configs. +- SDKs accept plaintext HTTP only for loopback. Remote Rin origins require + HTTPS and a token. +- Redirects are rejected, responses are size-limited, and user-visible errors + contain bounded Rin codes rather than provider bodies. +- Treat generated dialogue as display data. Never parse it as a console + command, reflection target, script name, item ID, or filesystem path. + +Luanti is a documented exception: its engine HTTP implementation follows up +to three redirects and the mod API has no per-request opt-out. The example is +therefore loopback-only and refuses Authorization headers. Use a native bridge +before supporting authenticated remote Rin from Luanti. + +## Example mods + +The Fabric overlay follows the official project layout, reuses Minecraft's +Gson, and schedules effects with `MinecraftServer.execute`. Generate the build +files from the current Fabric template instead of pinning a Loom/Minecraft +combination that will age inside Rin. + +The BepInEx overlay targets BepInEx 6 and .NET 6. It makes no HTTP request per +frame: `Update` drains a bounded queue and optionally detects the F8 demo key. +Subscribe to `NpcActionReady` and translate the three sample IDs through the +target game's supported APIs. + +The Luanti example is a complete server mod. It calls +`core.request_http_api()` at module scope, keeps the returned API local, and +requires `secure.http_mods = rin_npc_example`. + +## Verification + +```bash +make test +make test-sdks +``` + +The main Go compatibility suite checks route coverage, security markers, +engine-thread handoff, local action allowlists, and exact synchronization of +the vendored Luanti client. CI then executes Python, JavaScript, Java, C#, and +both Lua 5.1 and 5.4; the other jobs use each SDK's minimum supported runtime. + +## Primary references + +- [Fabric example mod (CC0)](https://github.com/FabricMC/fabric-example-mod) +- [Fabric project structure](https://docs.fabricmc.net/develop/getting-started/project-structure) +- [BepInEx plugin tutorial](https://docs.bepinex.dev/articles/dev_guide/plugin_tutorial/index.html) +- [BepInEx configuration](https://docs.bepinex.dev/articles/dev_guide/plugin_tutorial/4_configuration.html) +- [Java 17 HttpClient](https://docs.oracle.com/en/java/javase/17/docs/api/java.net.http/java/net/http/HttpClient.html) +- [.NET HttpClient JSON extensions](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.json) +- [`System.Text.Json` supported types](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/supported-types) +- [Luanti HTTP API](https://docs.luanti.org/for-creators/api/http-api/) +- [Luanti Lua API source](https://github.com/luanti-org/luanti/blob/master/doc/lua_api.md) + +The examples were written for Rin and do not copy implementation code from +those projects. Links document host lifecycle, metadata, and transport APIs. +Rin's SDKs, examples, and documentation are distributed under the +[MIT License](../LICENSE). diff --git a/docs/sdk-and-mods.zh-CN.md b/docs/sdk-and-mods.zh-CN.md new file mode 100644 index 0000000..996935c --- /dev/null +++ b/docs/sdk-and-mods.zh-CN.md @@ -0,0 +1,109 @@ +# SDK 与 Mod 接入套件 + +[English](sdk-and-mods.md) | [简体中文](sdk-and-mods.zh-CN.md) + +Rin 仍是与游戏无关的 Sidecar。这些 SDK 消除重复的 HTTP、超时、Envelope +和 Job 轮询代码,但不会把世界权威移入 Sidecar 或模型。 + +## 支持矩阵 + +| 语言 | 最低运行时 | 调用模型 | JSON 边界 | 典型宿主 | +| --- | --- | --- | --- | --- | +| Python | 3.9 | 同步 | 标准库 | Ren'Py、工具、服务器 | +| JavaScript | Node 18 / Fetch 宿主 | Promise | 内置 | Electron、Web Bridge、Node | +| C# | .NET 6 | Task | `System.Text.Json` | BepInEx 6、现代 .NET 游戏 | +| Java | 17 | `CompletableFuture` | 注入 `JsonCodec` | Fabric、JVM 服务器 | +| Lua | 5.1 | Callback | 注入 Codec 和 Transport | Luanti、嵌入式 Lua 引擎 | + +每套实现覆盖 +[`sdk/conformance/routes.json`](../sdk/conformance/routes.json) 中的 20 条 +路由。Python 和 JavaScript 没有运行时依赖;C# 只使用 Framework API; +Java 通过两个方法的 Codec 复用宿主 JSON 库;Lua 注入全部宿主服务,因为 +不同 Lua 引擎的 HTTP 和 JSON API 不兼容。 + +## 目录约定 + +```text +sdk/ + conformance/ 与语言无关的路由清单 + / 源码、语言 README、测试、可选快速开始 +examples/mods/ + fabric-rin-npc/ 官方 Fabric 模板的源码覆盖层 + bepinex-rin-npc/ BepInEx 6 源码覆盖层 + luanti-rin-npc/ 内置 Lua SDK 的完整服务器 Mod +``` + +SDK 当前以源码为主,尚未发布到语言注册表。应固定到带 Tag 的 Rin revision, +或直接引用源码项目。不要只复制单个客户端文件而遗漏 README 和 Conformance +版本。 + +## 接入生命周期 + +1. 捕获一个有界、由游戏拥有的事件并调用 `observe`。 +2. 只向 Rin 提供游戏能够安全实现的候选动作。 +3. 实时游戏使用异步 Proposal Job API。 +4. 用本地白名单验证返回的 Action ID 和 Payload。 +5. 切回引擎拥有的线程并应用动作。 +6. 用实际结果调用 `commit`,必要时提交拒绝。 +7. Rin 不可用时保留 authored 或 deterministic fallback。 + +不要从渲染或 Update 循环调用在线 Proposal 或 Generation 端点。一次玩家 +交互最多启动一个 Job;普通帧只应检查本地 Future、Coroutine、Timer 或 +主线程队列。 + +## 凭据与传输 + +- 模型供应商凭据只保留在 Rin Sidecar。 +- 游戏可以持有用于向 Rin 鉴权的 `RIN_TOKEN`;它不是供应商 API Key, + 不能写入存档、日志或 Mod 配置。 +- SDK 只对 loopback 接受明文 HTTP。远程 Rin Origin 必须使用 HTTPS 和 + Token。 +- SDK 拒绝重定向、限制响应大小,并只向用户显示有界 Rin 错误码,不暴露 + 供应商正文。 +- 把生成对白当作显示数据。绝不能把它解析成控制台命令、反射目标、脚本名、 + Item ID 或文件路径。 + +Luanti 是有文档记录的例外:其引擎 HTTP 实现最多跟随三次重定向,Mod API +没有单请求关闭开关。因此示例只允许 loopback,并拒绝 Authorization +Header。要从 Luanti 支持经过鉴权的远程 Rin,应先使用更严格的原生 Bridge。 + +## 示例 Mod + +Fabric 覆盖层遵循官方项目布局,复用 Minecraft 的 Gson,并通过 +`MinecraftServer.execute` 安排效果。应从当前 Fabric 模板生成构建文件, +不要在 Rin 中固定会老化的 Loom/Minecraft 组合。 + +BepInEx 覆盖层面向 BepInEx 6 和 .NET 6。它不会每帧发送 HTTP: +`Update` 只排空有上限的队列并可选检测 F8 演示按键。订阅 +`NpcActionReady`,再通过目标游戏支持的 API 转换三个示例 ID。 + +Luanti 示例是完整服务器 Mod。它只在模块作用域调用 +`core.request_http_api()`,把返回 API 保持为 local,并要求 +`secure.http_mods = rin_npc_example`。 + +## 验证 + +```bash +make test +make test-sdks +``` + +主 Go 兼容套件检查路由覆盖、安全标记、引擎线程切换、本地动作白名单和 +Luanti 内置客户端的精确同步。CI 运行 Python、JavaScript、Java、C# 以及 +Lua 5.1 和 5.4;其他 Job 使用各 SDK 的最低受支持运行时。 + +## 主要参考 + +- [Fabric 示例 Mod(CC0)](https://github.com/FabricMC/fabric-example-mod) +- [Fabric 项目结构](https://docs.fabricmc.net/develop/getting-started/project-structure) +- [BepInEx 插件教程](https://docs.bepinex.dev/articles/dev_guide/plugin_tutorial/index.html) +- [BepInEx 配置](https://docs.bepinex.dev/articles/dev_guide/plugin_tutorial/4_configuration.html) +- [Java 17 HttpClient](https://docs.oracle.com/en/java/javase/17/docs/api/java.net.http/java/net/http/HttpClient.html) +- [.NET HttpClient JSON 扩展](https://learn.microsoft.com/en-us/dotnet/api/system.net.http.json) +- [`System.Text.Json` 支持的类型](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json/supported-types) +- [Luanti HTTP API](https://docs.luanti.org/for-creators/api/http-api/) +- [Luanti Lua API 源码](https://github.com/luanti-org/luanti/blob/master/doc/lua_api.md) + +这些示例为 Rin 独立编写,没有复制上述项目的实现代码。链接用于说明宿主 +生命周期、元数据和传输 API。Rin SDK、示例与文档按 +[MIT License](../LICENSE) 发布。 diff --git a/examples/mods/bepinex-rin-npc/Plugin.cs b/examples/mods/bepinex-rin-npc/Plugin.cs new file mode 100644 index 0000000..53cf443 --- /dev/null +++ b/examples/mods/bepinex-rin-npc/Plugin.cs @@ -0,0 +1,312 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using BepInEx; +using BepInEx.Configuration; +using Rin.Client; +using UnityEngine; + +namespace RinNpcExample; + +[BepInPlugin(PluginGuid, PluginName, PluginVersion)] +public sealed class Plugin : BaseUnityPlugin +{ + public const string PluginGuid = "io.github.sunrioa.rin.npc-example"; + public const string PluginName = "Rin NPC Example"; + public const string PluginVersion = "0.1.0"; + + private const string ActorId = "npc.rin.companion"; + private static readonly HashSet AllowedActions = new(StringComparer.Ordinal) + { + "talk", + "wait", + "refuse", + }; + + private readonly ConcurrentQueue mainThread = new(); + private readonly SemaphoreSlim turnGate = new(1, 1); + private readonly object sessionLock = new(); + private RinClient? rin; + private ConfigEntry? baseUrl; + private ConfigEntry? demoHotkey; + private Task? sessionTask; + private string sessionId = string.Empty; + private string gameId = string.Empty; + private long sequence; + + public event Action? NpcActionReady; + + private void Awake() + { + baseUrl = Config.Bind( + "Connection", + "BaseUrl", + RinClient.DefaultBaseUrl, + "Rin origin. Remote origins require HTTPS and RIN_TOKEN in the process environment."); + demoHotkey = Config.Bind( + "Example", + "EnableF8Demo", + true, + "Press F8 to request one example NPC turn."); + sessionId = "bepinex." + Guid.NewGuid().ToString("N"); + gameId = Application.productName; + + try + { + rin = new RinClient(new RinClientOptions + { + BaseUrl = baseUrl.Value, + Token = Environment.GetEnvironmentVariable("RIN_TOKEN") ?? string.Empty, + }); + Logger.LogInfo("Rin NPC example loaded. No network request runs until an interaction is triggered."); + } + catch (RinException exception) + { + Logger.LogError("Rin configuration rejected: " + exception.Code); + } + } + + private void Update() + { + for (var count = 0; count < 64 && mainThread.TryDequeue(out var action); count++) + { + action(); + } + if (rin is not null && demoHotkey?.Value == true && Input.GetKeyDown(KeyCode.F8)) + { + RequestNpcTurn("The player requested guidance from the companion.", Time.frameCount); + } + } + + private void OnDestroy() + { + rin?.Dispose(); + turnGate.Dispose(); + } + + public void RequestNpcTurn(string observation, long gameTick) + { + if (rin is null) return; + _ = RunNpcTurnAsync(observation, gameTick); + } + + private async Task RunNpcTurnAsync(string observation, long gameTick) + { + if (rin is null) return; + await turnGate.WaitAsync().ConfigureAwait(false); + try + { + await EnsureSessionAsync().ConfigureAwait(false); + var turn = Interlocked.Increment(ref sequence); + await rin.ObserveAsync(new Dictionary + { + ["protocol_version"] = RinClient.ProtocolVersion, + ["session_id"] = sessionId, + ["request_id"] = "observe." + turn, + ["event_id"] = "event." + turn, + ["tick"] = gameTick, + ["observer_ids"] = new[] { ActorId }, + ["source"] = "bepinex-example", + ["kind"] = "dialogue", + ["summary"] = observation, + ["tags"] = new[] { "conversation", "player-request" }, + ["importance"] = 3, + }).ConfigureAwait(false); + + var queued = await rin.SubmitProposalJobAsync(new Dictionary + { + ["protocol_version"] = RinClient.ProtocolVersion, + ["session_id"] = sessionId, + ["request_id"] = "propose." + turn, + ["actor_id"] = ActorId, + ["tick"] = gameTick + 1, + ["intent"] = "Choose one bounded response to the player.", + ["tags"] = new[] { "conversation" }, + ["candidate_actions"] = new object[] + { + ActionSpec("talk", "dialogue", "offer one concrete hint"), + ActionSpec("wait", "wait", "ask the player to observe first"), + ActionSpec("refuse", "refuse", "decline an unsafe request"), + }, + }).ConfigureAwait(false); + var jobId = RequiredString(queued, "job_id"); + var job = await rin.WaitForProposalAsync(jobId).ConfigureAwait(false); + var applied = await ApplyOnMainThreadAsync(job).ConfigureAwait(false); + + var proposal = RequiredObject(job, "proposal"); + await rin.CommitAsync(new Dictionary + { + ["protocol_version"] = RinClient.ProtocolVersion, + ["session_id"] = sessionId, + ["request_id"] = "commit." + turn, + ["proposal_id"] = RequiredString(proposal, "proposal_id"), + ["event_id"] = "outcome." + turn, + ["tick"] = gameTick + 2, + ["accepted"] = applied.Accepted, + ["outcome"] = applied.Outcome, + ["tags"] = new[] { "bepinex-example", "conversation" }, + }).ConfigureAwait(false); + EnqueueLog("Rin turn committed."); + } + catch (RinException exception) + { + EnqueueLog("Rin request failed: " + exception.Code, error: true); + } + catch (Exception) + { + EnqueueLog("Rin integration failed before the proposal could be applied.", error: true); + } + finally + { + turnGate.Release(); + } + } + + private Task EnsureSessionAsync() + { + lock (sessionLock) + { + return sessionTask ??= CreateSessionAsync(); + } + } + + private async Task CreateSessionAsync() + { + if (rin is null) throw new InvalidOperationException("Rin is not configured"); + try + { + await rin.CreateSessionAsync(new Dictionary + { + ["protocol_version"] = RinClient.ProtocolVersion, + ["request_id"] = "create." + sessionId, + ["session_id"] = sessionId, + ["binding"] = new Dictionary + { + ["game_id"] = gameId, + ["content_id"] = "rin-bepinex-example", + ["content_version"] = PluginVersion, + ["content_hash"] = "sha256:" + new string('0', 64), + }, + ["seed"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + ["actors"] = new object[] + { + new Dictionary + { + ["id"] = ActorId, + ["kind"] = "npc", + ["display_name"] = "Rin Companion", + ["traits"] = new[] { "observant", "careful" }, + ["boundaries"] = new object[] + { + new Dictionary + { + ["id"] = "boundary.no-cheats", + ["description"] = "Never suggest cheats or bypassing game rules.", + ["trigger_tags"] = new[] { "unsafe" }, + ["response"] = "refuse", + }, + }, + ["goals"] = new object[] + { + new Dictionary + { + ["id"] = "goal.help-player", + ["description"] = "Help the player make one informed choice.", + ["priority"] = 4, + ["preferred_actions"] = new[] { "talk" }, + ["progress"] = 0, + ["target_progress"] = 3, + ["status"] = "active", + }, + }, + ["think_every_ticks"] = 20, + ["enabled"] = true, + }, + }, + }).ConfigureAwait(false); + } + catch + { + lock (sessionLock) sessionTask = null; + throw; + } + } + + private Task ApplyOnMainThreadAsync(JsonElement job) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + mainThread.Enqueue(() -> + { + try + { + var proposal = RequiredObject(job, "proposal"); + var action = RequiredObject(proposal, "action"); + var actionId = RequiredString(action, "id"); + if (!AllowedActions.Contains(actionId)) + { + completion.SetResult(new AppliedAction(false, "The game rejected an action outside its allowlist.")); + return; + } + var line = actionId switch + { + "talk" => "Companion: Check your resources before choosing the next route.", + "wait" => "Companion: Let us observe one more cycle before acting.", + "refuse" => "Companion: I cannot help with an action that breaks the game rules.", + _ => throw new InvalidOperationException("allowlist changed during apply"), + }; + Logger.LogMessage(line); + NpcActionReady?.Invoke(actionId, line); + completion.SetResult(new AppliedAction(true, line)); + } + catch (Exception) + { + completion.SetResult(new AppliedAction(false, "The game could not apply the proposal.")); + } + }); + return completion.Task; + } + + private void EnqueueLog(string message, bool error = false) + { + mainThread.Enqueue(() => + { + if (error) Logger.LogError(message); else Logger.LogInfo(message); + }); + } + + private static Dictionary ActionSpec(string id, string kind, string description) => new() + { + ["id"] = id, + ["kind"] = kind, + ["description"] = description, + }; + + private static JsonElement RequiredObject(JsonElement parent, string name) + { + if (!parent.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.Object) + throw new RinProtocolException("invalid_response", "Rin response is missing " + name); + return value; + } + + private static string RequiredString(JsonElement parent, string name) + { + if (!parent.TryGetProperty(name, out var value) || value.ValueKind != JsonValueKind.String) + throw new RinProtocolException("invalid_response", "Rin response is missing " + name); + return value.GetString() ?? string.Empty; + } + + private sealed class AppliedAction + { + public AppliedAction(bool accepted, string outcome) + { + Accepted = accepted; + Outcome = outcome; + } + + public bool Accepted { get; } + public string Outcome { get; } + } +} diff --git a/examples/mods/bepinex-rin-npc/README.md b/examples/mods/bepinex-rin-npc/README.md new file mode 100644 index 0000000..7ca7c44 --- /dev/null +++ b/examples/mods/bepinex-rin-npc/README.md @@ -0,0 +1,24 @@ +# BepInEx Rin NPC example + +[English](README.md) | [简体中文](README.zh-CN.md) + +This source overlay targets BepInEx 6 on a modern Unity/.NET runtime. + +1. Create a plugin from the official BepInEx plugin template for the target + game's backend and framework version. +2. Add a project reference to `sdk/csharp/Rin.Client/Rin.Client.csproj`, or + copy its compiled assembly into the plugin's reference directory. +3. Add `Plugin.cs`, start Rin, and build the plugin into `BepInEx/plugins`. +4. Configure only `BaseUrl` in the generated BepInEx config. Supply a remote + bearer token through the `RIN_TOKEN` process environment variable. +5. Press F8 for the isolated demo turn, or call `RequestNpcTurn` from the + target game's actual dialogue or interaction hook. + +`Update` only drains a bounded main-thread queue and detects the optional demo +key. HTTP runs asynchronously. The plugin validates `talk`, `wait`, or +`refuse`, invokes `NpcActionReady` on Unity's main thread, and commits only +after that application step. A real game-specific plugin should subscribe to +the event and map those IDs to its own NPC APIs. + +Official plugin tutorial: https://docs.bepinex.dev/articles/dev_guide/plugin_tutorial/index.html +Configuration guide: https://docs.bepinex.dev/articles/dev_guide/plugin_tutorial/4_configuration.html diff --git a/examples/mods/bepinex-rin-npc/README.zh-CN.md b/examples/mods/bepinex-rin-npc/README.zh-CN.md new file mode 100644 index 0000000..1a8a072 --- /dev/null +++ b/examples/mods/bepinex-rin-npc/README.zh-CN.md @@ -0,0 +1,24 @@ +# BepInEx Rin NPC 示例 + +[English](README.md) | [简体中文](README.zh-CN.md) + +该源码覆盖层面向现代 Unity/.NET 运行时上的 BepInEx 6。 + +1. 使用官方 BepInEx Plugin Template,为目标游戏的 Backend 和 Framework + 版本创建插件。 +2. 添加对 `sdk/csharp/Rin.Client/Rin.Client.csproj` 的项目引用,或把编译 + 后 Assembly 复制到插件引用目录。 +3. 添加 `Plugin.cs`,启动 Rin,并把插件构建到 `BepInEx/plugins`。 +4. 只在生成的 BepInEx Config 中配置 `BaseUrl`。远程 Bearer Token 通过 + `RIN_TOKEN` 进程环境变量提供。 +5. 按 F8 运行隔离 Demo Turn,或从目标游戏真实对白/交互 Hook 调用 + `RequestNpcTurn`。 + +`Update` 只排空有界主线程队列并检测可选 Demo Key;HTTP 异步运行。插件 +验证 `talk`、`wait` 或 `refuse`,在 Unity 主线程调用 `NpcActionReady`, +并且只在应用后 Commit。真实游戏专用插件应订阅该事件,把这些 ID 映射到 +自己的 NPC API。 + +官方插件教程:https://docs.bepinex.dev/articles/dev_guide/plugin_tutorial/index.html + +配置指南:https://docs.bepinex.dev/articles/dev_guide/plugin_tutorial/4_configuration.html diff --git a/examples/mods/fabric-rin-npc/README.md b/examples/mods/fabric-rin-npc/README.md new file mode 100644 index 0000000..d51fbee --- /dev/null +++ b/examples/mods/fabric-rin-npc/README.md @@ -0,0 +1,24 @@ +# Fabric Rin NPC example + +[English](README.md) | [简体中文](README.zh-CN.md) + +This is a source overlay for a dedicated-server Fabric mod, not a frozen +Gradle template. Start from the current official Fabric project generator so +Minecraft, Loader, mappings, Fabric API, and Loom stay on compatible versions. + +1. Generate a Java 21 / Minecraft 1.21+ Fabric project. +2. Copy this example's `src` directory into it. +3. Copy `sdk/java/src/main/java/io/github/sunrioa/rin` into the generated + project's `src/main/java/io/github/sunrioa/rin` directory. +4. Start Rin and set optional `RIN_URL` / `RIN_TOKEN` environment variables. +5. Run the server and enter `/rin-npc ask` as a player. + +The command creates an isolated sample session, observes the interaction, +submits an asynchronous proposal job, validates one of three action IDs, then +uses `MinecraftServer.execute` to apply it on the server thread. The result is +committed only after application. Replace the chat-only `switch` with your own +NPC API; do not let model text directly invoke commands, item grants, or world +edits. + +Reference template: https://github.com/FabricMC/fabric-example-mod +Project structure: https://docs.fabricmc.net/develop/getting-started/project-structure diff --git a/examples/mods/fabric-rin-npc/README.zh-CN.md b/examples/mods/fabric-rin-npc/README.zh-CN.md new file mode 100644 index 0000000..3c2e6b9 --- /dev/null +++ b/examples/mods/fabric-rin-npc/README.zh-CN.md @@ -0,0 +1,23 @@ +# Fabric Rin NPC 示例 + +[English](README.md) | [简体中文](README.zh-CN.md) + +这是面向 Fabric 专用服务器 Mod 的源码覆盖层,不是固定版本的 Gradle 模板。 +从当前官方 Fabric Project Generator 开始,确保 Minecraft、Loader、 +Mapping、Fabric API 和 Loom 版本互相兼容。 + +1. 生成 Java 21 / Minecraft 1.21+ Fabric 项目。 +2. 把本示例的 `src` 目录复制进去。 +3. 把 `sdk/java/src/main/java/io/github/sunrioa/rin` 复制到生成项目的 + `src/main/java/io/github/sunrioa/rin`。 +4. 启动 Rin,并按需设置 `RIN_URL` / `RIN_TOKEN` 环境变量。 +5. 启动服务器,以玩家身份输入 `/rin-npc ask`。 + +该命令创建隔离的示例 Session,观察交互,提交异步 Proposal Job,验证三个 +Action ID 之一,再使用 `MinecraftServer.execute` 在服务器线程应用。只有 +应用后才 Commit。应把只发聊天的 `switch` 替换为自己的 NPC API;不要让 +模型文本直接调用命令、发放 Item 或修改世界。 + +参考模板:https://github.com/FabricMC/fabric-example-mod + +项目结构:https://docs.fabricmc.net/develop/getting-started/project-structure diff --git a/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/GsonJsonCodec.java b/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/GsonJsonCodec.java new file mode 100644 index 0000000..426d897 --- /dev/null +++ b/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/GsonJsonCodec.java @@ -0,0 +1,32 @@ +package io.github.sunrioa.rin.example; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.reflect.TypeToken; +import io.github.sunrioa.rin.JsonCodec; + +import java.lang.reflect.Type; +import java.util.Map; + +final class GsonJsonCodec implements JsonCodec { + private static final Type OBJECT_MAP = new TypeToken>() { }.getType(); + private final Gson gson; + + GsonJsonCodec(Gson gson) { + this.gson = gson; + } + + @Override + public String encode(Map value) { + return gson.toJson(value); + } + + @Override + public Map decodeObject(String json) { + JsonElement root = gson.fromJson(json, JsonElement.class); + if (root == null || !root.isJsonObject()) { + throw new IllegalArgumentException("Rin envelope must be an object"); + } + return gson.fromJson(root, OBJECT_MAP); + } +} diff --git a/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java b/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java new file mode 100644 index 0000000..b7744d2 --- /dev/null +++ b/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java @@ -0,0 +1,229 @@ +package io.github.sunrioa.rin.example; + +import com.google.gson.Gson; +import com.mojang.brigadier.Command; +import io.github.sunrioa.rin.RinClient; +import io.github.sunrioa.rin.RinException; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import static net.minecraft.server.command.CommandManager.literal; + +public final class RinNpcMod implements ModInitializer { + private static final String ACTOR_ID = "npc.rin.guide"; + private static final Set ALLOWED_ACTIONS = Set.of("talk", "wait", "refuse"); + + private final String runId = UUID.randomUUID().toString().substring(0, 12); + private final AtomicLong sequence = new AtomicLong(); + private final Map> sessions = new ConcurrentHashMap<>(); + private final Set activePlayers = ConcurrentHashMap.newKeySet(); + private final RinClient rin = new RinClient( + System.getenv().getOrDefault("RIN_URL", RinClient.DEFAULT_BASE_URL), + System.getenv().getOrDefault("RIN_TOKEN", ""), + Duration.ofSeconds(5), + RinClient.DEFAULT_MAX_RESPONSE_BYTES, + new GsonJsonCodec(new Gson())); + + @Override + public void onInitialize() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> + dispatcher.register(literal("rin-npc") + .then(literal("ask").executes(context -> { + requestTurn(context.getSource()); + return Command.SINGLE_SUCCESS; + })))); + } + + private void requestTurn(ServerCommandSource source) { + ServerPlayerEntity player; + try { + player = source.getPlayerOrThrow(); + } catch (Exception ignored) { + source.sendError(Text.literal("This example command must be run by a player.")); + return; + } + + MinecraftServer server = source.getServer(); + UUID playerId = player.getUuid(); + if (!activePlayers.add(playerId)) { + source.sendError(Text.literal("A Rin turn is already running for this player.")); + return; + } + String sessionId = "fabric." + runId + "." + playerId; + long turn = sequence.incrementAndGet(); + long tick = server.getTicks(); + source.sendFeedback(() -> Text.literal("The Rin guide is considering the situation..."), false); + + ensureSession(sessionId, player.getName().getString(), turn) + .thenCompose(ignored -> rin.observe(mapOf( + "protocol_version", RinClient.PROTOCOL_VERSION, + "session_id", sessionId, + "request_id", "observe." + turn, + "event_id", "event." + turn, + "tick", tick, + "observer_ids", List.of(ACTOR_ID), + "source", "fabric-example", + "kind", "dialogue", + "summary", "The player asked the guide what to do next.", + "tags", List.of("conversation", "player-request"), + "importance", 3))) + .thenCompose(ignored -> rin.submitProposalJob(mapOf( + "protocol_version", RinClient.PROTOCOL_VERSION, + "session_id", sessionId, + "request_id", "propose." + turn, + "actor_id", ACTOR_ID, + "tick", tick + 1, + "intent", "Choose one bounded response to the player.", + "tags", List.of("conversation"), + "candidate_actions", List.of( + mapOf("id", "talk", "kind", "dialogue", "description", "offer one concrete hint"), + mapOf("id", "wait", "kind", "wait", "description", "ask the player to observe first"), + mapOf("id", "refuse", "kind", "refuse", "description", "decline an unsafe request"))))) + .thenCompose(job -> rin.waitForProposal(text(job, "job_id"))) + .thenCompose(job -> applyAndCommit(server, playerId, sessionId, turn, tick + 2, job)) + .thenAccept(ignored -> server.execute(() -> { + ServerPlayerEntity current = server.getPlayerManager().getPlayer(playerId); + if (current != null) current.sendMessage(Text.literal("Rin turn committed."), false); + })) + .exceptionally(error -> { + String code = safeCode(error); + server.execute(() -> { + ServerPlayerEntity current = server.getPlayerManager().getPlayer(playerId); + if (current != null) current.sendMessage(Text.literal("Rin request failed: " + code), false); + }); + return null; + }) + .whenComplete((ignored, error) -> activePlayers.remove(playerId)); + } + + private CompletableFuture ensureSession(String sessionId, String playerName, long turn) { + return sessions.computeIfAbsent(sessionId, key -> { + CompletableFuture created = rin.createSession(mapOf( + "protocol_version", RinClient.PROTOCOL_VERSION, + "request_id", "create." + turn, + "session_id", sessionId, + "binding", mapOf( + "game_id", "minecraft-fabric", + "content_id", "rin-npc-example", + "content_version", "0.1.0", + "content_hash", "sha256:" + "0".repeat(64)), + "seed", turn, + "actors", List.of(mapOf( + "id", ACTOR_ID, + "kind", "npc", + "display_name", "Rin Guide", + "traits", List.of("observant", "careful"), + "boundaries", List.of(mapOf( + "id", "boundary.no-griefing", + "description", "Never suggest griefing or bypassing server rules.", + "trigger_tags", List.of("unsafe"), + "response", "refuse")), + "goals", List.of(mapOf( + "id", "goal.help-player", + "description", "Help " + playerName + " make one informed choice.", + "priority", 4, + "preferred_actions", List.of("talk"), + "progress", 0, + "target_progress", 3, + "status", "active")), + "think_every_ticks", 20, + "enabled", true)))) + .thenApply(ignored -> null); + created.whenComplete((ignored, error) -> { + if (error != null) sessions.remove(key, created); + }); + return created; + }); + } + + private CompletableFuture> applyAndCommit( + MinecraftServer server, + UUID playerId, + String sessionId, + long turn, + long tick, + Map job) { + Map proposal = object(job.get("proposal")); + Map action = object(proposal.get("action")); + String actionId = text(action, "id"); + String proposalId = text(proposal, "proposal_id"); + CompletableFuture applied = new CompletableFuture<>(); + + server.execute(() -> { + ServerPlayerEntity player = server.getPlayerManager().getPlayer(playerId); + if (player == null) { + applied.complete(new AppliedAction(false, "Player left before the proposal could be applied.")); + return; + } + if (!ALLOWED_ACTIONS.contains(actionId)) { + applied.complete(new AppliedAction(false, "The game rejected an action outside its allowlist.")); + return; + } + String line = switch (actionId) { + case "talk" -> "Guide: Check the nearby terrain, then choose a route with cover."; + case "wait" -> "Guide: Let us watch one more cycle before acting."; + case "refuse" -> "Guide: I cannot help with an action that breaks the server rules."; + default -> throw new IllegalStateException("allowlist changed during apply"); + }; + player.sendMessage(Text.literal(line), false); + applied.complete(new AppliedAction(true, line)); + }); + + return applied.thenCompose(result -> rin.commit(mapOf( + "protocol_version", RinClient.PROTOCOL_VERSION, + "session_id", sessionId, + "request_id", "commit." + turn, + "proposal_id", proposalId, + "event_id", "outcome." + turn, + "tick", tick, + "accepted", result.accepted(), + "outcome", result.outcome(), + "tags", List.of("fabric-example", "conversation")))); + } + + private static Map mapOf(Object... entries) { + if (entries.length % 2 != 0) throw new IllegalArgumentException("map entries must be key/value pairs"); + Map result = new LinkedHashMap<>(); + for (int index = 0; index < entries.length; index += 2) { + result.put((String) entries[index], entries[index + 1]); + } + return result; + } + + private static Map object(Object value) { + if (!(value instanceof Map source)) return Map.of(); + Map result = new LinkedHashMap<>(); + source.forEach((key, item) -> { + if (key instanceof String text) result.put(text, item); + }); + return result; + } + + private static String text(Map value, String key) { + Object item = value.get(key); + return item instanceof String text ? text : ""; + } + + private static String safeCode(Throwable error) { + Throwable cause = error; + while (cause instanceof CompletionException && cause.getCause() != null) cause = cause.getCause(); + return cause instanceof RinException rinError ? rinError.code() : "integration_failed"; + } + + private record AppliedAction(boolean accepted, String outcome) { } +} diff --git a/examples/mods/fabric-rin-npc/src/main/resources/fabric.mod.json b/examples/mods/fabric-rin-npc/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..31ea752 --- /dev/null +++ b/examples/mods/fabric-rin-npc/src/main/resources/fabric.mod.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "id": "rin_npc_example", + "version": "0.1.0", + "name": "Rin NPC Example", + "description": "Reference Fabric integration for a Rin-backed NPC turn.", + "authors": ["sunrioa"], + "license": "MIT", + "environment": "server", + "entrypoints": { + "main": ["io.github.sunrioa.rin.example.RinNpcMod"] + }, + "depends": { + "fabricloader": ">=0.16.0", + "fabric-api": "*", + "minecraft": ">=1.21", + "java": ">=21" + } +} diff --git a/examples/mods/luanti-rin-npc/README.md b/examples/mods/luanti-rin-npc/README.md new file mode 100644 index 0000000..753d622 --- /dev/null +++ b/examples/mods/luanti-rin-npc/README.md @@ -0,0 +1,25 @@ +# Luanti Rin NPC example + +[English](README.md) | [简体中文](README.zh-CN.md) + +This is a complete server-side Luanti mod. The included `rin.lua` is a vendored +copy of `sdk/lua/rin.lua`; the repository test requires both copies to match. + +1. Copy this directory to the Luanti `mods` or world `worldmods` directory. +2. Add `rin_npc_example` to `secure.http_mods` in `minetest.conf`. +3. Start Rin at `http://127.0.0.1:7374`, enable the mod, and restart the world. +4. Run `/rin_npc` or `/rin_npc your message` in chat. + +The mod calls `core.request_http_api()` only at module scope, keeps the returned +API local, uses `HTTPApiTable.fetch` asynchronously, and schedules polling with +`core.after`. It maps only `talk`, `wait`, and `refuse` to fixed game-owned +effects before committing the result. + +Luanti's HTTP implementation follows redirects and the Lua API provides no +per-request switch to disable that behavior. For that reason this example +accepts only explicit loopback HTTP origins and refuses Authorization headers; +do not adapt it to an authenticated remote Rin endpoint without a stricter +native transport. + +Official HTTP API: https://docs.luanti.org/for-creators/api/http-api/ +Official Lua API source: https://github.com/luanti-org/luanti/blob/master/doc/lua_api.md diff --git a/examples/mods/luanti-rin-npc/README.zh-CN.md b/examples/mods/luanti-rin-npc/README.zh-CN.md new file mode 100644 index 0000000..1f97550 --- /dev/null +++ b/examples/mods/luanti-rin-npc/README.zh-CN.md @@ -0,0 +1,23 @@ +# Luanti Rin NPC 示例 + +[English](README.md) | [简体中文](README.zh-CN.md) + +这是完整的 Luanti 服务器 Mod。内置 `rin.lua` 是 `sdk/lua/rin.lua` 的 +Vendored Copy;仓库测试要求两份文件完全一致。 + +1. 把该目录复制到 Luanti `mods` 或世界 `worldmods` 目录。 +2. 在 `minetest.conf` 中把 `rin_npc_example` 加入 `secure.http_mods`。 +3. 在 `http://127.0.0.1:7374` 启动 Rin,启用 Mod 并重启世界。 +4. 在聊天中执行 `/rin_npc` 或 `/rin_npc your message`。 + +Mod 只在模块作用域调用 `core.request_http_api()`,把返回 API 保持为 local, +通过 `HTTPApiTable.fetch` 异步请求,并用 `core.after` 调度轮询。它只把 +`talk`、`wait` 和 `refuse` 映射到游戏拥有的固定效果,再 Commit 结果。 + +Luanti HTTP 实现会跟随重定向,而 Lua API 没有单请求关闭开关。因此示例 +只接受显式 loopback HTTP Origin,并拒绝 Authorization Header;没有更 +严格的原生 Transport 时,不要把它改为连接经过鉴权的远程 Rin。 + +官方 HTTP API:https://docs.luanti.org/for-creators/api/http-api/ + +官方 Lua API 源码:https://github.com/luanti-org/luanti/blob/master/doc/lua_api.md diff --git a/examples/mods/luanti-rin-npc/init.lua b/examples/mods/luanti-rin-npc/init.lua new file mode 100644 index 0000000..dee601a --- /dev/null +++ b/examples/mods/luanti-rin-npc/init.lua @@ -0,0 +1,270 @@ +local http_api = core.request_http_api and core.request_http_api() +local modpath = core.get_modpath(core.get_current_modname()) + +if not http_api then + core.log("error", "[rin_npc_example] HTTP access unavailable; add this mod to secure.http_mods") + return +end + +local function local_origin(value) + value = tostring(value or ""):gsub("/$", "") + if value:match("^http://127%.0%.0%.1:%d+$") or value:match("^http://localhost:%d+$") or + value:match("^http://%[::1%]:%d+$") then + return value + end + return nil +end + +local base_url = local_origin(core.settings:get("rin_npc_example.base_url") or "http://127.0.0.1:7374") +if not base_url then + core.log("error", "[rin_npc_example] base_url must be an explicit loopback HTTP origin") + return +end + +local rin = dofile(modpath .. "/rin.lua") + +local function encode_json(value) + local encoded, err = core.write_json(value) + if not encoded then error(err or "JSON encoding failed") end + return encoded +end + +local function decode_json(value) + local decoded, err = core.parse_json(value, nil, true) + if decoded == nil then error(err or "JSON decoding failed") end + return decoded +end + +local function fetch(request, callback) + if request.headers.Authorization then + callback({}) + return + end + local extra_headers = {} + local user_agent = "rin-luanti-example/0.1" + for key, value in pairs(request.headers) do + if key:lower() == "user-agent" then + user_agent = value + else + table.insert(extra_headers, key .. ": " .. value) + end + end + http_api.fetch({ + url = request.url, + timeout = request.timeout, + method = request.method, + data = request.body, + user_agent = user_agent, + extra_headers = extra_headers, + quiet = true, + }, function(result) + if not result.completed or not result.succeeded then + callback({}) + return + end + callback({ status = result.code, body = result.data or "", headers = {} }) + end) +end + +local client, client_error = rin.new({ + base_url = base_url, + http_fetch = fetch, + json_encode = encode_json, + json_decode = decode_json, + schedule = core.after, + now = function() return core.get_us_time() / 1000000 end, +}) +if not client then + core.log("error", "[rin_npc_example] configuration rejected: " .. client_error.code) + return +end + +local actor_id = "npc.rin.guide" +local allowed_actions = { + talk = "Guide: Check your supplies, then choose a route with a clear return path.", + wait = "Guide: Let us observe one more cycle before acting.", + refuse = "Guide: I cannot help with an action that breaks the world rules.", +} +local sessions = {} +local busy = {} +local sequence = 0 +local run_id = tostring(core.get_us_time()):gsub("[^0-9]", ""):sub(-12) + +local function next_turn() + sequence = sequence + 1 + return sequence +end + +local function safe_id(value) + return tostring(value):gsub("[^A-Za-z0-9._-]", "_"):sub(1, 48) +end + +local function notify(name, message) + core.chat_send_player(name, "[Rin] " .. message) +end + +local function failed(name, err) + busy[name] = nil + notify(name, "Request failed: " .. tostring(err and err.code or "integration_failed")) +end + +local function ensure_session(name, callback) + local existing = sessions[name] + if existing and existing.ready then + callback(existing.id) + return + end + if existing then + table.insert(existing.waiters, callback) + return + end + + local turn = next_turn() + local entry = { + id = "luanti." .. run_id .. "." .. safe_id(name), + ready = false, + waiters = { callback }, + } + sessions[name] = entry + client:create_session({ + protocol_version = rin.PROTOCOL_VERSION, + request_id = "create." .. turn, + session_id = entry.id, + binding = { + game_id = "luanti", + content_id = "rin-npc-example", + content_version = "0.1.0", + content_hash = "sha256:" .. string.rep("0", 64), + }, + seed = turn, + actors = { + { + id = actor_id, + kind = "npc", + display_name = "Rin Guide", + traits = { "observant", "careful" }, + boundaries = { + { + id = "boundary.no-griefing", + description = "Never suggest griefing or bypassing server rules.", + trigger_tags = { "unsafe" }, + response = "refuse", + }, + }, + goals = { + { + id = "goal.help-player", + description = "Help the player make one informed choice.", + priority = 4, + preferred_actions = { "talk" }, + progress = 0, + target_progress = 3, + status = "active", + }, + }, + think_every_ticks = 20, + enabled = true, + }, + }, + }, function(_, err) + local waiters = entry.waiters + entry.waiters = {} + if err then + sessions[name] = nil + for _, waiter in ipairs(waiters) do waiter(nil, err) end + return + end + entry.ready = true + for _, waiter in ipairs(waiters) do waiter(entry.id, nil) end + end) +end + +local function apply_and_commit(name, session_id, turn, tick, job) + local proposal = type(job.proposal) == "table" and job.proposal or {} + local action = type(proposal.action) == "table" and proposal.action or {} + local action_id = tostring(action.id or "") + local line = allowed_actions[action_id] + if type(proposal.proposal_id) ~= "string" then + failed(name, { code = "invalid_response" }) + return + end + local accepted = line ~= nil + local outcome = line or "The game rejected an action outside its allowlist." + + core.after(0, function() + if accepted then notify(name, line) end + client:commit({ + protocol_version = rin.PROTOCOL_VERSION, + session_id = session_id, + request_id = "commit." .. turn, + proposal_id = proposal.proposal_id, + event_id = "outcome." .. turn, + tick = tick, + accepted = accepted, + outcome = outcome, + tags = { "luanti-example", "conversation" }, + }, function(_, err) + busy[name] = nil + if err then failed(name, err) else notify(name, "Turn committed.") end + end) + end) +end + +local function request_turn(name, message) + if busy[name] then + notify(name, "A turn is already running.") + return + end + busy[name] = true + ensure_session(name, function(session_id, session_error) + if session_error or not session_id then failed(name, session_error); return end + local turn = next_turn() + local tick = turn * 3 + client:observe({ + protocol_version = rin.PROTOCOL_VERSION, + session_id = session_id, + request_id = "observe." .. turn, + event_id = "event." .. turn, + tick = tick, + observer_ids = { actor_id }, + source = "luanti-example", + kind = "dialogue", + summary = message, + tags = { "conversation", "player-request" }, + importance = 3, + }, function(_, observe_error) + if observe_error then failed(name, observe_error); return end + client:submit_proposal_job({ + protocol_version = rin.PROTOCOL_VERSION, + session_id = session_id, + request_id = "propose." .. turn, + actor_id = actor_id, + tick = tick + 1, + intent = "Choose one bounded response to the player.", + tags = { "conversation" }, + candidate_actions = { + { id = "talk", kind = "dialogue", description = "offer one concrete hint" }, + { id = "wait", kind = "wait", description = "ask the player to observe first" }, + { id = "refuse", kind = "refuse", description = "decline an unsafe request" }, + }, + }, function(queued, queue_error) + if queue_error then failed(name, queue_error); return end + client:wait_for_proposal(queued.job_id, nil, function(job, job_error) + if job_error then failed(name, job_error); return end + apply_and_commit(name, session_id, turn, tick + 2, job) + end) + end) + end) + end) +end + +core.register_chatcommand("rin_npc", { + params = "[message]", + description = "Ask the example Rin guide for one bounded action.", + func = function(name, param) + local message = tostring(param or ""):gsub("[%z\r\n]", " "):gsub("%s+", " "):sub(1, 300) + if message == "" then message = "The player asked what to do next." end + request_turn(name, message) + return true, "Rin request started." + end, +}) diff --git a/examples/mods/luanti-rin-npc/mod.conf b/examples/mods/luanti-rin-npc/mod.conf new file mode 100644 index 0000000..208301c --- /dev/null +++ b/examples/mods/luanti-rin-npc/mod.conf @@ -0,0 +1,5 @@ +name = rin_npc_example +title = Rin NPC Example +description = Reference Luanti integration for a Rin-backed NPC turn. +author = sunrioa +release = 1 diff --git a/examples/mods/luanti-rin-npc/rin.lua b/examples/mods/luanti-rin-npc/rin.lua new file mode 100644 index 0000000..5b10841 --- /dev/null +++ b/examples/mods/luanti-rin-npc/rin.lua @@ -0,0 +1,339 @@ +local rin = { + PROTOCOL_VERSION = "rin.protocol/v1", + DEFAULT_BASE_URL = "http://127.0.0.1:7374", + DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024, +} + +local Client = {} +Client.__index = Client + +local terminal_job_states = { + succeeded = true, + failed = true, + stale = true, + canceled = true, +} + +local function safe_text(value, maximum, fallback) + local text = tostring(value or ""):gsub("%z", " "):gsub("%s+", " ") + text = text:match("^%s*(.-)%s*$") or "" + if text == "" then text = fallback or "" end + return text:sub(1, maximum) +end + +local function failure(code, message, status, field) + return { + code = safe_text(code, 96, "rin_error"), + message = safe_text(message, 500, "Rin request failed"), + status = tonumber(status) or 0, + field = safe_text(field, 160, ""), + } +end + +local function validate_token(value) + local token = tostring(value or "") + if #token > 4096 or token:find("[%z\r\n]") or token:match("^%s") or token:match("%s$") then + return nil, failure("invalid_token", "Rin token must be a bounded single-line value") + end + return token +end + +local function is_loopback(host) + host = host:lower() + if host == "localhost" or host == "::1" or host == "0:0:0:0:0:0:0:1" then return true end + local first, second, third, fourth = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") + if not first then return false end + local octets = { tonumber(first), tonumber(second), tonumber(third), tonumber(fourth) } + if octets[1] ~= 127 then return false end + for index = 1, 4 do + if octets[index] < 0 or octets[index] > 255 then return false end + end + return true +end + +local function normalize_base_url(value, token) + local base_url = tostring(value or rin.DEFAULT_BASE_URL):match("^%s*(.-)%s*$") + while base_url:sub(-1) == "/" do base_url = base_url:sub(1, -2) end + local scheme, authority = base_url:match("^(https?)://([^/%?#]+)$") + if not scheme or authority:find("@", 1, true) then + return nil, failure("invalid_base_url", "Rin base URL must be an origin") + end + + local host, port + if authority:sub(1, 1) == "[" then + host, port = authority:match("^%[([^%]]+)%]:(%d+)$") + if not host then host = authority:match("^%[([^%]]+)%]$") end + else + host, port = authority:match("^([^:]+):(%d+)$") + if not host and not authority:find(":", 1, true) then host = authority end + end + if not host or host == "" then + return nil, failure("invalid_base_url", "Rin base URL must be an origin") + end + if port and (tonumber(port) < 1 or tonumber(port) > 65535) then + return nil, failure("invalid_base_url", "Rin base URL has an invalid port") + end + + local loopback = is_loopback(host) + if scheme == "http" and not loopback then + return nil, failure("insecure_base_url", "Remote Rin endpoints must use HTTPS") + end + if not loopback and token == "" then + return nil, failure("missing_token", "Remote Rin endpoints require a token") + end + return base_url +end + +local function path_id(value) + local text = tostring(value or "") + if #text < 1 or #text > 96 then + return nil, failure("invalid_identifier", "Rin path identifier is invalid") + end + for index = 1, #text do + local byte = text:byte(index) + local valid = (byte >= 48 and byte <= 57) or (byte >= 65 and byte <= 90) or + (byte >= 97 and byte <= 122) or byte == 45 or byte == 46 or byte == 95 + if not valid then + return nil, failure("invalid_identifier", "Rin path identifier is invalid") + end + end + return text +end + +local function header_value(headers, wanted) + if type(headers) ~= "table" then return nil end + wanted = wanted:lower() + for key, value in pairs(headers) do + if tostring(key):lower() == wanted then return tostring(value) end + end + return nil +end + +function rin.new(options) + options = options or {} + if type(options) ~= "table" then + return nil, failure("invalid_options", "Rin options must be a table") + end + if type(options.http_fetch) ~= "function" or type(options.json_encode) ~= "function" or + type(options.json_decode) ~= "function" then + return nil, failure("missing_adapter", "http_fetch, json_encode, and json_decode are required") + end + + local token, token_error = validate_token(options.token) + if not token then return nil, token_error end + local base_url, url_error = normalize_base_url(options.base_url, token) + if not base_url then return nil, url_error end + local timeout = tonumber(options.timeout or 5) + local max_response_bytes = tonumber(options.max_response_bytes or rin.DEFAULT_MAX_RESPONSE_BYTES) + if not timeout or timeout ~= timeout or timeout < 0.05 or timeout > 120 then + return nil, failure("invalid_timeout", "Timeout must be between 0.05 and 120 seconds") + end + if not max_response_bytes or max_response_bytes ~= math.floor(max_response_bytes) or + max_response_bytes < 1024 or max_response_bytes > 32 * 1024 * 1024 then + return nil, failure("invalid_response_limit", "Response limit must be between 1 KiB and 32 MiB") + end + + return setmetatable({ + base_url = base_url, + token = token, + timeout = timeout, + max_response_bytes = max_response_bytes, + http_fetch = options.http_fetch, + json_encode = options.json_encode, + json_decode = options.json_decode, + schedule = options.schedule, + now = options.now or os.time, + }, Client) +end + +function Client:_request(method, path, payload, expected_status, callback) + if type(callback) ~= "function" then error("Rin callback is required", 2) end + if type(path) ~= "string" or path:sub(1, 1) ~= "/" or path:find("//", 1, true) or path:find("..", 1, true) then + callback(nil, failure("invalid_path", "Rin request path is invalid")) + return + end + + local body + if payload ~= nil then + if type(payload) ~= "table" then + callback(nil, failure("invalid_request", "Rin payload must be an object")) + return + end + local encoded, value = pcall(self.json_encode, payload) + if not encoded or type(value) ~= "string" then + callback(nil, failure("invalid_request", "Rin payload is not JSON serializable")) + return + end + body = value + end + + local headers = { + ["Accept"] = "application/json", + ["User-Agent"] = "rin-lua/0.5", + } + if body then headers["Content-Type"] = "application/json; charset=utf-8" end + if self.token ~= "" then headers["Authorization"] = "Bearer " .. self.token end + local request = { + url = self.base_url .. path, + method = method, + headers = headers, + body = body, + timeout = self.timeout, + follow_redirects = false, + } + + local delivered = false + local function finish(data, err) + if delivered then return end + delivered = true + callback(data, err) + end + local started, start_error = pcall(self.http_fetch, request, function(response) + if type(response) ~= "table" then + finish(nil, failure("transport_failed", "Rin transport returned an invalid response")) + return + end + local status = tonumber(response.status) + if not status or status ~= math.floor(status) or status < 100 or status > 599 then + finish(nil, failure("transport_failed", "Rin transport did not return a valid status")) + return + end + if status >= 300 and status < 400 then + finish(nil, failure("redirect_rejected", "Rin endpoint attempted to redirect", status)) + return + end + local raw = response.body + if type(raw) ~= "string" then + finish(nil, failure("invalid_response", "Rin response body must be a string", status)) + return + end + local declared_text = header_value(response.headers, "content-length") + local declared = declared_text and tonumber(declared_text) or nil + if declared_text and (not declared or declared < 0 or declared ~= math.floor(declared)) then + finish(nil, failure("invalid_response", "Rin returned an invalid Content-Length", status)) + return + end + if (declared and declared > self.max_response_bytes) or #raw > self.max_response_bytes then + finish(nil, failure("response_too_large", "Rin response exceeds the configured limit", status)) + return + end + + local decoded, envelope = pcall(self.json_decode, raw) + if not decoded or type(envelope) ~= "table" then + if status ~= expected_status then + finish(nil, failure("http_error", "Rin request failed", status)) + else + finish(nil, failure("invalid_response", "Rin returned invalid JSON", status)) + end + return + end + if status ~= expected_status or envelope.ok ~= true then + local detail = type(envelope.error) == "table" and envelope.error or {} + finish(nil, failure(detail.code or "http_error", detail.message or "Rin request failed", status, detail.field)) + return + end + if type(envelope.data) ~= "table" then + finish(nil, failure("invalid_response", "Rin response data must be an object", status)) + return + end + finish(envelope.data, nil) + end) + if not started then + if delivered then error(start_error, 0) end + finish(nil, failure("transport_failed", "Rin transport could not start")) + end +end + +function Client:_post(path, payload, status, callback) + self:_request("POST", path, payload, status or 200, callback) +end + +function Client:health(callback) self:_request("GET", "/health", nil, 200, callback) end +function Client:create_session(payload, callback) self:_post("/v1/session/create", payload, 200, callback) end +function Client:observe(payload, callback) self:_post("/v1/session/observe", payload, 200, callback) end +function Client:propose(payload, callback) self:_post("/v1/agent/propose", payload, 200, callback) end +function Client:submit_proposal_job(payload, callback) self:_post("/v1/jobs/propose", payload, 202, callback) end +function Client:get_proposal_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("GET", "/v1/jobs/" .. id, nil, 200, callback) +end +function Client:cancel_proposal_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("DELETE", "/v1/jobs/" .. id, nil, 200, callback) +end +function Client:submit_generation_job(payload, callback) self:_post("/v1/generation/jobs", payload, 202, callback) end +function Client:get_generation_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("GET", "/v1/generation/jobs/" .. id, nil, 200, callback) +end +function Client:cancel_generation_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("DELETE", "/v1/generation/jobs/" .. id, nil, 200, callback) +end +function Client:commit(payload, callback) self:_post("/v1/action/commit", payload, 200, callback) end +function Client:commit_batch(payload, callback) self:_post("/v1/action/commit-batch", payload, 200, callback) end +function Client:set_actor_activity(payload, callback) self:_post("/v1/session/activity", payload, 200, callback) end +function Client:arbitrate(payload, callback) self:_post("/v1/world/arbitrate", payload, 200, callback) end +function Client:state(payload, callback) self:_post("/v1/session/get", payload, 200, callback) end +function Client:snapshot(payload, callback) self:_post("/v1/session/snapshot", payload, 200, callback) end +function Client:restore(payload, callback) self:_post("/v1/session/restore", payload, 200, callback) end +function Client:timeline(payload, callback) self:_post("/v1/session/timeline", payload, 200, callback) end +function Client:replay(payload, callback) self:_post("/v1/session/replay", payload, 200, callback) end +function Client:due_agents(payload, callback) self:_post("/v1/scheduler/due", payload, 200, callback) end + +function Client:_wait_job(job_id, getter, canceler, options, callback) + options = options or {} + local deadline = tonumber(options.deadline or 25) + local interval = tonumber(options.interval or 0.1) + if type(self.schedule) ~= "function" then + callback(nil, failure("missing_scheduler", "A scheduler is required to wait for jobs")) + return + end + if not deadline or deadline ~= deadline or deadline < 0.05 or deadline > 300 or + not interval or interval ~= interval or interval < 0.01 or interval > 5 then + callback(nil, failure("invalid_polling", "Job deadline or interval is out of range")) + return + end + local expires = self.now() + deadline + local poll + poll = function() + getter(self, job_id, function(job, err) + if err then callback(nil, err); return end + local status = tostring(job.status or "") + if status == "succeeded" then callback(job, nil); return end + if terminal_job_states[status] then + local detail = type(job.error) == "table" and job.error or {} + callback(nil, failure(detail.code or ("job_" .. status), detail.message or ("Rin job ended as " .. status))) + return + end + if status ~= "queued" and status ~= "running" then + callback(nil, failure("invalid_job", "Rin returned an unknown job status")) + return + end + if self.now() >= expires then + canceler(self, job_id, function() end) + callback(nil, failure("job_timeout", "Rin job exceeded its deadline")) + return + end + self.schedule(interval, poll) + end) + end + poll() +end + +function Client:wait_for_proposal(job_id, options, callback) + self:_wait_job(job_id, Client.get_proposal_job, Client.cancel_proposal_job, options, callback) +end + +function Client:wait_for_generation(job_id, options, callback) + local configured = {} + for key, value in pairs(options or {}) do configured[key] = value end + if configured.deadline == nil then configured.deadline = 45 end + self:_wait_job(job_id, Client.get_generation_job, Client.cancel_generation_job, configured, callback) +end + +return rin diff --git a/examples/mods/luanti-rin-npc/settingtypes.txt b/examples/mods/luanti-rin-npc/settingtypes.txt new file mode 100644 index 0000000..f65dfc4 --- /dev/null +++ b/examples/mods/luanti-rin-npc/settingtypes.txt @@ -0,0 +1,2 @@ +# Rin local origin. This example intentionally rejects remote origins and tokens. +rin_npc_example.base_url (Rin local origin) string http://127.0.0.1:7374 diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..ace7e86 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,34 @@ +# Rin SDKs + +[English](README.md) | [简体中文](README.zh-CN.md) + +These source SDKs expose the same `rin.protocol/v1` HTTP boundary without +moving game authority into the client library. + +| Language | Runtime | JSON | Async guidance | +| --- | --- | --- | --- | +| Python | 3.9+ | standard library | call from a worker in real-time games | +| JavaScript | Node 18+ / modern browser host | built in | Promise-based | +| C# | .NET 6+ | `System.Text.Json` | `Task`-based | +| Java | 17+ | host-provided JSON text | `CompletableFuture`-based | +| Lua | 5.1+ host | injected codec and transport | callback-based | + +All clients follow these rules: + +- plaintext HTTP is accepted only for an explicit loopback origin; +- remote origins require HTTPS and a bearer token; +- redirects are rejected; +- request timeouts and response-size limits are mandatory; +- errors expose bounded Rin codes, not provider bodies or credentials; +- proposals remain pending until the game applies and commits them. + +The SDKs are intentionally source-first and are not yet published to PyPI, +npm, NuGet, or Maven Central. Pin this repository revision when vendoring one. +Route compatibility is defined by [`conformance/routes.json`](conformance/routes.json). + +Game-specific examples live under [`examples/mods`](../examples/mods). They +show where host events enter Rin and where the game validates and applies a +proposal. They are integration templates, not universal patches for every +game version. + +The SDK source is released under the [MIT License](../LICENSE). diff --git a/sdk/README.zh-CN.md b/sdk/README.zh-CN.md new file mode 100644 index 0000000..878370f --- /dev/null +++ b/sdk/README.zh-CN.md @@ -0,0 +1,33 @@ +# Rin SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +这些源码 SDK 暴露同一个 `rin.protocol/v1` HTTP 边界,不会把游戏权威移动 +到客户端库。 + +| 语言 | 运行时 | JSON | 异步建议 | +| --- | --- | --- | --- | +| Python | 3.9+ | 标准库 | 实时游戏从 Worker 调用 | +| JavaScript | Node 18+ / 现代浏览器宿主 | 内置 | 基于 Promise | +| C# | .NET 6+ | `System.Text.Json` | 基于 `Task` | +| Java | 17+ | 宿主提供 JSON 文本 | 基于 `CompletableFuture` | +| Lua | 5.1+ 宿主 | 注入 Codec 与 Transport | 基于 Callback | + +所有客户端遵循以下规则: + +- 只对显式 loopback Origin 接受明文 HTTP; +- 远程 Origin 要求 HTTPS 和 Bearer Token; +- 拒绝重定向; +- 强制请求超时和响应大小限制; +- 错误只暴露有界 Rin Code,不暴露供应商正文或凭据; +- Proposal 保持 Pending,直到游戏应用并 Commit。 + +SDK 有意采用源码优先方式,尚未发布到 PyPI、npm、NuGet 或 Maven Central。 +Vendor 时应固定本仓库 Revision。路由兼容性由 +[`conformance/routes.json`](conformance/routes.json) 定义。 + +游戏专用示例位于 [`examples/mods`](../examples/mods)。它们展示宿主事件 +如何进入 Rin,以及游戏在何处验证并应用 Proposal。它们是接入模板,不是 +适用于每个游戏版本的通用补丁。 + +SDK 源码按 [MIT License](../LICENSE) 发布。 diff --git a/sdk/conformance/routes.json b/sdk/conformance/routes.json new file mode 100644 index 0000000..69bb69a --- /dev/null +++ b/sdk/conformance/routes.json @@ -0,0 +1,26 @@ +{ + "schema_version": 1, + "protocol_version": "rin.protocol/v1", + "operations": [ + {"name": "health", "method": "GET", "path": "/health", "status": 200}, + {"name": "create_session", "method": "POST", "path": "/v1/session/create", "status": 200}, + {"name": "observe", "method": "POST", "path": "/v1/session/observe", "status": 200}, + {"name": "propose", "method": "POST", "path": "/v1/agent/propose", "status": 200}, + {"name": "submit_proposal_job", "method": "POST", "path": "/v1/jobs/propose", "status": 202}, + {"name": "get_proposal_job", "method": "GET", "path": "/v1/jobs/{job_id}", "status": 200}, + {"name": "cancel_proposal_job", "method": "DELETE", "path": "/v1/jobs/{job_id}", "status": 200}, + {"name": "submit_generation_job", "method": "POST", "path": "/v1/generation/jobs", "status": 202}, + {"name": "get_generation_job", "method": "GET", "path": "/v1/generation/jobs/{job_id}", "status": 200}, + {"name": "cancel_generation_job", "method": "DELETE", "path": "/v1/generation/jobs/{job_id}", "status": 200}, + {"name": "commit", "method": "POST", "path": "/v1/action/commit", "status": 200}, + {"name": "commit_batch", "method": "POST", "path": "/v1/action/commit-batch", "status": 200}, + {"name": "set_actor_activity", "method": "POST", "path": "/v1/session/activity", "status": 200}, + {"name": "arbitrate", "method": "POST", "path": "/v1/world/arbitrate", "status": 200}, + {"name": "state", "method": "POST", "path": "/v1/session/get", "status": 200}, + {"name": "snapshot", "method": "POST", "path": "/v1/session/snapshot", "status": 200}, + {"name": "restore", "method": "POST", "path": "/v1/session/restore", "status": 200}, + {"name": "timeline", "method": "POST", "path": "/v1/session/timeline", "status": 200}, + {"name": "replay", "method": "POST", "path": "/v1/session/replay", "status": 200}, + {"name": "due_agents", "method": "POST", "path": "/v1/scheduler/due", "status": 200} + ] +} diff --git a/sdk/csharp/README.md b/sdk/csharp/README.md new file mode 100644 index 0000000..a13ed34 --- /dev/null +++ b/sdk/csharp/README.md @@ -0,0 +1,28 @@ +# Rin C# SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +`Rin.Client` targets .NET 6+ and uses only `HttpClient` and +`System.Text.Json`. Keep one client for the lifetime of the plugin or game. + +```csharp +using Rin.Client; + +using var rin = new RinClient(new RinClientOptions +{ + BaseUrl = "http://127.0.0.1:7374", + Token = Environment.GetEnvironmentVariable("RIN_TOKEN") ?? "", +}); + +var health = await rin.HealthAsync(); +Console.WriteLine(health.GetProperty("status").GetString()); +``` + +Build the source project with: + +```bash +dotnet run --project sdk/csharp/Rin.Client.Tests/Rin.Client.Tests.csproj +``` + +Unity and BepInEx callers must await off the render loop, then marshal the +validated result back to Unity's main thread before touching game objects. diff --git a/sdk/csharp/README.zh-CN.md b/sdk/csharp/README.zh-CN.md new file mode 100644 index 0000000..e473b85 --- /dev/null +++ b/sdk/csharp/README.zh-CN.md @@ -0,0 +1,28 @@ +# Rin C# SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +`Rin.Client` 面向 .NET 6+,只使用 `HttpClient` 和 `System.Text.Json`。 +在插件或游戏生命周期内复用一个 Client。 + +```csharp +using Rin.Client; + +using var rin = new RinClient(new RinClientOptions +{ + BaseUrl = "http://127.0.0.1:7374", + Token = Environment.GetEnvironmentVariable("RIN_TOKEN") ?? "", +}); + +var health = await rin.HealthAsync(); +Console.WriteLine(health.GetProperty("status").GetString()); +``` + +构建并运行源码测试: + +```bash +dotnet run --project sdk/csharp/Rin.Client.Tests/Rin.Client.Tests.csproj +``` + +Unity 和 BepInEx 调用方必须在渲染循环外 `await`,验证结果后再切回 Unity +主线程操作 GameObject。 diff --git a/sdk/csharp/Rin.Client.Tests/Program.cs b/sdk/csharp/Rin.Client.Tests/Program.cs new file mode 100644 index 0000000..6e83c64 --- /dev/null +++ b/sdk/csharp/Rin.Client.Tests/Program.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Text; +using Rin.Client; + +var handler = new RecordingHandler(); +using var client = new RinClient(new RinClientOptions { Token = "fixture" }, handler); +var payload = new Dictionary(); +var cases = new (Func Call, HttpMethod Method, string Path)[] +{ + (async () => await client.HealthAsync(), HttpMethod.Get, "/health"), + (async () => await client.CreateSessionAsync(payload), HttpMethod.Post, "/v1/session/create"), + (async () => await client.ObserveAsync(payload), HttpMethod.Post, "/v1/session/observe"), + (async () => await client.ProposeAsync(payload), HttpMethod.Post, "/v1/agent/propose"), + (async () => await client.SubmitProposalJobAsync(payload), HttpMethod.Post, "/v1/jobs/propose"), + (async () => await client.GetProposalJobAsync("job.fixture"), HttpMethod.Get, "/v1/jobs/job.fixture"), + (async () => await client.CancelProposalJobAsync("job.fixture"), HttpMethod.Delete, "/v1/jobs/job.fixture"), + (async () => await client.SubmitGenerationJobAsync(payload), HttpMethod.Post, "/v1/generation/jobs"), + (async () => await client.GetGenerationJobAsync("job.fixture"), HttpMethod.Get, "/v1/generation/jobs/job.fixture"), + (async () => await client.CancelGenerationJobAsync("job.fixture"), HttpMethod.Delete, "/v1/generation/jobs/job.fixture"), + (async () => await client.CommitAsync(payload), HttpMethod.Post, "/v1/action/commit"), + (async () => await client.CommitBatchAsync(payload), HttpMethod.Post, "/v1/action/commit-batch"), + (async () => await client.SetActorActivityAsync(payload), HttpMethod.Post, "/v1/session/activity"), + (async () => await client.ArbitrateAsync(payload), HttpMethod.Post, "/v1/world/arbitrate"), + (async () => await client.StateAsync(payload), HttpMethod.Post, "/v1/session/get"), + (async () => await client.SnapshotAsync(payload), HttpMethod.Post, "/v1/session/snapshot"), + (async () => await client.RestoreAsync(payload), HttpMethod.Post, "/v1/session/restore"), + (async () => await client.TimelineAsync(payload), HttpMethod.Post, "/v1/session/timeline"), + (async () => await client.ReplayAsync(payload), HttpMethod.Post, "/v1/session/replay"), + (async () => await client.DueAgentsAsync(payload), HttpMethod.Post, "/v1/scheduler/due"), +}; + +foreach (var test in cases) +{ + await test.Call(); + Require(handler.Method == test.Method, "wrong method for " + test.Path); + Require(handler.Path == test.Path, "wrong path for " + test.Path); + Require(handler.Authorization == "Bearer fixture", "missing bearer token"); +} + +RequireThrows(() => new RinClient(new RinClientOptions +{ + BaseUrl = "http://models.example", + Token = "fixture", +}), "remote HTTP origin was accepted"); +RequireThrows(() => new RinClient(new RinClientOptions +{ + BaseUrl = "https://models.example", +}), "remote origin without token was accepted"); +RequireThrows( + () => client.GetProposalJobAsync("\u4f5c\u4e1a"), + "Unicode path ID was accepted"); + +var oversized = new RecordingHandler { DeclaredLength = 2048 }; +using var limited = new RinClient(new RinClientOptions { MaxResponseBytes = 1024 }, oversized); +try +{ + await limited.HealthAsync(); + throw new InvalidOperationException("oversized response was accepted"); +} +catch (RinProtocolException exception) +{ + Require(exception.Code == "response_too_large", "wrong response limit error"); +} + +var slow = new RecordingHandler { ContentFactory = () => new StreamContent(new SlowStream()) }; +using var impatient = new RinClient(new RinClientOptions { Timeout = TimeSpan.FromMilliseconds(50) }, slow); +try +{ + await impatient.HealthAsync(); + throw new InvalidOperationException("slow response exceeded the request deadline"); +} +catch (RinTransportException exception) +{ + Require(exception.Code == "transport_timeout", "wrong timeout error"); +} + +Console.WriteLine("Rin C# SDK tests passed"); + +static void Require(bool condition, string message) +{ + if (!condition) throw new InvalidOperationException(message); +} + +static void RequireThrows(Action action, string message) where TException : Exception +{ + try + { + action(); + throw new InvalidOperationException(message); + } + catch (TException) + { + } +} + +sealed class RecordingHandler : HttpMessageHandler +{ + public HttpMethod? Method { get; private set; } + public string Path { get; private set; } = string.Empty; + public string Authorization { get; private set; } = string.Empty; + public long? DeclaredLength { get; init; } + public Func? ContentFactory { get; init; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Method = request.Method; + Path = request.RequestUri?.AbsolutePath ?? string.Empty; + Authorization = request.Headers.TryGetValues("Authorization", out var values) + ? values.Single() + : string.Empty; + var status = Path is "/v1/jobs/propose" or "/v1/generation/jobs" + ? HttpStatusCode.Accepted + : HttpStatusCode.OK; + var content = ContentFactory?.Invoke() ?? + new ByteArrayContent(Encoding.UTF8.GetBytes("{\"ok\":true,\"data\":{\"status\":\"ok\"}}")); + if (DeclaredLength.HasValue) content.Headers.ContentLength = DeclaredLength.Value; + return Task.FromResult(new HttpResponseMessage(status) { Content = content }); + } +} + +sealed class SlowStream : Stream +{ + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } +} diff --git a/sdk/csharp/Rin.Client.Tests/Rin.Client.Tests.csproj b/sdk/csharp/Rin.Client.Tests/Rin.Client.Tests.csproj new file mode 100644 index 0000000..67e3f8b --- /dev/null +++ b/sdk/csharp/Rin.Client.Tests/Rin.Client.Tests.csproj @@ -0,0 +1,12 @@ + + + Exe + net6.0 + enable + enable + true + + + + + diff --git a/sdk/csharp/Rin.Client/AssemblyInfo.cs b/sdk/csharp/Rin.Client/AssemblyInfo.cs new file mode 100644 index 0000000..42a2b0d --- /dev/null +++ b/sdk/csharp/Rin.Client/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Rin.Client.Tests")] diff --git a/sdk/csharp/Rin.Client/Rin.Client.csproj b/sdk/csharp/Rin.Client/Rin.Client.csproj new file mode 100644 index 0000000..5c3c3c0 --- /dev/null +++ b/sdk/csharp/Rin.Client/Rin.Client.csproj @@ -0,0 +1,12 @@ + + + net6.0 + enable + enable + true + Rin.Game.Client + 0.5.0 + Dependency-free .NET client for the Rin game agent runtime. + MIT + + diff --git a/sdk/csharp/Rin.Client/RinClient.cs b/sdk/csharp/Rin.Client/RinClient.cs new file mode 100644 index 0000000..e7474ae --- /dev/null +++ b/sdk/csharp/Rin.Client/RinClient.cs @@ -0,0 +1,388 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Rin.Client; + +public sealed class RinClient : IDisposable +{ + public const string ProtocolVersion = "rin.protocol/v1"; + public const string DefaultBaseUrl = "http://127.0.0.1:7374"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient httpClient; + private readonly string baseUrl; + private readonly string token; + private readonly TimeSpan timeout; + private readonly int maxResponseBytes; + + public RinClient(RinClientOptions? options = null) + : this(options, CreateHandler()) + { + } + + internal RinClient(RinClientOptions? options, HttpMessageHandler handler) + { + options ??= new RinClientOptions(); + token = ValidateToken(options.Token); + baseUrl = NormalizeBaseUrl(options.BaseUrl, token); + timeout = options.Timeout; + if (timeout < TimeSpan.FromMilliseconds(50) || timeout > TimeSpan.FromSeconds(120)) + { + throw new RinConfigurationException("invalid_timeout", "Timeout must be between 50 ms and 120 seconds"); + } + maxResponseBytes = options.MaxResponseBytes; + if (maxResponseBytes < 1024 || maxResponseBytes > 32 * 1024 * 1024) + { + throw new RinConfigurationException("invalid_response_limit", "Response limit must be between 1 KiB and 32 MiB"); + } + + httpClient = new HttpClient(handler ?? throw new ArgumentNullException(nameof(handler)), disposeHandler: true) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("rin-csharp/0.5"); + } + + public Task HealthAsync(CancellationToken cancellationToken = default) => + RequestAsync(HttpMethod.Get, "/health", null, 200, cancellationToken); + + public Task CreateSessionAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/create", payload, 200, cancellationToken); + + public Task ObserveAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/observe", payload, 200, cancellationToken); + + public Task ProposeAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/agent/propose", payload, 200, cancellationToken); + + public Task SubmitProposalJobAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/jobs/propose", payload, 202, cancellationToken); + + public Task GetProposalJobAsync(string jobId, CancellationToken cancellationToken = default) => + RequestAsync(HttpMethod.Get, "/v1/jobs/" + PathId(jobId), null, 200, cancellationToken); + + public Task CancelProposalJobAsync(string jobId, CancellationToken cancellationToken = default) => + RequestAsync(HttpMethod.Delete, "/v1/jobs/" + PathId(jobId), null, 200, cancellationToken); + + public Task SubmitGenerationJobAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/generation/jobs", payload, 202, cancellationToken); + + public Task GetGenerationJobAsync(string jobId, CancellationToken cancellationToken = default) => + RequestAsync(HttpMethod.Get, "/v1/generation/jobs/" + PathId(jobId), null, 200, cancellationToken); + + public Task CancelGenerationJobAsync(string jobId, CancellationToken cancellationToken = default) => + RequestAsync(HttpMethod.Delete, "/v1/generation/jobs/" + PathId(jobId), null, 200, cancellationToken); + + public Task CommitAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/action/commit", payload, 200, cancellationToken); + + public Task CommitBatchAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/action/commit-batch", payload, 200, cancellationToken); + + public Task SetActorActivityAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/activity", payload, 200, cancellationToken); + + public Task ArbitrateAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/world/arbitrate", payload, 200, cancellationToken); + + public Task StateAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/get", payload, 200, cancellationToken); + + public Task SnapshotAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/snapshot", payload, 200, cancellationToken); + + public Task RestoreAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/restore", payload, 200, cancellationToken); + + public Task TimelineAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/timeline", payload, 200, cancellationToken); + + public Task ReplayAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/replay", payload, 200, cancellationToken); + + public Task DueAgentsAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/scheduler/due", payload, 200, cancellationToken); + + public Task WaitForProposalAsync( + string jobId, + TimeSpan? deadline = null, + TimeSpan? interval = null, + CancellationToken cancellationToken = default) => + WaitForJobAsync( + jobId, + GetProposalJobAsync, + CancelProposalJobAsync, + deadline ?? TimeSpan.FromSeconds(25), + interval ?? TimeSpan.FromMilliseconds(100), + cancellationToken); + + public Task WaitForGenerationAsync( + string jobId, + TimeSpan? deadline = null, + TimeSpan? interval = null, + CancellationToken cancellationToken = default) => + WaitForJobAsync( + jobId, + GetGenerationJobAsync, + CancelGenerationJobAsync, + deadline ?? TimeSpan.FromSeconds(45), + interval ?? TimeSpan.FromMilliseconds(100), + cancellationToken); + + public void Dispose() => httpClient.Dispose(); + + private static HttpMessageHandler CreateHandler() => new HttpClientHandler + { + AllowAutoRedirect = false, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + }; + + private static async Task WaitForJobAsync( + string jobId, + Func> getter, + Func> canceler, + TimeSpan deadline, + TimeSpan interval, + CancellationToken cancellationToken) + { + if (deadline < TimeSpan.FromMilliseconds(50) || deadline > TimeSpan.FromMinutes(5) || + interval < TimeSpan.FromMilliseconds(10) || interval > TimeSpan.FromSeconds(5)) + { + throw new RinConfigurationException("invalid_polling", "Job deadline or interval is out of range"); + } + var elapsed = Stopwatch.StartNew(); + while (true) + { + var job = await getter(jobId, cancellationToken).ConfigureAwait(false); + var status = TextProperty(job, "status", 32); + if (status == "succeeded") return job; + if (status is "failed" or "stale" or "canceled") + { + var detail = job.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object + ? error + : default; + throw new RinApiException( + TextProperty(detail, "code", 96, "job_" + status), + TextProperty(detail, "message", 500, "Rin job ended as " + status)); + } + if (status is not ("queued" or "running")) + { + throw new RinProtocolException("invalid_job", "Rin returned an unknown job status"); + } + var remaining = deadline - elapsed.Elapsed; + if (remaining <= TimeSpan.Zero) + { + try { await canceler(jobId, CancellationToken.None).ConfigureAwait(false); } + catch (RinException) { } + throw new RinApiException("job_timeout", "Rin job exceeded its deadline"); + } + await Task.Delay(interval < remaining ? interval : remaining, cancellationToken).ConfigureAwait(false); + } + } + + private Task PostAsync(string path, object payload, int expectedStatus, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(payload); + return RequestAsync(HttpMethod.Post, path, payload, expectedStatus, cancellationToken); + } + + private async Task RequestAsync( + HttpMethod method, + string path, + object? payload, + int expectedStatus, + CancellationToken cancellationToken) + { + if (!path.StartsWith("/", StringComparison.Ordinal) || path.Contains("//", StringComparison.Ordinal) || path.Contains("..", StringComparison.Ordinal)) + { + throw new RinConfigurationException("invalid_path", "Rin request path is invalid"); + } + + using var request = new HttpRequestMessage(method, baseUrl + path); + if (payload is not null) + { + byte[] encoded; + try + { + encoded = JsonSerializer.SerializeToUtf8Bytes(payload, payload.GetType(), JsonOptions); + } + catch (Exception exception) when (exception is JsonException or NotSupportedException) + { + throw new RinProtocolException("invalid_request", "Rin payload is not JSON serializable", exception); + } + request.Content = new ByteArrayContent(encoded); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" }; + } + if (token.Length > 0) + { + request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token); + } + + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(timeout); + HttpResponseMessage response; + try + { + response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new RinTransportException("transport_timeout", "Rin request timed out", exception); + } + catch (HttpRequestException exception) + { + throw new RinTransportException("transport_failed", "Rin is unavailable", exception); + } + + using (response) + { + if (response.Headers.Location is not null && (int)response.StatusCode is >= 300 and < 400) + { + throw new RinTransportException("redirect_rejected", "Rin endpoint attempted to redirect"); + } + if (response.Content.Headers.ContentLength is long declared && declared > maxResponseBytes) + { + throw new RinProtocolException("response_too_large", "Rin response exceeds the configured limit"); + } + + byte[] raw; + try + { + raw = await ReadBoundedAsync(response.Content, deadline.Token).ConfigureAwait(false); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new RinTransportException("transport_timeout", "Rin request timed out", exception); + } + catch (Exception exception) when (exception is HttpRequestException or IOException) + { + throw new RinTransportException("transport_failed", "Rin response could not be read", exception); + } + JsonDocument document; + try + { + document = JsonDocument.Parse(raw); + } + catch (JsonException exception) + { + if ((int)response.StatusCode != expectedStatus) + { + throw new RinApiException("http_error", "Rin request failed", (int)response.StatusCode); + } + throw new RinProtocolException("invalid_response", "Rin returned invalid JSON", exception); + } + + using (document) + { + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new RinProtocolException("invalid_response", "Rin response must be an object"); + } + var ok = root.TryGetProperty("ok", out var okElement) && okElement.ValueKind == JsonValueKind.True; + if ((int)response.StatusCode != expectedStatus || !ok) + { + throw ApiError(root, (int)response.StatusCode); + } + if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) + { + throw new RinProtocolException("invalid_response", "Rin response data must be an object"); + } + return data.Clone(); + } + } + } + + private async Task ReadBoundedAsync(HttpContent content, CancellationToken cancellationToken) + { + await using var stream = await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var output = new MemoryStream(); + var buffer = new byte[8192]; + while (true) + { + var count = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false); + if (count == 0) break; + if (output.Length + count > maxResponseBytes) + { + throw new RinProtocolException("response_too_large", "Rin response exceeds the configured limit"); + } + output.Write(buffer, 0, count); + } + return output.ToArray(); + } + + private static RinApiException ApiError(JsonElement root, int status) + { + var detail = root.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object + ? error + : default; + return new RinApiException( + TextProperty(detail, "code", 96, "http_error"), + TextProperty(detail, "message", 500, "Rin request failed"), + status, + TextProperty(detail, "field", 160)); + } + + private static string TextProperty(JsonElement element, string name, int maximum, string fallback = "") + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String) + { + return RinException.SafeText(value.GetString(), maximum, fallback); + } + return fallback; + } + + private static string NormalizeBaseUrl(string? value, string validatedToken) + { + if (!Uri.TryCreate((value ?? DefaultBaseUrl).Trim().TrimEnd('/'), UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) || + uri.Host.Length == 0 || uri.UserInfo.Length > 0 || uri.Query.Length > 0 || uri.Fragment.Length > 0 || + (uri.AbsolutePath.Length > 0 && uri.AbsolutePath != "/")) + { + throw new RinConfigurationException("invalid_base_url", "Rin base URL must be an origin"); + } + var loopback = uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || + (IPAddress.TryParse(uri.Host, out var address) && IPAddress.IsLoopback(address)); + if (uri.Scheme == Uri.UriSchemeHttp && !loopback) + { + throw new RinConfigurationException("insecure_base_url", "Remote Rin endpoints must use HTTPS"); + } + if (!loopback && validatedToken.Length == 0) + { + throw new RinConfigurationException("missing_token", "Remote Rin endpoints require a token"); + } + return uri.GetLeftPart(UriPartial.Authority); + } + + private static string ValidateToken(string? value) + { + var candidate = value ?? string.Empty; + if (candidate.Length > 4096 || candidate != candidate.Trim() || candidate.IndexOfAny(new[] { '\0', '\r', '\n' }) >= 0) + { + throw new RinConfigurationException("invalid_token", "Rin token must be a bounded single-line value"); + } + return candidate; + } + + private static string PathId(string? value) + { + if (string.IsNullOrEmpty(value) || value.Length > 96 || value.Any(character => + !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || + character is '.' or '_' or '-'))) + { + throw new RinConfigurationException("invalid_identifier", "Rin path identifier is invalid"); + } + return Uri.EscapeDataString(value); + } +} diff --git a/sdk/csharp/Rin.Client/RinClientOptions.cs b/sdk/csharp/Rin.Client/RinClientOptions.cs new file mode 100644 index 0000000..3ed738a --- /dev/null +++ b/sdk/csharp/Rin.Client/RinClientOptions.cs @@ -0,0 +1,12 @@ +namespace Rin.Client; + +public sealed class RinClientOptions +{ + public string BaseUrl { get; init; } = RinClient.DefaultBaseUrl; + + public string Token { get; init; } = string.Empty; + + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5); + + public int MaxResponseBytes { get; init; } = 2 * 1024 * 1024; +} diff --git a/sdk/csharp/Rin.Client/RinException.cs b/sdk/csharp/Rin.Client/RinException.cs new file mode 100644 index 0000000..f05a506 --- /dev/null +++ b/sdk/csharp/Rin.Client/RinException.cs @@ -0,0 +1,52 @@ +namespace Rin.Client; + +public class RinException : Exception +{ + public RinException(string code, string message, Exception? innerException = null) + : base(SafeText(message, 500, "Rin request failed"), innerException) + { + Code = SafeText(code, 96, "rin_error"); + } + + public string Code { get; } + + internal static string SafeText(string? value, int maximum, string fallback = "") + { + var cleaned = string.Join(" ", (value ?? string.Empty) + .Replace("\0", string.Empty, StringComparison.Ordinal) + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + return cleaned.Length == 0 ? fallback : cleaned[..Math.Min(cleaned.Length, maximum)]; + } +} + +public sealed class RinConfigurationException : RinException +{ + public RinConfigurationException(string code, string message, Exception? innerException = null) + : base(code, message, innerException) { } +} + +public sealed class RinTransportException : RinException +{ + public RinTransportException(string code, string message, Exception? innerException = null) + : base(code, message, innerException) { } +} + +public sealed class RinProtocolException : RinException +{ + public RinProtocolException(string code, string message, Exception? innerException = null) + : base(code, message, innerException) { } +} + +public sealed class RinApiException : RinException +{ + public RinApiException(string code, string message, int status = 0, string? field = null) + : base(code, message) + { + Status = status; + Field = SafeText(field, 160); + } + + public int Status { get; } + + public string Field { get; } +} diff --git a/sdk/java/README.md b/sdk/java/README.md new file mode 100644 index 0000000..a80511c --- /dev/null +++ b/sdk/java/README.md @@ -0,0 +1,30 @@ +# Rin Java SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +Requires Java 17+. Transport uses the JDK `HttpClient`; JSON is injected so a +game can reuse its existing Gson, Jackson, or engine codec without creating a +second dependency graph. + +```java +JsonCodec codec = new GsonJsonCodec(gameGson); +RinClient rin = new RinClient( + "http://127.0.0.1:7374", + System.getenv().getOrDefault("RIN_TOKEN", ""), + Duration.ofSeconds(5), + RinClient.DEFAULT_MAX_RESPONSE_BYTES, + codec +); + +rin.health().thenAccept(data -> System.out.println(data.get("status"))); +``` + +`JsonCodec.decodeObject` must reject a non-object root. Calls return +`CompletableFuture`; schedule any Minecraft or other engine mutation back on +the owning game thread. + +Compile the SDK and its dependency-free smoke test with JDK 17: + +```bash +make test-sdk-java +``` diff --git a/sdk/java/README.zh-CN.md b/sdk/java/README.zh-CN.md new file mode 100644 index 0000000..9463ca1 --- /dev/null +++ b/sdk/java/README.zh-CN.md @@ -0,0 +1,29 @@ +# Rin Java SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +要求 Java 17+。Transport 使用 JDK `HttpClient`;JSON 通过接口注入,因此 +游戏可以复用已有 Gson、Jackson 或引擎 Codec,不会产生第二套依赖图。 + +```java +JsonCodec codec = new GsonJsonCodec(gameGson); +RinClient rin = new RinClient( + "http://127.0.0.1:7374", + System.getenv().getOrDefault("RIN_TOKEN", ""), + Duration.ofSeconds(5), + RinClient.DEFAULT_MAX_RESPONSE_BYTES, + codec +); + +rin.health().thenAccept(data -> System.out.println(data.get("status"))); +``` + +`JsonCodec.decodeObject` 必须拒绝非 Object 根节点。调用返回 +`CompletableFuture`;Minecraft 或其他引擎状态修改必须重新安排到引擎 +拥有的游戏线程。 + +使用 JDK 17 编译 SDK 和无依赖 Smoke Test: + +```bash +make test-sdk-java +``` diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/JsonCodec.java b/sdk/java/src/main/java/io/github/sunrioa/rin/JsonCodec.java new file mode 100644 index 0000000..3905749 --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/JsonCodec.java @@ -0,0 +1,10 @@ +package io.github.sunrioa.rin; + +import java.util.Map; + +/** JSON boundary supplied by the host engine (for example Gson or Jackson). */ +public interface JsonCodec { + String encode(Map value) throws Exception; + + Map decodeObject(String json) throws Exception; +} diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/RinApiException.java b/sdk/java/src/main/java/io/github/sunrioa/rin/RinApiException.java new file mode 100644 index 0000000..10c88f8 --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/RinApiException.java @@ -0,0 +1,22 @@ +package io.github.sunrioa.rin; + +public final class RinApiException extends RinException { + private static final long serialVersionUID = 1L; + + private final int status; + private final String field; + + public RinApiException(String code, String message, int status, String field) { + super(code, message); + this.status = status; + this.field = safeText(field, 160, ""); + } + + public int status() { + return status; + } + + public String field() { + return field; + } +} diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java b/sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java new file mode 100644 index 0000000..3c0f571 --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java @@ -0,0 +1,499 @@ +package io.github.sunrioa.rin; + +import java.io.ByteArrayOutputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +public final class RinClient { + public static final String PROTOCOL_VERSION = "rin.protocol/v1"; + public static final String DEFAULT_BASE_URL = "http://127.0.0.1:7374"; + public static final int DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; + + private final String baseUrl; + private final String token; + private final Duration timeout; + private final int maxResponseBytes; + private final JsonCodec codec; + private final HttpClient http; + + public RinClient(JsonCodec codec) { + this(DEFAULT_BASE_URL, "", Duration.ofSeconds(5), DEFAULT_MAX_RESPONSE_BYTES, codec); + } + + public RinClient(String baseUrl, String token, Duration timeout, int maxResponseBytes, JsonCodec codec) { + this.token = validateToken(token); + this.baseUrl = normalizeBaseUrl(baseUrl, this.token); + this.timeout = Objects.requireNonNull(timeout, "timeout"); + if (timeout.compareTo(Duration.ofMillis(50)) < 0 || timeout.compareTo(Duration.ofSeconds(120)) > 0) { + throw new RinConfigurationException("invalid_timeout", "Timeout must be between 50 ms and 120 seconds"); + } + if (maxResponseBytes < 1024 || maxResponseBytes > 32 * 1024 * 1024) { + throw new RinConfigurationException("invalid_response_limit", "Response limit must be between 1 KiB and 32 MiB"); + } + this.maxResponseBytes = maxResponseBytes; + this.codec = Objects.requireNonNull(codec, "codec"); + this.http = HttpClient.newBuilder() + .connectTimeout(timeout) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + } + + public CompletableFuture> health() { + return request("GET", "/health", null, Set.of(200)); + } + + public CompletableFuture> createSession(Map payload) { + return post("/v1/session/create", payload, 200); + } + + public CompletableFuture> observe(Map payload) { + return post("/v1/session/observe", payload, 200); + } + + public CompletableFuture> propose(Map payload) { + return post("/v1/agent/propose", payload, 200); + } + + public CompletableFuture> submitProposalJob(Map payload) { + return post("/v1/jobs/propose", payload, 202); + } + + public CompletableFuture> getProposalJob(String jobId) { + return request("GET", "/v1/jobs/" + pathId(jobId), null, Set.of(200)); + } + + public CompletableFuture> cancelProposalJob(String jobId) { + return request("DELETE", "/v1/jobs/" + pathId(jobId), null, Set.of(200)); + } + + public CompletableFuture> submitGenerationJob(Map payload) { + return post("/v1/generation/jobs", payload, 202); + } + + public CompletableFuture> getGenerationJob(String jobId) { + return request("GET", "/v1/generation/jobs/" + pathId(jobId), null, Set.of(200)); + } + + public CompletableFuture> cancelGenerationJob(String jobId) { + return request("DELETE", "/v1/generation/jobs/" + pathId(jobId), null, Set.of(200)); + } + + public CompletableFuture> commit(Map payload) { + return post("/v1/action/commit", payload, 200); + } + + public CompletableFuture> commitBatch(Map payload) { + return post("/v1/action/commit-batch", payload, 200); + } + + public CompletableFuture> setActorActivity(Map payload) { + return post("/v1/session/activity", payload, 200); + } + + public CompletableFuture> arbitrate(Map payload) { + return post("/v1/world/arbitrate", payload, 200); + } + + public CompletableFuture> state(Map payload) { + return post("/v1/session/get", payload, 200); + } + + public CompletableFuture> snapshot(Map payload) { + return post("/v1/session/snapshot", payload, 200); + } + + public CompletableFuture> restore(Map payload) { + return post("/v1/session/restore", payload, 200); + } + + public CompletableFuture> timeline(Map payload) { + return post("/v1/session/timeline", payload, 200); + } + + public CompletableFuture> replay(Map payload) { + return post("/v1/session/replay", payload, 200); + } + + public CompletableFuture> dueAgents(Map payload) { + return post("/v1/scheduler/due", payload, 200); + } + + public CompletableFuture> waitForProposal(String jobId) { + return waitForJob(jobId, this::getProposalJob, this::cancelProposalJob, Duration.ofSeconds(25), Duration.ofMillis(100)); + } + + public CompletableFuture> waitForProposal(String jobId, Duration deadline, Duration interval) { + return waitForJob(jobId, this::getProposalJob, this::cancelProposalJob, deadline, interval); + } + + public CompletableFuture> waitForGeneration(String jobId) { + return waitForJob(jobId, this::getGenerationJob, this::cancelGenerationJob, Duration.ofSeconds(45), Duration.ofMillis(100)); + } + + public CompletableFuture> waitForGeneration(String jobId, Duration deadline, Duration interval) { + return waitForJob(jobId, this::getGenerationJob, this::cancelGenerationJob, deadline, interval); + } + + private CompletableFuture> waitForJob( + String jobId, + Function>> getter, + Function>> canceler, + Duration deadline, + Duration interval) { + if (deadline == null || interval == null || deadline.compareTo(Duration.ofMillis(50)) < 0 || + deadline.compareTo(Duration.ofMinutes(5)) > 0 || interval.compareTo(Duration.ofMillis(10)) < 0 || + interval.compareTo(Duration.ofSeconds(5)) > 0) { + throw new RinConfigurationException("invalid_polling", "Job deadline or interval is out of range"); + } + long expires = System.nanoTime() + deadline.toNanos(); + CompletableFuture> result = new CompletableFuture<>(); + class Poller { + void poll() { + if (result.isDone()) return; + getter.apply(jobId).whenComplete((job, failure) -> { + if (result.isDone()) return; + if (failure != null) { + result.completeExceptionally(unwrap(failure)); + return; + } + String status = RinException.safeText(job.get("status"), 32, ""); + if (status.equals("succeeded")) { + result.complete(job); + return; + } + if (status.equals("failed") || status.equals("stale") || status.equals("canceled")) { + Object value = job.get("error"); + Map detail = value instanceof Map map ? map : Map.of(); + result.completeExceptionally(new RinApiException( + RinException.safeText(detail.get("code"), 96, "job_" + status), + RinException.safeText(detail.get("message"), 500, "Rin job ended as " + status), + 0, + "")); + return; + } + if (!status.equals("queued") && !status.equals("running")) { + result.completeExceptionally(new RinProtocolException("invalid_job", "Rin returned an unknown job status")); + return; + } + long remaining = expires - System.nanoTime(); + if (remaining <= 0) { + try { + canceler.apply(jobId); + } catch (RinException ignored) { + // Timeout remains the useful result even if best-effort cancellation fails. + } + result.completeExceptionally(new RinApiException("job_timeout", "Rin job exceeded its deadline", 0, "")); + return; + } + long delay = Math.min(interval.toNanos(), remaining); + CompletableFuture.delayedExecutor(delay, TimeUnit.NANOSECONDS).execute(this::poll); + }); + } + } + new Poller().poll(); + return result; + } + + private CompletableFuture> post(String path, Map payload, int expectedStatus) { + return request("POST", path, Objects.requireNonNull(payload, "payload"), Set.of(expectedStatus)); + } + + private CompletableFuture> request( + String method, + String path, + Map payload, + Set expectedStatuses) { + if (!path.startsWith("/") || path.contains("//") || path.contains("..")) { + throw new RinConfigurationException("invalid_path", "Rin request path is invalid"); + } + + HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.noBody(); + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(baseUrl + path)) + .timeout(timeout) + .header("Accept", "application/json") + .header("User-Agent", "rin-java/0.5"); + if (payload != null) { + final String encoded; + try { + encoded = codec.encode(payload); + } catch (Exception exception) { + throw new RinProtocolException("invalid_request", "Rin payload is not JSON serializable", exception); + } + if (encoded == null) { + throw new RinProtocolException("invalid_request", "JSON codec returned a null request"); + } + body = HttpRequest.BodyPublishers.ofString(encoded, StandardCharsets.UTF_8); + builder.header("Content-Type", "application/json; charset=utf-8"); + } + if (!token.isEmpty()) builder.header("Authorization", "Bearer " + token); + builder.method(method, body); + + CompletableFuture> network = http.sendAsync( + builder.build(), + ignored -> new BoundedBodySubscriber(maxResponseBytes)); + CompletableFuture> wire = new CompletableFuture<>(); + network.whenComplete((response, failure) -> { + if (failure == null) wire.complete(response); + else wire.completeExceptionally(unwrap(failure)); + }); + CompletableFuture.delayedExecutor(timeout.toMillis(), TimeUnit.MILLISECONDS).execute(() -> { + if (wire.completeExceptionally(new HttpTimeoutException("Rin request timed out"))) { + network.cancel(true); + } + }); + CompletableFuture> result = new CompletableFuture<>(); + wire.whenComplete((response, failure) -> { + if (failure != null) { + Throwable cause = unwrap(failure); + if (cause instanceof RinException) { + result.completeExceptionally(cause); + } else if (cause instanceof HttpTimeoutException) { + result.completeExceptionally(new RinTransportException( + "transport_timeout", "Rin request timed out", cause)); + } else { + result.completeExceptionally(new RinTransportException( + "transport_failed", "Rin is unavailable", cause)); + } + return; + } + try { + result.complete(decodeResponse(response, expectedStatuses)); + } catch (RuntimeException exception) { + result.completeExceptionally(exception); + } + }); + result.whenComplete((ignored, failure) -> { + if (result.isCancelled()) { + wire.cancel(true); + network.cancel(true); + } + }); + return result; + } + + private Map decodeResponse(HttpResponse response, Set expectedStatuses) { + int status = response.statusCode(); + if (status >= 300 && status < 400) { + throw new RinTransportException("redirect_rejected", "Rin endpoint attempted to redirect"); + } + String contentLength = response.headers().firstValue("Content-Length").orElse(null); + if (contentLength != null) { + final long declared; + try { + declared = Long.parseLong(contentLength); + } catch (NumberFormatException exception) { + throw new RinProtocolException("invalid_response", "Rin returned an invalid Content-Length", exception); + } + if (declared < 0 || declared > maxResponseBytes) { + throw new RinProtocolException("response_too_large", "Rin response exceeds the configured limit"); + } + } + + byte[] bytes = response.body(); + + final String json; + try { + json = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException exception) { + throw new RinProtocolException("invalid_response", "Rin returned invalid UTF-8", exception); + } + + final Map envelope; + try { + envelope = codec.decodeObject(json); + } catch (Exception exception) { + if (!expectedStatuses.contains(status)) { + throw new RinApiException("http_error", "Rin request failed", status, ""); + } + throw new RinProtocolException("invalid_response", "Rin returned invalid JSON", exception); + } + if (envelope == null) { + throw new RinProtocolException("invalid_response", "Rin response must be an object"); + } + if (!expectedStatuses.contains(status) || !Boolean.TRUE.equals(envelope.get("ok"))) { + throw apiError(envelope, status); + } + Object data = envelope.get("data"); + if (!(data instanceof Map map)) { + throw new RinProtocolException("invalid_response", "Rin response data must be an object"); + } + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + throw new RinProtocolException("invalid_response", "Rin response data contains a non-string key"); + } + result.put(key, entry.getValue()); + } + return Collections.unmodifiableMap(result); + } + + private static RinApiException apiError(Map envelope, int status) { + Object value = envelope.get("error"); + Map detail = value instanceof Map map ? map : Map.of(); + return new RinApiException( + RinException.safeText(detail.get("code"), 96, "http_error"), + RinException.safeText(detail.get("message"), 500, "Rin request failed"), + status, + RinException.safeText(detail.get("field"), 160, "")); + } + + private static String normalizeBaseUrl(String value, String validatedToken) { + String raw = value == null || value.isBlank() ? DEFAULT_BASE_URL : value.strip(); + while (raw.endsWith("/")) raw = raw.substring(0, raw.length() - 1); + final URI uri; + try { + uri = new URI(raw); + } catch (URISyntaxException exception) { + throw new RinConfigurationException("invalid_base_url", "Rin base URL must be an origin", exception); + } + String scheme = uri.getScheme(); + String host = uri.getHost(); + String path = uri.getRawPath(); + if (!("http".equals(scheme) || "https".equals(scheme)) || host == null || host.isEmpty() || + uri.getRawUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null || + (path != null && !path.isEmpty() && !"/".equals(path)) || uri.getPort() > 65535) { + throw new RinConfigurationException("invalid_base_url", "Rin base URL must be an origin"); + } + boolean loopback = isLoopback(host); + if ("http".equals(scheme) && !loopback) { + throw new RinConfigurationException("insecure_base_url", "Remote Rin endpoints must use HTTPS"); + } + if (!loopback && validatedToken.isEmpty()) { + throw new RinConfigurationException("missing_token", "Remote Rin endpoints require a token"); + } + return uri.getScheme() + "://" + uri.getRawAuthority(); + } + + private static boolean isLoopback(String value) { + String host = value.toLowerCase(); + if (host.startsWith("[") && host.endsWith("]")) host = host.substring(1, host.length() - 1); + if (host.equals("localhost") || host.equals("::1") || host.equals("0:0:0:0:0:0:0:1")) return true; + String[] octets = host.split("\\.", -1); + if (octets.length != 4) return false; + try { + if (Integer.parseInt(octets[0]) != 127) return false; + for (String octet : octets) { + int number = Integer.parseInt(octet); + if (number < 0 || number > 255) return false; + } + return true; + } catch (NumberFormatException ignored) { + return false; + } + } + + private static String validateToken(String value) { + String candidate = value == null ? "" : value; + if (candidate.length() > 4096 || !candidate.equals(candidate.strip()) || + candidate.indexOf('\0') >= 0 || candidate.indexOf('\r') >= 0 || candidate.indexOf('\n') >= 0) { + throw new RinConfigurationException("invalid_token", "Rin token must be a bounded single-line value"); + } + return candidate; + } + + private static String pathId(String value) { + if (value == null || value.isEmpty() || value.length() > 96 || !value.matches("[A-Za-z0-9._-]+")) { + throw new RinConfigurationException("invalid_identifier", "Rin path identifier is invalid"); + } + return value; + } + + private static Throwable unwrap(Throwable failure) { + return failure instanceof CompletionException && failure.getCause() != null ? failure.getCause() : failure; + } + + private static final class BoundedBodySubscriber implements HttpResponse.BodySubscriber { + private final int maximum; + private final ByteArrayOutputStream output; + private final CompletableFuture body = new CompletableFuture<>(); + private Flow.Subscription subscription; + private boolean done; + + private BoundedBodySubscriber(int maximum) { + this.maximum = maximum; + this.output = new ByteArrayOutputStream(Math.min(maximum, 8192)); + } + + @Override + public CompletionStage getBody() { + return body; + } + + @Override + public void onSubscribe(Flow.Subscription value) { + if (subscription != null) { + value.cancel(); + return; + } + subscription = Objects.requireNonNull(value, "subscription"); + subscription.request(1); + } + + @Override + public void onNext(List buffers) { + if (done) return; + try { + for (ByteBuffer buffer : buffers) { + int count = buffer.remaining(); + if ((long) output.size() + count > maximum) { + fail(new RinProtocolException( + "response_too_large", "Rin response exceeds the configured limit")); + return; + } + byte[] chunk = new byte[count]; + buffer.get(chunk); + output.write(chunk, 0, count); + } + subscription.request(1); + } catch (RuntimeException exception) { + fail(exception); + } + } + + @Override + public void onError(Throwable failure) { + if (done) return; + done = true; + body.completeExceptionally(failure); + } + + @Override + public void onComplete() { + if (done) return; + done = true; + body.complete(output.toByteArray()); + } + + private void fail(RuntimeException failure) { + if (done) return; + done = true; + subscription.cancel(); + body.completeExceptionally(failure); + } + } +} diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/RinConfigurationException.java b/sdk/java/src/main/java/io/github/sunrioa/rin/RinConfigurationException.java new file mode 100644 index 0000000..6408a4e --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/RinConfigurationException.java @@ -0,0 +1,13 @@ +package io.github.sunrioa.rin; + +public final class RinConfigurationException extends RinException { + private static final long serialVersionUID = 1L; + + public RinConfigurationException(String code, String message) { + super(code, message); + } + + public RinConfigurationException(String code, String message, Throwable cause) { + super(code, message, cause); + } +} diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/RinException.java b/sdk/java/src/main/java/io/github/sunrioa/rin/RinException.java new file mode 100644 index 0000000..375ba27 --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/RinException.java @@ -0,0 +1,29 @@ +package io.github.sunrioa.rin; + +public class RinException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final String code; + + public RinException(String code, String message) { + this(code, message, null); + } + + public RinException(String code, String message, Throwable cause) { + super(safeText(message, 500, "Rin request failed"), cause); + this.code = safeText(code, 96, "rin_error"); + } + + public String code() { + return code; + } + + static String safeText(Object value, int maximum, String fallback) { + String cleaned = String.valueOf(value == null ? "" : value) + .replace('\0', ' ') + .trim() + .replaceAll("\\s+", " "); + if (cleaned.isEmpty()) return fallback; + return cleaned.substring(0, Math.min(cleaned.length(), maximum)); + } +} diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/RinProtocolException.java b/sdk/java/src/main/java/io/github/sunrioa/rin/RinProtocolException.java new file mode 100644 index 0000000..9ed8c3b --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/RinProtocolException.java @@ -0,0 +1,13 @@ +package io.github.sunrioa.rin; + +public final class RinProtocolException extends RinException { + private static final long serialVersionUID = 1L; + + public RinProtocolException(String code, String message) { + super(code, message); + } + + public RinProtocolException(String code, String message, Throwable cause) { + super(code, message, cause); + } +} diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/RinTransportException.java b/sdk/java/src/main/java/io/github/sunrioa/rin/RinTransportException.java new file mode 100644 index 0000000..78bcf3f --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/RinTransportException.java @@ -0,0 +1,13 @@ +package io.github.sunrioa.rin; + +public final class RinTransportException extends RinException { + private static final long serialVersionUID = 1L; + + public RinTransportException(String code, String message) { + super(code, message); + } + + public RinTransportException(String code, String message, Throwable cause) { + super(code, message, cause); + } +} diff --git a/sdk/java/test/io/github/sunrioa/rin/RinClientTest.java b/sdk/java/test/io/github/sunrioa/rin/RinClientTest.java new file mode 100644 index 0000000..99a5582 --- /dev/null +++ b/sdk/java/test/io/github/sunrioa/rin/RinClientTest.java @@ -0,0 +1,134 @@ +package io.github.sunrioa.rin; + +import com.sun.net.httpserver.HttpServer; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Supplier; + +public final class RinClientTest { + private record RequestCase(Supplier>> call, String method, String path) { } + + public static void main(String[] args) throws Exception { + String[] lastRequest = new String[3]; + String[] mode = {"normal"}; + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + lastRequest[0] = exchange.getRequestMethod(); + lastRequest[1] = exchange.getRequestURI().getPath(); + lastRequest[2] = exchange.getRequestHeaders().getFirst("Authorization"); + if (mode[0].equals("slow")) { + try { + Thread.sleep(200); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } + byte[] body = mode[0].equals("oversized") + ? new byte[2048] + : "{}".getBytes(StandardCharsets.UTF_8); + int status = (lastRequest[1].equals("/v1/jobs/propose") || lastRequest[1].equals("/v1/generation/jobs")) ? 202 : 200; + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + JsonCodec codec = new JsonCodec() { + public String encode(Map value) { return "{}"; } + public Map decodeObject(String json) { + return Map.of("ok", true, "data", Map.of("status", "ok")); + } + }; + RinClient client = new RinClient( + "http://127.0.0.1:" + server.getAddress().getPort(), + "fixture", + Duration.ofSeconds(2), + 1024 * 1024, + codec); + Map payload = Map.of(); + List cases = List.of( + new RequestCase(client::health, "GET", "/health"), + new RequestCase(() -> client.createSession(payload), "POST", "/v1/session/create"), + new RequestCase(() -> client.observe(payload), "POST", "/v1/session/observe"), + new RequestCase(() -> client.propose(payload), "POST", "/v1/agent/propose"), + new RequestCase(() -> client.submitProposalJob(payload), "POST", "/v1/jobs/propose"), + new RequestCase(() -> client.getProposalJob("job.fixture"), "GET", "/v1/jobs/job.fixture"), + new RequestCase(() -> client.cancelProposalJob("job.fixture"), "DELETE", "/v1/jobs/job.fixture"), + new RequestCase(() -> client.submitGenerationJob(payload), "POST", "/v1/generation/jobs"), + new RequestCase(() -> client.getGenerationJob("job.fixture"), "GET", "/v1/generation/jobs/job.fixture"), + new RequestCase(() -> client.cancelGenerationJob("job.fixture"), "DELETE", "/v1/generation/jobs/job.fixture"), + new RequestCase(() -> client.commit(payload), "POST", "/v1/action/commit"), + new RequestCase(() -> client.commitBatch(payload), "POST", "/v1/action/commit-batch"), + new RequestCase(() -> client.setActorActivity(payload), "POST", "/v1/session/activity"), + new RequestCase(() -> client.arbitrate(payload), "POST", "/v1/world/arbitrate"), + new RequestCase(() -> client.state(payload), "POST", "/v1/session/get"), + new RequestCase(() -> client.snapshot(payload), "POST", "/v1/session/snapshot"), + new RequestCase(() -> client.restore(payload), "POST", "/v1/session/restore"), + new RequestCase(() -> client.timeline(payload), "POST", "/v1/session/timeline"), + new RequestCase(() -> client.replay(payload), "POST", "/v1/session/replay"), + new RequestCase(() -> client.dueAgents(payload), "POST", "/v1/scheduler/due") + ); + try { + for (RequestCase test : cases) { + test.call().get().join(); + require(test.method().equals(lastRequest[0]), "wrong method for " + test.path()); + require(test.path().equals(lastRequest[1]), "wrong path for " + test.path()); + require("Bearer fixture".equals(lastRequest[2]), "missing bearer token"); + } + try { + client.getProposalJob("\u4f5c\u4e1a"); + throw new AssertionError("Unicode path ID was accepted"); + } catch (RinConfigurationException expected) { + require("invalid_identifier".equals(expected.code()), "wrong identifier error"); + } + + mode[0] = "oversized"; + RinClient limited = new RinClient( + "http://127.0.0.1:" + server.getAddress().getPort(), + "", + Duration.ofSeconds(2), + 1024, + codec); + try { + limited.health().join(); + throw new AssertionError("oversized streamed response was accepted"); + } catch (CompletionException expected) { + require(rootCause(expected) instanceof RinProtocolException, "wrong response limit error"); + } + + mode[0] = "slow"; + RinClient impatient = new RinClient( + "http://127.0.0.1:" + server.getAddress().getPort(), + "", + Duration.ofMillis(50), + 1024, + codec); + try { + impatient.health().join(); + throw new AssertionError("slow response exceeded the request deadline"); + } catch (CompletionException expected) { + Throwable cause = rootCause(expected); + require(cause instanceof RinTransportException, "wrong timeout error type"); + require("transport_timeout".equals(((RinTransportException) cause).code()), "wrong timeout error code"); + } + } finally { + server.stop(0); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } + + private static Throwable rootCause(Throwable error) { + Throwable result = error; + while (result instanceof CompletionException && result.getCause() != null) result = result.getCause(); + return result; + } +} diff --git a/sdk/javascript/README.md b/sdk/javascript/README.md new file mode 100644 index 0000000..4238020 --- /dev/null +++ b/sdk/javascript/README.md @@ -0,0 +1,23 @@ +# Rin JavaScript SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +Requires Node.js 18+ or a host that implements the standard Fetch API. The +package has no runtime dependencies and includes TypeScript declarations. + +```js +import { RinClient } from "@sunrioa/rin-sdk"; + +const rin = new RinClient("http://127.0.0.1:7374"); +console.log(await rin.health()); +``` + +Run directly from this checkout: + +```bash +node sdk/javascript/examples/quickstart.js +cd sdk/javascript && npm test +``` + +Calls are Promise-based. Apply engine state only after returning to the +engine's main thread and validating the proposal against a local allowlist. diff --git a/sdk/javascript/README.zh-CN.md b/sdk/javascript/README.zh-CN.md new file mode 100644 index 0000000..46adc18 --- /dev/null +++ b/sdk/javascript/README.zh-CN.md @@ -0,0 +1,23 @@ +# Rin JavaScript SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +要求 Node.js 18+ 或实现标准 Fetch API 的宿主。Package 没有运行时依赖, +并包含 TypeScript Declaration。 + +```js +import { RinClient } from "@sunrioa/rin-sdk"; + +const rin = new RinClient("http://127.0.0.1:7374"); +console.log(await rin.health()); +``` + +从当前 Checkout 直接运行: + +```bash +node sdk/javascript/examples/quickstart.js +cd sdk/javascript && npm test +``` + +调用基于 Promise。只有回到引擎主线程并用本地白名单验证 Proposal 后, +才能应用引擎状态。 diff --git a/sdk/javascript/examples/quickstart.js b/sdk/javascript/examples/quickstart.js new file mode 100644 index 0000000..ea93d64 --- /dev/null +++ b/sdk/javascript/examples/quickstart.js @@ -0,0 +1,10 @@ +import { RinClient } from "../src/index.js"; + +const client = new RinClient(process.env.RIN_URL, { token: process.env.RIN_TOKEN }); + +try { + console.log(await client.health()); +} catch (error) { + console.error(`${error.code || "rin_error"}: ${error.message}`); + process.exitCode = 1; +} diff --git a/sdk/javascript/package.json b/sdk/javascript/package.json new file mode 100644 index 0000000..aae3348 --- /dev/null +++ b/sdk/javascript/package.json @@ -0,0 +1,20 @@ +{ + "name": "@sunrioa/rin-sdk", + "version": "0.5.0", + "description": "Zero-dependency JavaScript client for the Rin game agent runtime", + "type": "module", + "exports": { + ".": { + "types": "./src/index.d.ts", + "import": "./src/index.js" + } + }, + "scripts": { + "test": "node --test" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT", + "private": true +} diff --git a/sdk/javascript/src/index.d.ts b/sdk/javascript/src/index.d.ts new file mode 100644 index 0000000..5854bdd --- /dev/null +++ b/sdk/javascript/src/index.d.ts @@ -0,0 +1,56 @@ +export const PROTOCOL_VERSION: "rin.protocol/v1"; +export const DEFAULT_BASE_URL: string; +export const DEFAULT_MAX_RESPONSE_BYTES: number; + +export type RinObject = Record; +export type FetchImplementation = typeof globalThis.fetch; + +export interface RinClientOptions { + token?: string; + timeoutMs?: number; + maxResponseBytes?: number; + fetch?: FetchImplementation; + now?: () => number; + sleep?: (milliseconds: number) => Promise; +} + +export interface RinPollingOptions { + deadlineMs?: number; + intervalMs?: number; +} + +export class RinError extends Error { readonly code: string; } +export class RinConfigurationError extends RinError {} +export class RinTransportError extends RinError {} +export class RinProtocolError extends RinError {} +export class RinAPIError extends RinError { + readonly status: number; + readonly field: string; +} + +export class RinClient { + constructor(baseUrl?: string, options?: RinClientOptions); + readonly baseUrl: string; + health(): Promise; + createSession(payload: RinObject): Promise; + observe(payload: RinObject): Promise; + propose(payload: RinObject): Promise; + submitProposalJob(payload: RinObject): Promise; + getProposalJob(jobId: string): Promise; + cancelProposalJob(jobId: string): Promise; + submitGenerationJob(payload: RinObject): Promise; + getGenerationJob(jobId: string): Promise; + cancelGenerationJob(jobId: string): Promise; + commit(payload: RinObject): Promise; + commitBatch(payload: RinObject): Promise; + setActorActivity(payload: RinObject): Promise; + arbitrate(payload: RinObject): Promise; + state(payload: RinObject): Promise; + snapshot(payload: RinObject): Promise; + restore(payload: RinObject): Promise; + timeline(payload: RinObject): Promise; + replay(payload: RinObject): Promise; + dueAgents(payload: RinObject): Promise; + waitForProposal(jobId: string, options?: RinPollingOptions): Promise; + waitForGeneration(jobId: string, options?: RinPollingOptions): Promise; +} diff --git a/sdk/javascript/src/index.js b/sdk/javascript/src/index.js new file mode 100644 index 0000000..8f248bd --- /dev/null +++ b/sdk/javascript/src/index.js @@ -0,0 +1,302 @@ +export const PROTOCOL_VERSION = "rin.protocol/v1"; +export const DEFAULT_BASE_URL = "http://127.0.0.1:7374"; +export const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; + +const TERMINAL_JOB_STATES = new Set(["succeeded", "failed", "stale", "canceled"]); + +export class RinError extends Error { + constructor(code, message, options = {}) { + super(safeText(message, 500) || "Rin request failed", options); + this.name = new.target.name; + this.code = safeText(code, 96) || "rin_error"; + } +} + +export class RinConfigurationError extends RinError {} +export class RinTransportError extends RinError {} +export class RinProtocolError extends RinError {} + +export class RinAPIError extends RinError { + constructor(code, message, { status = 0, field = "", cause } = {}) { + super(code, message, cause ? { cause } : {}); + this.status = Number(status) || 0; + this.field = safeText(field, 160); + } +} + +export class RinClient { + constructor(baseUrl = DEFAULT_BASE_URL, options = {}) { + const { + token = "", + timeoutMs = 5000, + maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, + fetch: fetchImplementation = globalThis.fetch, + now = () => Date.now(), + sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + } = options; + + this.token = validateToken(token); + this.baseUrl = normalizeBaseUrl(baseUrl, this.token); + this.timeoutMs = Number(timeoutMs); + if (!Number.isFinite(this.timeoutMs) || this.timeoutMs < 50 || this.timeoutMs > 120000) { + throw new RinConfigurationError("invalid_timeout", "timeoutMs must be between 50 and 120000"); + } + this.maxResponseBytes = Number(maxResponseBytes); + if (!Number.isSafeInteger(this.maxResponseBytes) || this.maxResponseBytes < 1024 || this.maxResponseBytes > 32 * 1024 * 1024) { + throw new RinConfigurationError("invalid_response_limit", "response limit must be between 1 KiB and 32 MiB"); + } + if (typeof fetchImplementation !== "function") { + throw new RinConfigurationError("missing_fetch", "a Fetch API implementation is required"); + } + this.fetch = fetchImplementation; + this.now = now; + this.sleep = sleep; + } + + health() { return this.request("GET", "/health"); } + createSession(payload) { return this.post("/v1/session/create", payload); } + observe(payload) { return this.post("/v1/session/observe", payload); } + propose(payload) { return this.post("/v1/agent/propose", payload); } + submitProposalJob(payload) { return this.request("POST", "/v1/jobs/propose", payload, [202]); } + getProposalJob(jobId) { return this.request("GET", `/v1/jobs/${pathId(jobId)}`); } + cancelProposalJob(jobId) { return this.request("DELETE", `/v1/jobs/${pathId(jobId)}`); } + submitGenerationJob(payload) { return this.request("POST", "/v1/generation/jobs", payload, [202]); } + getGenerationJob(jobId) { return this.request("GET", `/v1/generation/jobs/${pathId(jobId)}`); } + cancelGenerationJob(jobId) { return this.request("DELETE", `/v1/generation/jobs/${pathId(jobId)}`); } + commit(payload) { return this.post("/v1/action/commit", payload); } + commitBatch(payload) { return this.post("/v1/action/commit-batch", payload); } + setActorActivity(payload) { return this.post("/v1/session/activity", payload); } + arbitrate(payload) { return this.post("/v1/world/arbitrate", payload); } + state(payload) { return this.post("/v1/session/get", payload); } + snapshot(payload) { return this.post("/v1/session/snapshot", payload); } + restore(payload) { return this.post("/v1/session/restore", payload); } + timeline(payload) { return this.post("/v1/session/timeline", payload); } + replay(payload) { return this.post("/v1/session/replay", payload); } + dueAgents(payload) { return this.post("/v1/scheduler/due", payload); } + + waitForProposal(jobId, options = {}) { + return this.waitJob(jobId, this.getProposalJob.bind(this), this.cancelProposalJob.bind(this), { + deadlineMs: 25000, + ...options, + }); + } + + waitForGeneration(jobId, options = {}) { + return this.waitJob(jobId, this.getGenerationJob.bind(this), this.cancelGenerationJob.bind(this), { + deadlineMs: 45000, + ...options, + }); + } + + async waitJob(jobId, getter, canceler, { deadlineMs, intervalMs = 100 }) { + if (!Number.isFinite(deadlineMs) || deadlineMs < 50 || deadlineMs > 300000 || + !Number.isFinite(intervalMs) || intervalMs < 10 || intervalMs > 5000) { + throw new RinConfigurationError("invalid_polling", "job deadline or interval is out of range"); + } + const expires = this.now() + deadlineMs; + for (;;) { + const job = await getter(jobId); + const status = String(job.status || ""); + if (status === "succeeded") return job; + if (TERMINAL_JOB_STATES.has(status)) { + const detail = isObject(job.error) ? job.error : {}; + throw new RinAPIError( + safeText(detail.code, 96) || `job_${status}`, + safeText(detail.message, 500) || `Rin job ended as ${status}`, + ); + } + if (status !== "queued" && status !== "running") { + throw new RinProtocolError("invalid_job", "Rin returned an unknown job status"); + } + const remaining = expires - this.now(); + if (remaining <= 0) { + try { await canceler(jobId); } catch (error) { if (!(error instanceof RinError)) throw error; } + throw new RinAPIError("job_timeout", "Rin job exceeded its deadline"); + } + await this.sleep(Math.min(intervalMs, remaining)); + } + } + + post(path, payload) { + return this.request("POST", path, payload); + } + + async request(method, path, payload, expectedStatuses = [200]) { + if (typeof path !== "string" || !path.startsWith("/") || path.includes("//") || path.includes("..")) { + throw new RinConfigurationError("invalid_path", "Rin request path is invalid"); + } + const headers = { Accept: "application/json", "User-Agent": "rin-javascript/0.5" }; + let body; + if (payload !== undefined) { + if (!isObject(payload)) { + throw new RinProtocolError("invalid_request", "Rin payload must be an object"); + } + try { + body = JSON.stringify(payload); + } catch (cause) { + throw new RinProtocolError("invalid_request", "Rin payload is not JSON serializable", { cause }); + } + headers["Content-Type"] = "application/json"; + } + if (this.token) headers.Authorization = `Bearer ${this.token}`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const response = await this.fetch(`${this.baseUrl}${path}`, { + method, + headers, + body, + signal: controller.signal, + redirect: "error", + }); + const declared = response.headers?.get?.("content-length"); + if (declared !== null && declared !== undefined && declared !== "") { + const length = Number(declared); + if (!Number.isSafeInteger(length) || length < 0) { + throw new RinProtocolError("invalid_response", "Rin returned an invalid Content-Length"); + } + if (length > this.maxResponseBytes) { + await cancelBody(response); + throw new RinProtocolError("response_too_large", "Rin response exceeds the configured limit"); + } + } + const raw = await readBoundedBody(response, this.maxResponseBytes); + + let envelope; + try { + envelope = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)); + } catch (cause) { + throw new RinProtocolError("invalid_response", "Rin returned invalid JSON", { cause }); + } + if (!isObject(envelope)) { + throw new RinProtocolError("invalid_response", "Rin response must be an object"); + } + if (!expectedStatuses.includes(response.status) || envelope.ok !== true) { + throw apiError(envelope, response.status); + } + if (!isObject(envelope.data)) { + throw new RinProtocolError("invalid_response", "Rin response data must be an object"); + } + return envelope.data; + } catch (cause) { + if (cause instanceof RinError) throw cause; + const timedOut = controller.signal.aborted; + throw new RinTransportError( + timedOut ? "transport_timeout" : "transport_failed", + timedOut ? "Rin request timed out" : "Rin is unavailable", + { cause }, + ); + } finally { + clearTimeout(timer); + } + } +} + +async function readBoundedBody(response, maximum) { + const reader = response.body?.getReader?.(); + if (!reader) { + const raw = new Uint8Array(await response.arrayBuffer()); + if (raw.byteLength > maximum) { + throw new RinProtocolError("response_too_large", "Rin response exceeds the configured limit"); + } + return raw; + } + + const chunks = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) { + throw new RinProtocolError("invalid_response", "Rin response stream returned an invalid chunk"); + } + total += value.byteLength; + if (total > maximum) { + await reader.cancel(); + throw new RinProtocolError("response_too_large", "Rin response exceeds the configured limit"); + } + chunks.push(value); + } + } finally { + reader.releaseLock?.(); + } + + const raw = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + raw.set(chunk, offset); + offset += chunk.byteLength; + } + return raw; +} + +async function cancelBody(response) { + try { + await response.body?.cancel?.(); + } catch { + // The response is already being rejected; cancellation is best effort. + } +} + +function normalizeBaseUrl(value, token) { + let parsed; + try { + parsed = new URL(String(value || DEFAULT_BASE_URL).trim().replace(/\/+$/, "")); + } catch (cause) { + throw new RinConfigurationError("invalid_base_url", "Rin base URL must be an origin", { cause }); + } + if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || + parsed.search || parsed.hash || (parsed.pathname !== "/" && parsed.pathname !== "")) { + throw new RinConfigurationError("invalid_base_url", "Rin base URL must be an origin"); + } + const loopback = isLoopback(parsed.hostname); + if (parsed.protocol === "http:" && !loopback) { + throw new RinConfigurationError("insecure_base_url", "remote Rin endpoints must use HTTPS"); + } + if (!loopback && !token) { + throw new RinConfigurationError("missing_token", "remote Rin endpoints require a token"); + } + return parsed.origin; +} + +function isLoopback(hostname) { + const host = String(hostname).toLowerCase().replace(/^\[|\]$/g, ""); + if (host === "localhost" || host === "::1" || host === "0:0:0:0:0:0:0:1") return true; + const octets = host.split("."); + return octets.length === 4 && octets.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255) && Number(octets[0]) === 127; +} + +function validateToken(value) { + const token = String(value || ""); + if (token !== token.trim() || /[\0\r\n]/.test(token) || token.length > 4096) { + throw new RinConfigurationError("invalid_token", "Rin token must be a bounded single-line value"); + } + return token; +} + +function pathId(value) { + const text = String(value || ""); + if (!/^[A-Za-z0-9._-]{1,96}$/.test(text)) { + throw new RinConfigurationError("invalid_identifier", "Rin path identifier is invalid"); + } + return encodeURIComponent(text); +} + +function apiError(envelope, status) { + const detail = isObject(envelope.error) ? envelope.error : {}; + return new RinAPIError( + safeText(detail.code, 96) || "http_error", + safeText(detail.message, 500) || "Rin request failed", + { status, field: safeText(detail.field, 160) }, + ); +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function safeText(value, maximum) { + return String(value ?? "").replace(/\0/g, "").trim().split(/\s+/).filter(Boolean).join(" ").slice(0, maximum); +} diff --git a/sdk/javascript/test/client.test.js b/sdk/javascript/test/client.test.js new file mode 100644 index 0000000..006d00b --- /dev/null +++ b/sdk/javascript/test/client.test.js @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RinAPIError, + RinClient, + RinConfigurationError, + RinProtocolError, +} from "../src/index.js"; + +function response(status, envelope, headers = {}) { + const bytes = new TextEncoder().encode(JSON.stringify(envelope)); + const values = new Map(Object.entries({ "content-length": String(bytes.byteLength), ...headers })); + return { + status, + headers: { get: (name) => values.get(name.toLowerCase()) ?? null }, + arrayBuffer: async () => bytes.buffer, + }; +} + +test("all protocol routes use the expected method and bearer token", async () => { + const requests = []; + const fetch = async (url, options) => { + requests.push({ url: new URL(url), options }); + const accepted = url.endsWith("/v1/jobs/propose") || url.endsWith("/v1/generation/jobs") ? 202 : 200; + return response(accepted, { ok: true, data: { status: "ok" } }); + }; + const client = new RinClient(undefined, { token: "fixture", fetch }); + const cases = [ + [() => client.health(), "GET", "/health"], + [() => client.createSession({}), "POST", "/v1/session/create"], + [() => client.observe({}), "POST", "/v1/session/observe"], + [() => client.propose({}), "POST", "/v1/agent/propose"], + [() => client.submitProposalJob({}), "POST", "/v1/jobs/propose"], + [() => client.getProposalJob("job.fixture"), "GET", "/v1/jobs/job.fixture"], + [() => client.cancelProposalJob("job.fixture"), "DELETE", "/v1/jobs/job.fixture"], + [() => client.submitGenerationJob({}), "POST", "/v1/generation/jobs"], + [() => client.getGenerationJob("job.fixture"), "GET", "/v1/generation/jobs/job.fixture"], + [() => client.cancelGenerationJob("job.fixture"), "DELETE", "/v1/generation/jobs/job.fixture"], + [() => client.commit({}), "POST", "/v1/action/commit"], + [() => client.commitBatch({}), "POST", "/v1/action/commit-batch"], + [() => client.setActorActivity({}), "POST", "/v1/session/activity"], + [() => client.arbitrate({}), "POST", "/v1/world/arbitrate"], + [() => client.state({}), "POST", "/v1/session/get"], + [() => client.snapshot({}), "POST", "/v1/session/snapshot"], + [() => client.restore({}), "POST", "/v1/session/restore"], + [() => client.timeline({}), "POST", "/v1/session/timeline"], + [() => client.replay({}), "POST", "/v1/session/replay"], + [() => client.dueAgents({}), "POST", "/v1/scheduler/due"], + ]; + for (const [call, method, path] of cases) { + await call(); + const request = requests.at(-1); + assert.equal(request.url.pathname, path); + assert.equal(request.options.method, method); + assert.equal(request.options.headers.Authorization, "Bearer fixture"); + assert.equal(request.options.redirect, "error"); + } +}); + +test("remote endpoints require TLS and a token", () => { + assert.throws(() => new RinClient("http://models.example", { token: "fixture", fetch: () => {} }), RinConfigurationError); + assert.throws(() => new RinClient("https://models.example", { fetch: () => {} }), RinConfigurationError); + assert.equal(new RinClient("https://models.example", { token: "fixture", fetch: () => {} }).baseUrl, "https://models.example"); +}); + +test("unsafe identifiers and oversized responses are rejected", async () => { + const client = new RinClient(undefined, { + maxResponseBytes: 1024, + fetch: async () => response(200, { ok: true, data: {} }, { "content-length": "2048" }), + }); + assert.throws(() => client.getProposalJob("\u4f5c\u4e1a"), RinConfigurationError); + await assert.rejects(client.health(), RinProtocolError); +}); + +test("streamed responses are capped before the full body is buffered", async () => { + let reads = 0; + let canceled = false; + const body = { + getReader: () => ({ + read: async () => { + reads += 1; + return { done: false, value: new Uint8Array(600) }; + }, + cancel: async () => { canceled = true; }, + releaseLock: () => {}, + }), + }; + const client = new RinClient(undefined, { + maxResponseBytes: 1024, + fetch: async () => ({ status: 200, headers: { get: () => null }, body }), + }); + await assert.rejects(client.health(), RinProtocolError); + assert.equal(reads, 2); + assert.equal(canceled, true); +}); + +test("the deadline remains active while a streamed body is read", async () => { + const client = new RinClient(undefined, { + timeoutMs: 50, + fetch: async (_url, options) => ({ + status: 200, + headers: { get: () => null }, + body: { + getReader: () => ({ + read: () => new Promise((_resolve, reject) => { + options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }), + cancel: async () => {}, + releaseLock: () => {}, + }), + }, + }), + }); + await assert.rejects(client.health(), (error) => error.code === "transport_timeout"); +}); + +test("API errors expose only the bounded protocol detail", async () => { + const client = new RinClient(undefined, { + fetch: async () => response(400, { ok: false, error: { code: "invalid_request", message: "safe", field: "actor_id" } }), + }); + await assert.rejects(client.health(), (error) => { + assert.ok(error instanceof RinAPIError); + assert.equal(error.code, "invalid_request"); + assert.equal(error.status, 400); + assert.equal(error.field, "actor_id"); + return true; + }); +}); diff --git a/sdk/lua/README.md b/sdk/lua/README.md new file mode 100644 index 0000000..c3af587 --- /dev/null +++ b/sdk/lua/README.md @@ -0,0 +1,32 @@ +# Rin Lua SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +The client supports Lua 5.1+ and does not assume a particular engine. Supply +three adapters: + +- `http_fetch(request, callback)` returns `{status, body, headers}` and must + honor `follow_redirects = false`; +- `json_encode(table)` and `json_decode(string)` use the engine's JSON codec; +- optional `schedule(seconds, callback)` and a monotonic `now()` enable job + polling without blocking the game loop. Without `now`, the portable but + lower-resolution `os.time` wall clock is used. + +```lua +local rin = dofile("rin.lua") +local client, err = rin.new({ + base_url = "http://127.0.0.1:7374", + http_fetch = engine_http_fetch, + json_encode = engine_json_encode, + json_decode = engine_json_decode, + schedule = engine_schedule, +}) +assert(client, err and err.message) + +client:health(function(data, request_error) + if request_error then print(request_error.code) else print(data.status) end +end) +``` + +The callback convention is `(data, error)`. Network work remains asynchronous; +only apply allowlisted actions from the engine's owning thread. diff --git a/sdk/lua/README.zh-CN.md b/sdk/lua/README.zh-CN.md new file mode 100644 index 0000000..d5076ea --- /dev/null +++ b/sdk/lua/README.zh-CN.md @@ -0,0 +1,31 @@ +# Rin Lua SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +客户端支持 Lua 5.1+,不假设具体引擎。需要提供三个 Adapter: + +- `http_fetch(request, callback)` 返回 `{status, body, headers}`,并且必须 + 遵守 `follow_redirects = false`; +- `json_encode(table)` 和 `json_decode(string)` 使用引擎的 JSON Codec; +- 可选 `schedule(seconds, callback)` 和单调 `now()` 可在不阻塞游戏循环 + 的情况下轮询 Job。未提供 `now` 时使用可移植但分辨率较低的 `os.time` + 墙上时钟。 + +```lua +local rin = dofile("rin.lua") +local client, err = rin.new({ + base_url = "http://127.0.0.1:7374", + http_fetch = engine_http_fetch, + json_encode = engine_json_encode, + json_decode = engine_json_decode, + schedule = engine_schedule, +}) +assert(client, err and err.message) + +client:health(function(data, request_error) + if request_error then print(request_error.code) else print(data.status) end +end) +``` + +Callback 约定为 `(data, error)`。网络工作保持异步;只能从引擎拥有的线程 +应用白名单动作。 diff --git a/sdk/lua/rin.lua b/sdk/lua/rin.lua new file mode 100644 index 0000000..5b10841 --- /dev/null +++ b/sdk/lua/rin.lua @@ -0,0 +1,339 @@ +local rin = { + PROTOCOL_VERSION = "rin.protocol/v1", + DEFAULT_BASE_URL = "http://127.0.0.1:7374", + DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024, +} + +local Client = {} +Client.__index = Client + +local terminal_job_states = { + succeeded = true, + failed = true, + stale = true, + canceled = true, +} + +local function safe_text(value, maximum, fallback) + local text = tostring(value or ""):gsub("%z", " "):gsub("%s+", " ") + text = text:match("^%s*(.-)%s*$") or "" + if text == "" then text = fallback or "" end + return text:sub(1, maximum) +end + +local function failure(code, message, status, field) + return { + code = safe_text(code, 96, "rin_error"), + message = safe_text(message, 500, "Rin request failed"), + status = tonumber(status) or 0, + field = safe_text(field, 160, ""), + } +end + +local function validate_token(value) + local token = tostring(value or "") + if #token > 4096 or token:find("[%z\r\n]") or token:match("^%s") or token:match("%s$") then + return nil, failure("invalid_token", "Rin token must be a bounded single-line value") + end + return token +end + +local function is_loopback(host) + host = host:lower() + if host == "localhost" or host == "::1" or host == "0:0:0:0:0:0:0:1" then return true end + local first, second, third, fourth = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") + if not first then return false end + local octets = { tonumber(first), tonumber(second), tonumber(third), tonumber(fourth) } + if octets[1] ~= 127 then return false end + for index = 1, 4 do + if octets[index] < 0 or octets[index] > 255 then return false end + end + return true +end + +local function normalize_base_url(value, token) + local base_url = tostring(value or rin.DEFAULT_BASE_URL):match("^%s*(.-)%s*$") + while base_url:sub(-1) == "/" do base_url = base_url:sub(1, -2) end + local scheme, authority = base_url:match("^(https?)://([^/%?#]+)$") + if not scheme or authority:find("@", 1, true) then + return nil, failure("invalid_base_url", "Rin base URL must be an origin") + end + + local host, port + if authority:sub(1, 1) == "[" then + host, port = authority:match("^%[([^%]]+)%]:(%d+)$") + if not host then host = authority:match("^%[([^%]]+)%]$") end + else + host, port = authority:match("^([^:]+):(%d+)$") + if not host and not authority:find(":", 1, true) then host = authority end + end + if not host or host == "" then + return nil, failure("invalid_base_url", "Rin base URL must be an origin") + end + if port and (tonumber(port) < 1 or tonumber(port) > 65535) then + return nil, failure("invalid_base_url", "Rin base URL has an invalid port") + end + + local loopback = is_loopback(host) + if scheme == "http" and not loopback then + return nil, failure("insecure_base_url", "Remote Rin endpoints must use HTTPS") + end + if not loopback and token == "" then + return nil, failure("missing_token", "Remote Rin endpoints require a token") + end + return base_url +end + +local function path_id(value) + local text = tostring(value or "") + if #text < 1 or #text > 96 then + return nil, failure("invalid_identifier", "Rin path identifier is invalid") + end + for index = 1, #text do + local byte = text:byte(index) + local valid = (byte >= 48 and byte <= 57) or (byte >= 65 and byte <= 90) or + (byte >= 97 and byte <= 122) or byte == 45 or byte == 46 or byte == 95 + if not valid then + return nil, failure("invalid_identifier", "Rin path identifier is invalid") + end + end + return text +end + +local function header_value(headers, wanted) + if type(headers) ~= "table" then return nil end + wanted = wanted:lower() + for key, value in pairs(headers) do + if tostring(key):lower() == wanted then return tostring(value) end + end + return nil +end + +function rin.new(options) + options = options or {} + if type(options) ~= "table" then + return nil, failure("invalid_options", "Rin options must be a table") + end + if type(options.http_fetch) ~= "function" or type(options.json_encode) ~= "function" or + type(options.json_decode) ~= "function" then + return nil, failure("missing_adapter", "http_fetch, json_encode, and json_decode are required") + end + + local token, token_error = validate_token(options.token) + if not token then return nil, token_error end + local base_url, url_error = normalize_base_url(options.base_url, token) + if not base_url then return nil, url_error end + local timeout = tonumber(options.timeout or 5) + local max_response_bytes = tonumber(options.max_response_bytes or rin.DEFAULT_MAX_RESPONSE_BYTES) + if not timeout or timeout ~= timeout or timeout < 0.05 or timeout > 120 then + return nil, failure("invalid_timeout", "Timeout must be between 0.05 and 120 seconds") + end + if not max_response_bytes or max_response_bytes ~= math.floor(max_response_bytes) or + max_response_bytes < 1024 or max_response_bytes > 32 * 1024 * 1024 then + return nil, failure("invalid_response_limit", "Response limit must be between 1 KiB and 32 MiB") + end + + return setmetatable({ + base_url = base_url, + token = token, + timeout = timeout, + max_response_bytes = max_response_bytes, + http_fetch = options.http_fetch, + json_encode = options.json_encode, + json_decode = options.json_decode, + schedule = options.schedule, + now = options.now or os.time, + }, Client) +end + +function Client:_request(method, path, payload, expected_status, callback) + if type(callback) ~= "function" then error("Rin callback is required", 2) end + if type(path) ~= "string" or path:sub(1, 1) ~= "/" or path:find("//", 1, true) or path:find("..", 1, true) then + callback(nil, failure("invalid_path", "Rin request path is invalid")) + return + end + + local body + if payload ~= nil then + if type(payload) ~= "table" then + callback(nil, failure("invalid_request", "Rin payload must be an object")) + return + end + local encoded, value = pcall(self.json_encode, payload) + if not encoded or type(value) ~= "string" then + callback(nil, failure("invalid_request", "Rin payload is not JSON serializable")) + return + end + body = value + end + + local headers = { + ["Accept"] = "application/json", + ["User-Agent"] = "rin-lua/0.5", + } + if body then headers["Content-Type"] = "application/json; charset=utf-8" end + if self.token ~= "" then headers["Authorization"] = "Bearer " .. self.token end + local request = { + url = self.base_url .. path, + method = method, + headers = headers, + body = body, + timeout = self.timeout, + follow_redirects = false, + } + + local delivered = false + local function finish(data, err) + if delivered then return end + delivered = true + callback(data, err) + end + local started, start_error = pcall(self.http_fetch, request, function(response) + if type(response) ~= "table" then + finish(nil, failure("transport_failed", "Rin transport returned an invalid response")) + return + end + local status = tonumber(response.status) + if not status or status ~= math.floor(status) or status < 100 or status > 599 then + finish(nil, failure("transport_failed", "Rin transport did not return a valid status")) + return + end + if status >= 300 and status < 400 then + finish(nil, failure("redirect_rejected", "Rin endpoint attempted to redirect", status)) + return + end + local raw = response.body + if type(raw) ~= "string" then + finish(nil, failure("invalid_response", "Rin response body must be a string", status)) + return + end + local declared_text = header_value(response.headers, "content-length") + local declared = declared_text and tonumber(declared_text) or nil + if declared_text and (not declared or declared < 0 or declared ~= math.floor(declared)) then + finish(nil, failure("invalid_response", "Rin returned an invalid Content-Length", status)) + return + end + if (declared and declared > self.max_response_bytes) or #raw > self.max_response_bytes then + finish(nil, failure("response_too_large", "Rin response exceeds the configured limit", status)) + return + end + + local decoded, envelope = pcall(self.json_decode, raw) + if not decoded or type(envelope) ~= "table" then + if status ~= expected_status then + finish(nil, failure("http_error", "Rin request failed", status)) + else + finish(nil, failure("invalid_response", "Rin returned invalid JSON", status)) + end + return + end + if status ~= expected_status or envelope.ok ~= true then + local detail = type(envelope.error) == "table" and envelope.error or {} + finish(nil, failure(detail.code or "http_error", detail.message or "Rin request failed", status, detail.field)) + return + end + if type(envelope.data) ~= "table" then + finish(nil, failure("invalid_response", "Rin response data must be an object", status)) + return + end + finish(envelope.data, nil) + end) + if not started then + if delivered then error(start_error, 0) end + finish(nil, failure("transport_failed", "Rin transport could not start")) + end +end + +function Client:_post(path, payload, status, callback) + self:_request("POST", path, payload, status or 200, callback) +end + +function Client:health(callback) self:_request("GET", "/health", nil, 200, callback) end +function Client:create_session(payload, callback) self:_post("/v1/session/create", payload, 200, callback) end +function Client:observe(payload, callback) self:_post("/v1/session/observe", payload, 200, callback) end +function Client:propose(payload, callback) self:_post("/v1/agent/propose", payload, 200, callback) end +function Client:submit_proposal_job(payload, callback) self:_post("/v1/jobs/propose", payload, 202, callback) end +function Client:get_proposal_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("GET", "/v1/jobs/" .. id, nil, 200, callback) +end +function Client:cancel_proposal_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("DELETE", "/v1/jobs/" .. id, nil, 200, callback) +end +function Client:submit_generation_job(payload, callback) self:_post("/v1/generation/jobs", payload, 202, callback) end +function Client:get_generation_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("GET", "/v1/generation/jobs/" .. id, nil, 200, callback) +end +function Client:cancel_generation_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("DELETE", "/v1/generation/jobs/" .. id, nil, 200, callback) +end +function Client:commit(payload, callback) self:_post("/v1/action/commit", payload, 200, callback) end +function Client:commit_batch(payload, callback) self:_post("/v1/action/commit-batch", payload, 200, callback) end +function Client:set_actor_activity(payload, callback) self:_post("/v1/session/activity", payload, 200, callback) end +function Client:arbitrate(payload, callback) self:_post("/v1/world/arbitrate", payload, 200, callback) end +function Client:state(payload, callback) self:_post("/v1/session/get", payload, 200, callback) end +function Client:snapshot(payload, callback) self:_post("/v1/session/snapshot", payload, 200, callback) end +function Client:restore(payload, callback) self:_post("/v1/session/restore", payload, 200, callback) end +function Client:timeline(payload, callback) self:_post("/v1/session/timeline", payload, 200, callback) end +function Client:replay(payload, callback) self:_post("/v1/session/replay", payload, 200, callback) end +function Client:due_agents(payload, callback) self:_post("/v1/scheduler/due", payload, 200, callback) end + +function Client:_wait_job(job_id, getter, canceler, options, callback) + options = options or {} + local deadline = tonumber(options.deadline or 25) + local interval = tonumber(options.interval or 0.1) + if type(self.schedule) ~= "function" then + callback(nil, failure("missing_scheduler", "A scheduler is required to wait for jobs")) + return + end + if not deadline or deadline ~= deadline or deadline < 0.05 or deadline > 300 or + not interval or interval ~= interval or interval < 0.01 or interval > 5 then + callback(nil, failure("invalid_polling", "Job deadline or interval is out of range")) + return + end + local expires = self.now() + deadline + local poll + poll = function() + getter(self, job_id, function(job, err) + if err then callback(nil, err); return end + local status = tostring(job.status or "") + if status == "succeeded" then callback(job, nil); return end + if terminal_job_states[status] then + local detail = type(job.error) == "table" and job.error or {} + callback(nil, failure(detail.code or ("job_" .. status), detail.message or ("Rin job ended as " .. status))) + return + end + if status ~= "queued" and status ~= "running" then + callback(nil, failure("invalid_job", "Rin returned an unknown job status")) + return + end + if self.now() >= expires then + canceler(self, job_id, function() end) + callback(nil, failure("job_timeout", "Rin job exceeded its deadline")) + return + end + self.schedule(interval, poll) + end) + end + poll() +end + +function Client:wait_for_proposal(job_id, options, callback) + self:_wait_job(job_id, Client.get_proposal_job, Client.cancel_proposal_job, options, callback) +end + +function Client:wait_for_generation(job_id, options, callback) + local configured = {} + for key, value in pairs(options or {}) do configured[key] = value end + if configured.deadline == nil then configured.deadline = 45 end + self:_wait_job(job_id, Client.get_generation_job, Client.cancel_generation_job, configured, callback) +end + +return rin diff --git a/sdk/lua/test_client.lua b/sdk/lua/test_client.lua new file mode 100644 index 0000000..6634e97 --- /dev/null +++ b/sdk/lua/test_client.lua @@ -0,0 +1,79 @@ +local rin = dofile("sdk/lua/rin.lua") + +local last_request +local function fetch(request, callback) + last_request = request + local accepted = request.url:match("/v1/jobs/propose$") or request.url:match("/v1/generation/jobs$") + callback({ status = accepted and 202 or 200, body = "{}", headers = { ["Content-Length"] = "2" } }) +end + +local client, config_error = rin.new({ + token = "fixture", + http_fetch = fetch, + json_encode = function() return "{}" end, + json_decode = function() return { ok = true, data = { status = "ok" } } end, +}) +assert(client, config_error and config_error.message) + +local cases = { + { function(done) client:health(done) end, "GET", "/health" }, + { function(done) client:create_session({}, done) end, "POST", "/v1/session/create" }, + { function(done) client:observe({}, done) end, "POST", "/v1/session/observe" }, + { function(done) client:propose({}, done) end, "POST", "/v1/agent/propose" }, + { function(done) client:submit_proposal_job({}, done) end, "POST", "/v1/jobs/propose" }, + { function(done) client:get_proposal_job("job.fixture", done) end, "GET", "/v1/jobs/job.fixture" }, + { function(done) client:cancel_proposal_job("job.fixture", done) end, "DELETE", "/v1/jobs/job.fixture" }, + { function(done) client:submit_generation_job({}, done) end, "POST", "/v1/generation/jobs" }, + { function(done) client:get_generation_job("job.fixture", done) end, "GET", "/v1/generation/jobs/job.fixture" }, + { function(done) client:cancel_generation_job("job.fixture", done) end, "DELETE", "/v1/generation/jobs/job.fixture" }, + { function(done) client:commit({}, done) end, "POST", "/v1/action/commit" }, + { function(done) client:commit_batch({}, done) end, "POST", "/v1/action/commit-batch" }, + { function(done) client:set_actor_activity({}, done) end, "POST", "/v1/session/activity" }, + { function(done) client:arbitrate({}, done) end, "POST", "/v1/world/arbitrate" }, + { function(done) client:state({}, done) end, "POST", "/v1/session/get" }, + { function(done) client:snapshot({}, done) end, "POST", "/v1/session/snapshot" }, + { function(done) client:restore({}, done) end, "POST", "/v1/session/restore" }, + { function(done) client:timeline({}, done) end, "POST", "/v1/session/timeline" }, + { function(done) client:replay({}, done) end, "POST", "/v1/session/replay" }, + { function(done) client:due_agents({}, done) end, "POST", "/v1/scheduler/due" }, +} + +for _, test in ipairs(cases) do + test[1](function(data, err) assert(data and not err) end) + assert(last_request.method == test[2], "wrong method for " .. test[3]) + assert(last_request.url:sub(-#test[3]) == test[3], "wrong path for " .. test[3]) + assert(last_request.headers.Authorization == "Bearer fixture") + assert(last_request.follow_redirects == false) +end + +client:get_proposal_job(string.char(228, 189, 156, 228, 184, 154), function(data, err) + assert(not data and err.code == "invalid_identifier") +end) + +local remote, remote_error = rin.new({ + base_url = "http://models.example", + token = "fixture", + http_fetch = fetch, + json_encode = function() return "{}" end, + json_decode = function() return {} end, +}) +assert(not remote and remote_error.code == "insecure_base_url") + +local clock = 0 +local canceled = false +local polling_client = assert(rin.new({ + http_fetch = function(request, callback) + if request.method == "DELETE" then canceled = true end + callback({ status = 200, body = "{}", headers = {} }) + end, + json_encode = function() return "{}" end, + json_decode = function() return { ok = true, data = { status = "running" } } end, + schedule = function(seconds, callback) clock = clock + seconds; callback() end, + now = function() return clock end, +})) +polling_client:wait_for_proposal("job.fixture", { deadline = 0.05, interval = 0.01 }, function(data, err) + assert(not data and err.code == "job_timeout") +end) +assert(canceled, "timed-out job was not canceled") + +print("Rin Lua SDK tests passed") diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 0000000..784e8bd --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,23 @@ +# Rin Python SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +Requires Python 3.9+ and has no third-party dependencies. + +```python +from rin_sdk import PROTOCOL_VERSION, RinClient + +client = RinClient("http://127.0.0.1:7374") +health = client.health() +``` + +Install from this checkout during development: + +```bash +python3 -m pip install -e sdk/python +python3 -m unittest discover -s sdk/python/tests -p 'test_*.py' +``` + +The client is synchronous. Desktop tools and turn-based servers can call it +directly; a real-time game should run calls on its worker system and marshal +only the returned plain dictionaries back to the game thread. diff --git a/sdk/python/README.zh-CN.md b/sdk/python/README.zh-CN.md new file mode 100644 index 0000000..710f610 --- /dev/null +++ b/sdk/python/README.zh-CN.md @@ -0,0 +1,22 @@ +# Rin Python SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +要求 Python 3.9+,没有第三方依赖。 + +```python +from rin_sdk import PROTOCOL_VERSION, RinClient + +client = RinClient("http://127.0.0.1:7374") +health = client.health() +``` + +开发时从当前 Checkout 安装并测试: + +```bash +python3 -m pip install -e sdk/python +python3 -m unittest discover -s sdk/python/tests -p 'test_*.py' +``` + +客户端是同步的。桌面工具和回合制服务器可以直接调用;实时游戏应在自己的 +Worker 系统中运行请求,只把返回的普通 Dictionary 切回游戏线程。 diff --git a/sdk/python/examples/quickstart.py b/sdk/python/examples/quickstart.py new file mode 100644 index 0000000..30e7d06 --- /dev/null +++ b/sdk/python/examples/quickstart.py @@ -0,0 +1,30 @@ +from rin_sdk import PROTOCOL_VERSION, RinClient + + +client = RinClient("http://127.0.0.1:7374") +print(client.health()) + +session = { + "protocol_version": PROTOCOL_VERSION, + "request_id": "create.python-quickstart", + "session_id": "python-quickstart", + "binding": { + "game_id": "python-demo", + "content_id": "base", + "content_version": "1", + "content_hash": "demo-content-hash", + }, + "seed": 42, + "actors": [{ + "id": "npc.guide", + "kind": "npc", + "display_name": "Guide", + "think_every_ticks": 1, + "enabled": True, + }], +} + +try: + print(client.create_session(session)) +except Exception as exc: + print("Session may already exist:", getattr(exc, "code", "rin_error")) diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml new file mode 100644 index 0000000..147fce8 --- /dev/null +++ b/sdk/python/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "rin-game-sdk" +version = "0.5.0" +description = "Dependency-free Python client for the Rin game agent runtime" +requires-python = ">=3.9" +license = {text = "MIT"} + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/sdk/python/src/rin_sdk/__init__.py b/sdk/python/src/rin_sdk/__init__.py new file mode 100644 index 0000000..cdf430e --- /dev/null +++ b/sdk/python/src/rin_sdk/__init__.py @@ -0,0 +1,21 @@ +from .client import ( + DEFAULT_BASE_URL, + PROTOCOL_VERSION, + RinAPIError, + RinClient, + RinConfigurationError, + RinError, + RinProtocolError, + RinTransportError, +) + +__all__ = ( + "DEFAULT_BASE_URL", + "PROTOCOL_VERSION", + "RinAPIError", + "RinClient", + "RinConfigurationError", + "RinError", + "RinProtocolError", + "RinTransportError", +) diff --git a/sdk/python/src/rin_sdk/client.py b/sdk/python/src/rin_sdk/client.py new file mode 100644 index 0000000..a431599 --- /dev/null +++ b/sdk/python/src/rin_sdk/client.py @@ -0,0 +1,318 @@ +"""Strict standard-library client for Rin Protocol v1.""" + +from __future__ import annotations + +import ipaddress +import json +import time +from typing import Any, Callable, Dict, Optional, Sequence, Tuple +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlsplit, urlunsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + + +PROTOCOL_VERSION = "rin.protocol/v1" +DEFAULT_BASE_URL = "http://127.0.0.1:7374" +DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024 +_TERMINAL_JOB_STATES = frozenset(("succeeded", "failed", "stale", "canceled")) + + +class RinError(RuntimeError): + def __init__(self, code: str, message: str) -> None: + self.code = _safe_text(code, 96) or "rin_error" + self.safe_message = _safe_text(message, 500) or "Rin request failed" + super().__init__(self.safe_message) + + +class RinConfigurationError(RinError): + pass + + +class RinTransportError(RinError): + pass + + +class RinProtocolError(RinError): + pass + + +class RinAPIError(RinError): + def __init__(self, code: str, message: str, *, status: int = 0, field: str = "") -> None: + self.status = int(status or 0) + self.field = _safe_text(field, 160) + super().__init__(code, message) + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +class RinClient: + def __init__( + self, + base_url: str = DEFAULT_BASE_URL, + *, + token: str = "", + timeout: float = 5.0, + max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES, + clock: Callable[[], float] = time.monotonic, + sleeper: Callable[[float], None] = time.sleep, + ) -> None: + self.token = _validate_token(token) + self.base_url = _normalize_base_url(base_url, self.token) + self.timeout = float(timeout) + if not 0.05 <= self.timeout <= 120.0: + raise RinConfigurationError("invalid_timeout", "timeout must be between 0.05 and 120 seconds") + self.max_response_bytes = int(max_response_bytes) + if not 1024 <= self.max_response_bytes <= 32 * 1024 * 1024: + raise RinConfigurationError("invalid_response_limit", "response limit must be between 1 KiB and 32 MiB") + self._opener = build_opener(_NoRedirect()) + self._clock = clock + self._sleeper = sleeper + + def health(self) -> Dict[str, Any]: + return self._request("GET", "/health") + + def create_session(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/create", payload) + + def observe(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/observe", payload) + + def propose(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/agent/propose", payload) + + def submit_proposal_job(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("POST", "/v1/jobs/propose", payload, (202,)) + + def get_proposal_job(self, job_id: str) -> Dict[str, Any]: + return self._request("GET", "/v1/jobs/" + _path_id(job_id)) + + def cancel_proposal_job(self, job_id: str) -> Dict[str, Any]: + return self._request("DELETE", "/v1/jobs/" + _path_id(job_id)) + + def submit_generation_job(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("POST", "/v1/generation/jobs", payload, (202,)) + + def get_generation_job(self, job_id: str) -> Dict[str, Any]: + return self._request("GET", "/v1/generation/jobs/" + _path_id(job_id)) + + def cancel_generation_job(self, job_id: str) -> Dict[str, Any]: + return self._request("DELETE", "/v1/generation/jobs/" + _path_id(job_id)) + + def commit(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/action/commit", payload) + + def commit_batch(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/action/commit-batch", payload) + + def set_actor_activity(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/activity", payload) + + def arbitrate(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/world/arbitrate", payload) + + def state(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/get", payload) + + def snapshot(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/snapshot", payload) + + def restore(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/restore", payload) + + def timeline(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/timeline", payload) + + def replay(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/replay", payload) + + def due_agents(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/scheduler/due", payload) + + def wait_for_proposal(self, job_id: str, *, deadline: float = 25.0, interval: float = 0.1) -> Dict[str, Any]: + return self._wait_job(job_id, self.get_proposal_job, self.cancel_proposal_job, deadline, interval) + + def wait_for_generation(self, job_id: str, *, deadline: float = 45.0, interval: float = 0.1) -> Dict[str, Any]: + return self._wait_job(job_id, self.get_generation_job, self.cancel_generation_job, deadline, interval) + + def _wait_job( + self, + job_id: str, + getter: Callable[[str], Dict[str, Any]], + canceler: Callable[[str], Dict[str, Any]], + deadline: float, + interval: float, + ) -> Dict[str, Any]: + if not 0.05 <= deadline <= 300.0 or not 0.01 <= interval <= 5.0: + raise RinConfigurationError("invalid_polling", "job deadline or interval is out of range") + expires = self._clock() + deadline + while True: + job = getter(job_id) + status = str(job.get("status", "")) + if status == "succeeded": + return job + if status in _TERMINAL_JOB_STATES: + detail = job.get("error") if isinstance(job.get("error"), dict) else {} + raise RinAPIError( + _safe_text(detail.get("code"), 96) or "job_" + status, + _safe_text(detail.get("message"), 500) or "Rin job ended as " + status, + ) + if status not in ("queued", "running"): + raise RinProtocolError("invalid_job", "Rin returned an unknown job status") + remaining = expires - self._clock() + if remaining <= 0: + try: + canceler(job_id) + except RinError: + pass + raise RinAPIError("job_timeout", "Rin job exceeded its deadline") + self._sleeper(min(interval, remaining)) + + def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("POST", path, payload) + + def _request( + self, + method: str, + path: str, + payload: Optional[Dict[str, Any]] = None, + expected_statuses: Sequence[int] = (200,), + ) -> Dict[str, Any]: + if not path.startswith("/") or "//" in path or ".." in path: + raise RinConfigurationError("invalid_path", "Rin request path is invalid") + body = None + headers = {"Accept": "application/json", "User-Agent": "rin-python/0.5"} + if payload is not None: + if not isinstance(payload, dict): + raise RinProtocolError("invalid_request", "Rin payload must be an object") + try: + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + except (TypeError, ValueError) as exc: + raise RinProtocolError("invalid_request", "Rin payload is not JSON serializable") from exc + headers["Content-Type"] = "application/json" + if self.token: + headers["Authorization"] = "Bearer " + self.token + request = Request(self.base_url + path, data=body, headers=headers, method=method) + try: + with self._opener.open(request, timeout=self.timeout) as response: + return self._decode(response, int(response.getcode()), tuple(expected_statuses)) + except HTTPError as exc: + try: + return self._decode_error(exc, int(exc.code)) + finally: + exc.close() + except (URLError, TimeoutError, OSError) as exc: + raise RinTransportError("transport_failed", "Rin is unavailable") from exc + + def _decode(self, response: Any, status: int, expected: Tuple[int, ...]) -> Dict[str, Any]: + declared = response.headers.get("Content-Length", "") + if declared: + try: + length = int(declared) + if length < 0: + raise RinProtocolError("invalid_response", "Rin returned an invalid Content-Length") + if length > self.max_response_bytes: + raise RinProtocolError("response_too_large", "Rin response exceeds the configured limit") + except ValueError as exc: + raise RinProtocolError("invalid_response", "Rin returned an invalid Content-Length") from exc + raw = response.read(self.max_response_bytes + 1) + if len(raw) > self.max_response_bytes: + raise RinProtocolError("response_too_large", "Rin response exceeds the configured limit") + envelope = _parse_envelope(raw) + if status not in expected or envelope.get("ok") is not True: + raise _api_error(envelope, status) + data = envelope.get("data") + if not isinstance(data, dict): + raise RinProtocolError("invalid_response", "Rin response data must be an object") + return data + + def _decode_error(self, response: HTTPError, status: int) -> Dict[str, Any]: + if 300 <= status < 400: + raise RinTransportError("redirect_rejected", "Rin endpoint attempted to redirect") + raw = response.read(self.max_response_bytes + 1) + if len(raw) > self.max_response_bytes: + raise RinProtocolError("response_too_large", "Rin error response exceeds the configured limit") + try: + envelope = _parse_envelope(raw) + except RinProtocolError: + envelope = {} + raise _api_error(envelope, status) + + +def _parse_envelope(raw: bytes) -> Dict[str, Any]: + try: + value = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RinProtocolError("invalid_response", "Rin returned invalid JSON") from exc + if not isinstance(value, dict): + raise RinProtocolError("invalid_response", "Rin response must be an object") + return value + + +def _api_error(envelope: Dict[str, Any], status: int) -> RinAPIError: + detail = envelope.get("error") if isinstance(envelope.get("error"), dict) else {} + return RinAPIError( + _safe_text(detail.get("code"), 96) or "http_error", + _safe_text(detail.get("message"), 500) or "Rin request failed", + status=status, + field=_safe_text(detail.get("field"), 160), + ) + + +def _normalize_base_url(value: str, token: str) -> str: + parsed = urlsplit(str(value or DEFAULT_BASE_URL).strip().rstrip("/")) + try: + port = parsed.port + except ValueError as exc: + raise RinConfigurationError("invalid_base_url", "Rin base URL has an invalid port") from exc + if ( + parsed.scheme not in ("http", "https") + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in ("", "/") + ): + raise RinConfigurationError("invalid_base_url", "Rin base URL must be an origin") + loopback = _is_loopback(parsed.hostname) + if parsed.scheme == "http" and not loopback: + raise RinConfigurationError("insecure_base_url", "remote Rin endpoints must use HTTPS") + if not loopback and not token: + raise RinConfigurationError("missing_token", "remote Rin endpoints require a token") + if port is not None and not 1 <= port <= 65535: + raise RinConfigurationError("invalid_base_url", "Rin base URL has an invalid port") + return urlunsplit((parsed.scheme, parsed.netloc, "", "", "")).rstrip("/") + + +def _is_loopback(host: str) -> bool: + if host.casefold() == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _validate_token(value: str) -> str: + token = str(value or "") + if token != token.strip() or any(character in token for character in ("\x00", "\r", "\n")) or len(token) > 4096: + raise RinConfigurationError("invalid_token", "Rin token must be a bounded single-line value") + return token + + +def _path_id(value: str) -> str: + text = str(value or "") + if ( + not text + or len(text) > 96 + or not all(character.isascii() and (character.isalnum() or character in "._-") for character in text) + ): + raise RinConfigurationError("invalid_identifier", "Rin path identifier is invalid") + return quote(text, safe="._-") + + +def _safe_text(value: Any, maximum: int) -> str: + return " ".join(str(value or "").replace("\x00", "").split())[:maximum] diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py new file mode 100644 index 0000000..7525ce9 --- /dev/null +++ b/sdk/python/tests/test_client.py @@ -0,0 +1,152 @@ +import io +import json +import sys +import unittest +from pathlib import Path + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from rin_sdk import ( # noqa: E402 + RinAPIError, + RinClient, + RinConfigurationError, + RinProtocolError, + RinTransportError, +) + + +class _Response: + def __init__(self, status, payload): + self.status = status + self.payload = json.dumps(payload).encode("utf-8") + self.headers = {"Content-Length": str(len(self.payload))} + self.stream = io.BytesIO(self.payload) + + def getcode(self): + return self.status + + def read(self, maximum=-1): + return self.stream.read(maximum) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + +class _Opener: + def __init__(self): + self.path = "" + self.method = "" + self.authorization = "" + + def open(self, request, timeout): + del timeout + self.path = request.full_url.split("7374", 1)[-1] + self.method = request.get_method() + self.authorization = request.get_header("Authorization", "") + status = 202 if self.path in ("/v1/jobs/propose", "/v1/generation/jobs") else 200 + return _Response(status, {"ok": True, "data": {"status": "ok", "job_id": "job.fixture"}}) + + +class RinClientTests(unittest.TestCase): + def test_routes_and_token(self): + client = RinClient(token="fixture") + client._opener = _Opener() + cases = ( + (client.health, (), "GET", "/health"), + (client.create_session, ({},), "POST", "/v1/session/create"), + (client.observe, ({},), "POST", "/v1/session/observe"), + (client.propose, ({},), "POST", "/v1/agent/propose"), + (client.submit_proposal_job, ({},), "POST", "/v1/jobs/propose"), + (client.get_proposal_job, ("job.fixture",), "GET", "/v1/jobs/job.fixture"), + (client.cancel_proposal_job, ("job.fixture",), "DELETE", "/v1/jobs/job.fixture"), + (client.submit_generation_job, ({},), "POST", "/v1/generation/jobs"), + (client.get_generation_job, ("job.fixture",), "GET", "/v1/generation/jobs/job.fixture"), + (client.cancel_generation_job, ("job.fixture",), "DELETE", "/v1/generation/jobs/job.fixture"), + (client.commit, ({},), "POST", "/v1/action/commit"), + (client.commit_batch, ({},), "POST", "/v1/action/commit-batch"), + (client.set_actor_activity, ({},), "POST", "/v1/session/activity"), + (client.arbitrate, ({},), "POST", "/v1/world/arbitrate"), + (client.state, ({},), "POST", "/v1/session/get"), + (client.snapshot, ({},), "POST", "/v1/session/snapshot"), + (client.restore, ({},), "POST", "/v1/session/restore"), + (client.timeline, ({},), "POST", "/v1/session/timeline"), + (client.replay, ({},), "POST", "/v1/session/replay"), + (client.due_agents, ({},), "POST", "/v1/scheduler/due"), + ) + for method, args, http_method, path in cases: + with self.subTest(path=path): + method(*args) + self.assertEqual(client._opener.path, path) + self.assertEqual(client._opener.method, http_method) + self.assertEqual(client._opener.authorization, "Bearer fixture") + + def test_job_id_is_ascii_and_path_safe(self): + client = RinClient() + client._opener = _Opener() + for invalid in ("", "../job", "job/other", "\u4f5c\u4e1a"): + with self.subTest(job_id=invalid), self.assertRaises(RinConfigurationError): + client.get_proposal_job(invalid) + + def test_remote_endpoint_requires_tls_and_token(self): + with self.assertRaises(RinConfigurationError): + RinClient("http://models.example", token="fixture") + with self.assertRaises(RinConfigurationError): + RinClient("https://models.example") + self.assertEqual(RinClient("https://models.example", token="fixture").base_url, "https://models.example") + + def test_api_error_is_bounded(self): + client = RinClient() + + class ErrorOpener: + def open(self, request, timeout): + del request, timeout + from urllib.error import HTTPError + + body = json.dumps({"ok": False, "error": {"code": "invalid_request", "message": "safe"}}).encode() + raise HTTPError("http://127.0.0.1", 400, "Bad", {}, io.BytesIO(body)) + + client._opener = ErrorOpener() + with self.assertRaises(RinAPIError) as caught: + client.health() + self.assertEqual(caught.exception.code, "invalid_request") + self.assertEqual(caught.exception.status, 400) + + def test_invalid_payload_and_content_length_are_protocol_errors(self): + client = RinClient() + with self.assertRaises(RinProtocolError): + client.observe({"recursive": object()}) + + response = _Response(200, {"ok": True, "data": {}}) + response.headers["Content-Length"] = "-1" + + class NegativeLengthOpener: + def open(self, request, timeout): + del request, timeout + return response + + client._opener = NegativeLengthOpener() + with self.assertRaises(RinProtocolError): + client.health() + + def test_redirect_is_rejected(self): + client = RinClient() + + class RedirectOpener: + def open(self, request, timeout): + del request, timeout + from urllib.error import HTTPError + + raise HTTPError("http://127.0.0.1", 302, "Found", {"Location": "https://example.com"}, io.BytesIO(b"")) + + client._opener = RedirectOpener() + with self.assertRaises(RinTransportError) as caught: + client.health() + self.assertEqual(caught.exception.code, "redirect_rejected") + + +if __name__ == "__main__": + unittest.main()