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