From f1295d8eb710f87d1396bdfffcd3867e4b8f8c9a Mon Sep 17 00:00:00 2001 From: sunrioa Date: Thu, 23 Jul 2026 01:41:39 +0800 Subject: [PATCH 1/4] feat: add Python and JavaScript SDKs --- sdk/README.md | 30 +++ sdk/conformance/routes.json | 26 +++ sdk/javascript/README.md | 21 ++ sdk/javascript/examples/quickstart.js | 10 + sdk/javascript/package.json | 20 ++ sdk/javascript/src/index.d.ts | 56 +++++ sdk/javascript/src/index.js | 254 +++++++++++++++++++++ sdk/javascript/test/client.test.js | 87 ++++++++ sdk/python/README.md | 21 ++ sdk/python/examples/quickstart.py | 30 +++ sdk/python/pyproject.toml | 16 ++ sdk/python/src/rin_sdk/__init__.py | 21 ++ sdk/python/src/rin_sdk/client.py | 307 ++++++++++++++++++++++++++ sdk/python/tests/test_client.py | 114 ++++++++++ 14 files changed, 1013 insertions(+) create mode 100644 sdk/README.md create mode 100644 sdk/conformance/routes.json create mode 100644 sdk/javascript/README.md create mode 100644 sdk/javascript/examples/quickstart.js create mode 100644 sdk/javascript/package.json create mode 100644 sdk/javascript/src/index.d.ts create mode 100644 sdk/javascript/src/index.js create mode 100644 sdk/javascript/test/client.test.js create mode 100644 sdk/python/README.md create mode 100644 sdk/python/examples/quickstart.py create mode 100644 sdk/python/pyproject.toml create mode 100644 sdk/python/src/rin_sdk/__init__.py create mode 100644 sdk/python/src/rin_sdk/client.py create mode 100644 sdk/python/tests/test_client.py diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..2b99112 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,30 @@ +# Rin SDKs + +These source SDKs expose the same `rin.protocol/v1` HTTP boundary without +moving game authority into the client library. + +| Language | Runtime | JSON | Async guidance | +| --- | --- | --- | --- | +| Python | 3.9+ | standard library | call from a worker in real-time games | +| JavaScript | Node 18+ / modern browser host | built in | Promise-based | +| C# | .NET 6+ | `System.Text.Json` | `Task`-based | +| Java | 17+ | host-provided JSON text | `CompletableFuture`-based | +| Lua | 5.1+ host | injected codec and transport | callback-based | + +All clients follow these rules: + +- plaintext HTTP is accepted only for an explicit loopback origin; +- remote origins require HTTPS and a bearer token; +- redirects are rejected; +- request timeouts and response-size limits are mandatory; +- errors expose bounded Rin codes, not provider bodies or credentials; +- proposals remain pending until the game applies and commits them. + +The SDKs are intentionally source-first and are not yet published to PyPI, +npm, NuGet, or Maven Central. Pin this repository revision when vendoring one. +Route compatibility is defined by [`conformance/routes.json`](conformance/routes.json). + +Game-specific examples live under [`examples/mods`](../examples/mods). They +show where host events enter Rin and where the game validates and applies a +proposal. They are integration templates, not universal patches for every +game version. diff --git a/sdk/conformance/routes.json b/sdk/conformance/routes.json new file mode 100644 index 0000000..69bb69a --- /dev/null +++ b/sdk/conformance/routes.json @@ -0,0 +1,26 @@ +{ + "schema_version": 1, + "protocol_version": "rin.protocol/v1", + "operations": [ + {"name": "health", "method": "GET", "path": "/health", "status": 200}, + {"name": "create_session", "method": "POST", "path": "/v1/session/create", "status": 200}, + {"name": "observe", "method": "POST", "path": "/v1/session/observe", "status": 200}, + {"name": "propose", "method": "POST", "path": "/v1/agent/propose", "status": 200}, + {"name": "submit_proposal_job", "method": "POST", "path": "/v1/jobs/propose", "status": 202}, + {"name": "get_proposal_job", "method": "GET", "path": "/v1/jobs/{job_id}", "status": 200}, + {"name": "cancel_proposal_job", "method": "DELETE", "path": "/v1/jobs/{job_id}", "status": 200}, + {"name": "submit_generation_job", "method": "POST", "path": "/v1/generation/jobs", "status": 202}, + {"name": "get_generation_job", "method": "GET", "path": "/v1/generation/jobs/{job_id}", "status": 200}, + {"name": "cancel_generation_job", "method": "DELETE", "path": "/v1/generation/jobs/{job_id}", "status": 200}, + {"name": "commit", "method": "POST", "path": "/v1/action/commit", "status": 200}, + {"name": "commit_batch", "method": "POST", "path": "/v1/action/commit-batch", "status": 200}, + {"name": "set_actor_activity", "method": "POST", "path": "/v1/session/activity", "status": 200}, + {"name": "arbitrate", "method": "POST", "path": "/v1/world/arbitrate", "status": 200}, + {"name": "state", "method": "POST", "path": "/v1/session/get", "status": 200}, + {"name": "snapshot", "method": "POST", "path": "/v1/session/snapshot", "status": 200}, + {"name": "restore", "method": "POST", "path": "/v1/session/restore", "status": 200}, + {"name": "timeline", "method": "POST", "path": "/v1/session/timeline", "status": 200}, + {"name": "replay", "method": "POST", "path": "/v1/session/replay", "status": 200}, + {"name": "due_agents", "method": "POST", "path": "/v1/scheduler/due", "status": 200} + ] +} diff --git a/sdk/javascript/README.md b/sdk/javascript/README.md new file mode 100644 index 0000000..4669afb --- /dev/null +++ b/sdk/javascript/README.md @@ -0,0 +1,21 @@ +# Rin JavaScript SDK + +Requires Node.js 18+ or a host that implements the standard Fetch API. The +package has no runtime dependencies and includes TypeScript declarations. + +```js +import { RinClient } from "@sunrioa/rin-sdk"; + +const rin = new RinClient("http://127.0.0.1:7374"); +console.log(await rin.health()); +``` + +Run directly from this checkout: + +```bash +node sdk/javascript/examples/quickstart.js +cd sdk/javascript && npm test +``` + +Calls are Promise-based. Apply engine state only after returning to the +engine's main thread and validating the proposal against a local allowlist. diff --git a/sdk/javascript/examples/quickstart.js b/sdk/javascript/examples/quickstart.js new file mode 100644 index 0000000..ea93d64 --- /dev/null +++ b/sdk/javascript/examples/quickstart.js @@ -0,0 +1,10 @@ +import { RinClient } from "../src/index.js"; + +const client = new RinClient(process.env.RIN_URL, { token: process.env.RIN_TOKEN }); + +try { + console.log(await client.health()); +} catch (error) { + console.error(`${error.code || "rin_error"}: ${error.message}`); + process.exitCode = 1; +} diff --git a/sdk/javascript/package.json b/sdk/javascript/package.json new file mode 100644 index 0000000..aae3348 --- /dev/null +++ b/sdk/javascript/package.json @@ -0,0 +1,20 @@ +{ + "name": "@sunrioa/rin-sdk", + "version": "0.5.0", + "description": "Zero-dependency JavaScript client for the Rin game agent runtime", + "type": "module", + "exports": { + ".": { + "types": "./src/index.d.ts", + "import": "./src/index.js" + } + }, + "scripts": { + "test": "node --test" + }, + "engines": { + "node": ">=18" + }, + "license": "MIT", + "private": true +} diff --git a/sdk/javascript/src/index.d.ts b/sdk/javascript/src/index.d.ts new file mode 100644 index 0000000..5854bdd --- /dev/null +++ b/sdk/javascript/src/index.d.ts @@ -0,0 +1,56 @@ +export const PROTOCOL_VERSION: "rin.protocol/v1"; +export const DEFAULT_BASE_URL: string; +export const DEFAULT_MAX_RESPONSE_BYTES: number; + +export type RinObject = Record; +export type FetchImplementation = typeof globalThis.fetch; + +export interface RinClientOptions { + token?: string; + timeoutMs?: number; + maxResponseBytes?: number; + fetch?: FetchImplementation; + now?: () => number; + sleep?: (milliseconds: number) => Promise; +} + +export interface RinPollingOptions { + deadlineMs?: number; + intervalMs?: number; +} + +export class RinError extends Error { readonly code: string; } +export class RinConfigurationError extends RinError {} +export class RinTransportError extends RinError {} +export class RinProtocolError extends RinError {} +export class RinAPIError extends RinError { + readonly status: number; + readonly field: string; +} + +export class RinClient { + constructor(baseUrl?: string, options?: RinClientOptions); + readonly baseUrl: string; + health(): Promise; + createSession(payload: RinObject): Promise; + observe(payload: RinObject): Promise; + propose(payload: RinObject): Promise; + submitProposalJob(payload: RinObject): Promise; + getProposalJob(jobId: string): Promise; + cancelProposalJob(jobId: string): Promise; + submitGenerationJob(payload: RinObject): Promise; + getGenerationJob(jobId: string): Promise; + cancelGenerationJob(jobId: string): Promise; + commit(payload: RinObject): Promise; + commitBatch(payload: RinObject): Promise; + setActorActivity(payload: RinObject): Promise; + arbitrate(payload: RinObject): Promise; + state(payload: RinObject): Promise; + snapshot(payload: RinObject): Promise; + restore(payload: RinObject): Promise; + timeline(payload: RinObject): Promise; + replay(payload: RinObject): Promise; + dueAgents(payload: RinObject): Promise; + waitForProposal(jobId: string, options?: RinPollingOptions): Promise; + waitForGeneration(jobId: string, options?: RinPollingOptions): Promise; +} diff --git a/sdk/javascript/src/index.js b/sdk/javascript/src/index.js new file mode 100644 index 0000000..6a8367e --- /dev/null +++ b/sdk/javascript/src/index.js @@ -0,0 +1,254 @@ +export const PROTOCOL_VERSION = "rin.protocol/v1"; +export const DEFAULT_BASE_URL = "http://127.0.0.1:7374"; +export const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; + +const TERMINAL_JOB_STATES = new Set(["succeeded", "failed", "stale", "canceled"]); + +export class RinError extends Error { + constructor(code, message, options = {}) { + super(safeText(message, 500) || "Rin request failed", options); + this.name = new.target.name; + this.code = safeText(code, 96) || "rin_error"; + } +} + +export class RinConfigurationError extends RinError {} +export class RinTransportError extends RinError {} +export class RinProtocolError extends RinError {} + +export class RinAPIError extends RinError { + constructor(code, message, { status = 0, field = "", cause } = {}) { + super(code, message, cause ? { cause } : {}); + this.status = Number(status) || 0; + this.field = safeText(field, 160); + } +} + +export class RinClient { + constructor(baseUrl = DEFAULT_BASE_URL, options = {}) { + const { + token = "", + timeoutMs = 5000, + maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES, + fetch: fetchImplementation = globalThis.fetch, + now = () => Date.now(), + sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), + } = options; + + this.token = validateToken(token); + this.baseUrl = normalizeBaseUrl(baseUrl, this.token); + this.timeoutMs = Number(timeoutMs); + if (!Number.isFinite(this.timeoutMs) || this.timeoutMs < 50 || this.timeoutMs > 120000) { + throw new RinConfigurationError("invalid_timeout", "timeoutMs must be between 50 and 120000"); + } + this.maxResponseBytes = Number(maxResponseBytes); + if (!Number.isSafeInteger(this.maxResponseBytes) || this.maxResponseBytes < 1024 || this.maxResponseBytes > 32 * 1024 * 1024) { + throw new RinConfigurationError("invalid_response_limit", "response limit must be between 1 KiB and 32 MiB"); + } + if (typeof fetchImplementation !== "function") { + throw new RinConfigurationError("missing_fetch", "a Fetch API implementation is required"); + } + this.fetch = fetchImplementation; + this.now = now; + this.sleep = sleep; + } + + health() { return this.request("GET", "/health"); } + createSession(payload) { return this.post("/v1/session/create", payload); } + observe(payload) { return this.post("/v1/session/observe", payload); } + propose(payload) { return this.post("/v1/agent/propose", payload); } + submitProposalJob(payload) { return this.request("POST", "/v1/jobs/propose", payload, [202]); } + getProposalJob(jobId) { return this.request("GET", `/v1/jobs/${pathId(jobId)}`); } + cancelProposalJob(jobId) { return this.request("DELETE", `/v1/jobs/${pathId(jobId)}`); } + submitGenerationJob(payload) { return this.request("POST", "/v1/generation/jobs", payload, [202]); } + getGenerationJob(jobId) { return this.request("GET", `/v1/generation/jobs/${pathId(jobId)}`); } + cancelGenerationJob(jobId) { return this.request("DELETE", `/v1/generation/jobs/${pathId(jobId)}`); } + commit(payload) { return this.post("/v1/action/commit", payload); } + commitBatch(payload) { return this.post("/v1/action/commit-batch", payload); } + setActorActivity(payload) { return this.post("/v1/session/activity", payload); } + arbitrate(payload) { return this.post("/v1/world/arbitrate", payload); } + state(payload) { return this.post("/v1/session/get", payload); } + snapshot(payload) { return this.post("/v1/session/snapshot", payload); } + restore(payload) { return this.post("/v1/session/restore", payload); } + timeline(payload) { return this.post("/v1/session/timeline", payload); } + replay(payload) { return this.post("/v1/session/replay", payload); } + dueAgents(payload) { return this.post("/v1/scheduler/due", payload); } + + waitForProposal(jobId, options = {}) { + return this.waitJob(jobId, this.getProposalJob.bind(this), this.cancelProposalJob.bind(this), { + deadlineMs: 25000, + ...options, + }); + } + + waitForGeneration(jobId, options = {}) { + return this.waitJob(jobId, this.getGenerationJob.bind(this), this.cancelGenerationJob.bind(this), { + deadlineMs: 45000, + ...options, + }); + } + + async waitJob(jobId, getter, canceler, { deadlineMs, intervalMs = 100 }) { + if (!Number.isFinite(deadlineMs) || deadlineMs < 50 || deadlineMs > 300000 || + !Number.isFinite(intervalMs) || intervalMs < 10 || intervalMs > 5000) { + throw new RinConfigurationError("invalid_polling", "job deadline or interval is out of range"); + } + const expires = this.now() + deadlineMs; + for (;;) { + const job = await getter(jobId); + const status = String(job.status || ""); + if (status === "succeeded") return job; + if (TERMINAL_JOB_STATES.has(status)) { + const detail = isObject(job.error) ? job.error : {}; + throw new RinAPIError( + safeText(detail.code, 96) || `job_${status}`, + safeText(detail.message, 500) || `Rin job ended as ${status}`, + ); + } + if (status !== "queued" && status !== "running") { + throw new RinProtocolError("invalid_job", "Rin returned an unknown job status"); + } + const remaining = expires - this.now(); + if (remaining <= 0) { + try { await canceler(jobId); } catch (error) { if (!(error instanceof RinError)) throw error; } + throw new RinAPIError("job_timeout", "Rin job exceeded its deadline"); + } + await this.sleep(Math.min(intervalMs, remaining)); + } + } + + post(path, payload) { + return this.request("POST", path, payload); + } + + async request(method, path, payload, expectedStatuses = [200]) { + if (typeof path !== "string" || !path.startsWith("/") || path.includes("//") || path.includes("..")) { + throw new RinConfigurationError("invalid_path", "Rin request path is invalid"); + } + const headers = { Accept: "application/json", "User-Agent": "rin-javascript/0.5" }; + let body; + if (payload !== undefined) { + if (!isObject(payload)) { + throw new RinProtocolError("invalid_request", "Rin payload must be an object"); + } + try { + body = JSON.stringify(payload); + } catch (cause) { + throw new RinProtocolError("invalid_request", "Rin payload is not JSON serializable", { cause }); + } + headers["Content-Type"] = "application/json"; + } + if (this.token) headers.Authorization = `Bearer ${this.token}`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + let response; + try { + response = await this.fetch(`${this.baseUrl}${path}`, { + method, + headers, + body, + signal: controller.signal, + redirect: "error", + }); + } catch (cause) { + if (cause instanceof RinError) throw cause; + throw new RinTransportError("transport_failed", "Rin is unavailable", { cause }); + } finally { + clearTimeout(timer); + } + + const declared = response.headers?.get?.("content-length"); + if (declared !== null && declared !== undefined && declared !== "") { + const length = Number(declared); + if (!Number.isSafeInteger(length) || length < 0) { + throw new RinProtocolError("invalid_response", "Rin returned an invalid Content-Length"); + } + if (length > this.maxResponseBytes) { + throw new RinProtocolError("response_too_large", "Rin response exceeds the configured limit"); + } + } + const raw = await response.arrayBuffer(); + if (raw.byteLength > this.maxResponseBytes) { + throw new RinProtocolError("response_too_large", "Rin response exceeds the configured limit"); + } + + let envelope; + try { + envelope = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)); + } catch (cause) { + throw new RinProtocolError("invalid_response", "Rin returned invalid JSON", { cause }); + } + if (!isObject(envelope)) { + throw new RinProtocolError("invalid_response", "Rin response must be an object"); + } + if (!expectedStatuses.includes(response.status) || envelope.ok !== true) { + throw apiError(envelope, response.status); + } + if (!isObject(envelope.data)) { + throw new RinProtocolError("invalid_response", "Rin response data must be an object"); + } + return envelope.data; + } +} + +function normalizeBaseUrl(value, token) { + let parsed; + try { + parsed = new URL(String(value || DEFAULT_BASE_URL).trim().replace(/\/+$/, "")); + } catch (cause) { + throw new RinConfigurationError("invalid_base_url", "Rin base URL must be an origin", { cause }); + } + if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || + parsed.search || parsed.hash || (parsed.pathname !== "/" && parsed.pathname !== "")) { + throw new RinConfigurationError("invalid_base_url", "Rin base URL must be an origin"); + } + const loopback = isLoopback(parsed.hostname); + if (parsed.protocol === "http:" && !loopback) { + throw new RinConfigurationError("insecure_base_url", "remote Rin endpoints must use HTTPS"); + } + if (!loopback && !token) { + throw new RinConfigurationError("missing_token", "remote Rin endpoints require a token"); + } + return parsed.origin; +} + +function isLoopback(hostname) { + const host = String(hostname).toLowerCase().replace(/^\[|\]$/g, ""); + if (host === "localhost" || host === "::1" || host === "0:0:0:0:0:0:0:1") return true; + const octets = host.split("."); + return octets.length === 4 && octets.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255) && Number(octets[0]) === 127; +} + +function validateToken(value) { + const token = String(value || ""); + if (token !== token.trim() || /[\0\r\n]/.test(token) || token.length > 4096) { + throw new RinConfigurationError("invalid_token", "Rin token must be a bounded single-line value"); + } + return token; +} + +function pathId(value) { + const text = String(value || ""); + if (!/^[A-Za-z0-9._-]{1,96}$/.test(text)) { + throw new RinConfigurationError("invalid_identifier", "Rin path identifier is invalid"); + } + return encodeURIComponent(text); +} + +function apiError(envelope, status) { + const detail = isObject(envelope.error) ? envelope.error : {}; + return new RinAPIError( + safeText(detail.code, 96) || "http_error", + safeText(detail.message, 500) || "Rin request failed", + { status, field: safeText(detail.field, 160) }, + ); +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function safeText(value, maximum) { + return String(value ?? "").replace(/\0/g, "").trim().split(/\s+/).filter(Boolean).join(" ").slice(0, maximum); +} diff --git a/sdk/javascript/test/client.test.js b/sdk/javascript/test/client.test.js new file mode 100644 index 0000000..89fb4f3 --- /dev/null +++ b/sdk/javascript/test/client.test.js @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + RinAPIError, + RinClient, + RinConfigurationError, + RinProtocolError, +} from "../src/index.js"; + +function response(status, envelope, headers = {}) { + const bytes = new TextEncoder().encode(JSON.stringify(envelope)); + const values = new Map(Object.entries({ "content-length": String(bytes.byteLength), ...headers })); + return { + status, + headers: { get: (name) => values.get(name.toLowerCase()) ?? null }, + arrayBuffer: async () => bytes.buffer, + }; +} + +test("all protocol routes use the expected method and bearer token", async () => { + const requests = []; + const fetch = async (url, options) => { + requests.push({ url: new URL(url), options }); + const accepted = url.endsWith("/v1/jobs/propose") || url.endsWith("/v1/generation/jobs") ? 202 : 200; + return response(accepted, { ok: true, data: { status: "ok" } }); + }; + const client = new RinClient(undefined, { token: "fixture", fetch }); + const cases = [ + [() => client.health(), "GET", "/health"], + [() => client.createSession({}), "POST", "/v1/session/create"], + [() => client.observe({}), "POST", "/v1/session/observe"], + [() => client.propose({}), "POST", "/v1/agent/propose"], + [() => client.submitProposalJob({}), "POST", "/v1/jobs/propose"], + [() => client.getProposalJob("job.fixture"), "GET", "/v1/jobs/job.fixture"], + [() => client.cancelProposalJob("job.fixture"), "DELETE", "/v1/jobs/job.fixture"], + [() => client.submitGenerationJob({}), "POST", "/v1/generation/jobs"], + [() => client.getGenerationJob("job.fixture"), "GET", "/v1/generation/jobs/job.fixture"], + [() => client.cancelGenerationJob("job.fixture"), "DELETE", "/v1/generation/jobs/job.fixture"], + [() => client.commit({}), "POST", "/v1/action/commit"], + [() => client.commitBatch({}), "POST", "/v1/action/commit-batch"], + [() => client.setActorActivity({}), "POST", "/v1/session/activity"], + [() => client.arbitrate({}), "POST", "/v1/world/arbitrate"], + [() => client.state({}), "POST", "/v1/session/get"], + [() => client.snapshot({}), "POST", "/v1/session/snapshot"], + [() => client.restore({}), "POST", "/v1/session/restore"], + [() => client.timeline({}), "POST", "/v1/session/timeline"], + [() => client.replay({}), "POST", "/v1/session/replay"], + [() => client.dueAgents({}), "POST", "/v1/scheduler/due"], + ]; + for (const [call, method, path] of cases) { + await call(); + const request = requests.at(-1); + assert.equal(request.url.pathname, path); + assert.equal(request.options.method, method); + assert.equal(request.options.headers.Authorization, "Bearer fixture"); + assert.equal(request.options.redirect, "error"); + } +}); + +test("remote endpoints require TLS and a token", () => { + assert.throws(() => new RinClient("http://models.example", { token: "fixture", fetch: () => {} }), RinConfigurationError); + assert.throws(() => new RinClient("https://models.example", { fetch: () => {} }), RinConfigurationError); + assert.equal(new RinClient("https://models.example", { token: "fixture", fetch: () => {} }).baseUrl, "https://models.example"); +}); + +test("unsafe identifiers and oversized responses are rejected", async () => { + const client = new RinClient(undefined, { + maxResponseBytes: 1024, + fetch: async () => response(200, { ok: true, data: {} }, { "content-length": "2048" }), + }); + assert.throws(() => client.getProposalJob("作业"), RinConfigurationError); + await assert.rejects(client.health(), RinProtocolError); +}); + +test("API errors expose only the bounded protocol detail", async () => { + const client = new RinClient(undefined, { + fetch: async () => response(400, { ok: false, error: { code: "invalid_request", message: "safe", field: "actor_id" } }), + }); + await assert.rejects(client.health(), (error) => { + assert.ok(error instanceof RinAPIError); + assert.equal(error.code, "invalid_request"); + assert.equal(error.status, 400); + assert.equal(error.field, "actor_id"); + return true; + }); +}); diff --git a/sdk/python/README.md b/sdk/python/README.md new file mode 100644 index 0000000..1c92e63 --- /dev/null +++ b/sdk/python/README.md @@ -0,0 +1,21 @@ +# Rin Python SDK + +Requires Python 3.9+ and has no third-party dependencies. + +```python +from rin_sdk import PROTOCOL_VERSION, RinClient + +client = RinClient("http://127.0.0.1:7374") +health = client.health() +``` + +Install from this checkout during development: + +```bash +python3 -m pip install -e sdk/python +python3 -m unittest discover -s sdk/python/tests -p 'test_*.py' +``` + +The client is synchronous. Desktop tools and turn-based servers can call it +directly; a real-time game should run calls on its worker system and marshal +only the returned plain dictionaries back to the game thread. diff --git a/sdk/python/examples/quickstart.py b/sdk/python/examples/quickstart.py new file mode 100644 index 0000000..30e7d06 --- /dev/null +++ b/sdk/python/examples/quickstart.py @@ -0,0 +1,30 @@ +from rin_sdk import PROTOCOL_VERSION, RinClient + + +client = RinClient("http://127.0.0.1:7374") +print(client.health()) + +session = { + "protocol_version": PROTOCOL_VERSION, + "request_id": "create.python-quickstart", + "session_id": "python-quickstart", + "binding": { + "game_id": "python-demo", + "content_id": "base", + "content_version": "1", + "content_hash": "demo-content-hash", + }, + "seed": 42, + "actors": [{ + "id": "npc.guide", + "kind": "npc", + "display_name": "Guide", + "think_every_ticks": 1, + "enabled": True, + }], +} + +try: + print(client.create_session(session)) +except Exception as exc: + print("Session may already exist:", getattr(exc, "code", "rin_error")) diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml new file mode 100644 index 0000000..147fce8 --- /dev/null +++ b/sdk/python/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "rin-game-sdk" +version = "0.5.0" +description = "Dependency-free Python client for the Rin game agent runtime" +requires-python = ">=3.9" +license = {text = "MIT"} + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/sdk/python/src/rin_sdk/__init__.py b/sdk/python/src/rin_sdk/__init__.py new file mode 100644 index 0000000..cdf430e --- /dev/null +++ b/sdk/python/src/rin_sdk/__init__.py @@ -0,0 +1,21 @@ +from .client import ( + DEFAULT_BASE_URL, + PROTOCOL_VERSION, + RinAPIError, + RinClient, + RinConfigurationError, + RinError, + RinProtocolError, + RinTransportError, +) + +__all__ = ( + "DEFAULT_BASE_URL", + "PROTOCOL_VERSION", + "RinAPIError", + "RinClient", + "RinConfigurationError", + "RinError", + "RinProtocolError", + "RinTransportError", +) diff --git a/sdk/python/src/rin_sdk/client.py b/sdk/python/src/rin_sdk/client.py new file mode 100644 index 0000000..8cb650d --- /dev/null +++ b/sdk/python/src/rin_sdk/client.py @@ -0,0 +1,307 @@ +"""Strict standard-library client for Rin Protocol v1.""" + +from __future__ import annotations + +import ipaddress +import json +import time +from typing import Any, Callable, Dict, Optional, Sequence, Tuple +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlsplit, urlunsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + + +PROTOCOL_VERSION = "rin.protocol/v1" +DEFAULT_BASE_URL = "http://127.0.0.1:7374" +DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024 +_TERMINAL_JOB_STATES = frozenset(("succeeded", "failed", "stale", "canceled")) + + +class RinError(RuntimeError): + def __init__(self, code: str, message: str) -> None: + self.code = _safe_text(code, 96) or "rin_error" + self.safe_message = _safe_text(message, 500) or "Rin request failed" + super().__init__(self.safe_message) + + +class RinConfigurationError(RinError): + pass + + +class RinTransportError(RinError): + pass + + +class RinProtocolError(RinError): + pass + + +class RinAPIError(RinError): + def __init__(self, code: str, message: str, *, status: int = 0, field: str = "") -> None: + self.status = int(status or 0) + self.field = _safe_text(field, 160) + super().__init__(code, message) + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +class RinClient: + def __init__( + self, + base_url: str = DEFAULT_BASE_URL, + *, + token: str = "", + timeout: float = 5.0, + max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES, + clock: Callable[[], float] = time.monotonic, + sleeper: Callable[[float], None] = time.sleep, + ) -> None: + self.token = _validate_token(token) + self.base_url = _normalize_base_url(base_url, self.token) + self.timeout = float(timeout) + if not 0.05 <= self.timeout <= 120.0: + raise RinConfigurationError("invalid_timeout", "timeout must be between 0.05 and 120 seconds") + self.max_response_bytes = int(max_response_bytes) + if not 1024 <= self.max_response_bytes <= 32 * 1024 * 1024: + raise RinConfigurationError("invalid_response_limit", "response limit must be between 1 KiB and 32 MiB") + self._opener = build_opener(_NoRedirect()) + self._clock = clock + self._sleeper = sleeper + + def health(self) -> Dict[str, Any]: + return self._request("GET", "/health") + + def create_session(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/create", payload) + + def observe(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/observe", payload) + + def propose(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/agent/propose", payload) + + def submit_proposal_job(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("POST", "/v1/jobs/propose", payload, (202,)) + + def get_proposal_job(self, job_id: str) -> Dict[str, Any]: + return self._request("GET", "/v1/jobs/" + _path_id(job_id)) + + def cancel_proposal_job(self, job_id: str) -> Dict[str, Any]: + return self._request("DELETE", "/v1/jobs/" + _path_id(job_id)) + + def submit_generation_job(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("POST", "/v1/generation/jobs", payload, (202,)) + + def get_generation_job(self, job_id: str) -> Dict[str, Any]: + return self._request("GET", "/v1/generation/jobs/" + _path_id(job_id)) + + def cancel_generation_job(self, job_id: str) -> Dict[str, Any]: + return self._request("DELETE", "/v1/generation/jobs/" + _path_id(job_id)) + + def commit(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/action/commit", payload) + + def commit_batch(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/action/commit-batch", payload) + + def set_actor_activity(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/activity", payload) + + def arbitrate(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/world/arbitrate", payload) + + def state(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/get", payload) + + def snapshot(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/snapshot", payload) + + def restore(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/restore", payload) + + def timeline(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/timeline", payload) + + def replay(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/session/replay", payload) + + def due_agents(self, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._post("/v1/scheduler/due", payload) + + def wait_for_proposal(self, job_id: str, *, deadline: float = 25.0, interval: float = 0.1) -> Dict[str, Any]: + return self._wait_job(job_id, self.get_proposal_job, self.cancel_proposal_job, deadline, interval) + + def wait_for_generation(self, job_id: str, *, deadline: float = 45.0, interval: float = 0.1) -> Dict[str, Any]: + return self._wait_job(job_id, self.get_generation_job, self.cancel_generation_job, deadline, interval) + + def _wait_job( + self, + job_id: str, + getter: Callable[[str], Dict[str, Any]], + canceler: Callable[[str], Dict[str, Any]], + deadline: float, + interval: float, + ) -> Dict[str, Any]: + if not 0.05 <= deadline <= 300.0 or not 0.01 <= interval <= 5.0: + raise RinConfigurationError("invalid_polling", "job deadline or interval is out of range") + expires = self._clock() + deadline + while True: + job = getter(job_id) + status = str(job.get("status", "")) + if status == "succeeded": + return job + if status in _TERMINAL_JOB_STATES: + detail = job.get("error") if isinstance(job.get("error"), dict) else {} + raise RinAPIError( + _safe_text(detail.get("code"), 96) or "job_" + status, + _safe_text(detail.get("message"), 500) or "Rin job ended as " + status, + ) + if status not in ("queued", "running"): + raise RinProtocolError("invalid_job", "Rin returned an unknown job status") + remaining = expires - self._clock() + if remaining <= 0: + try: + canceler(job_id) + except RinError: + pass + raise RinAPIError("job_timeout", "Rin job exceeded its deadline") + self._sleeper(min(interval, remaining)) + + def _post(self, path: str, payload: Dict[str, Any]) -> Dict[str, Any]: + return self._request("POST", path, payload) + + def _request( + self, + method: str, + path: str, + payload: Optional[Dict[str, Any]] = None, + expected_statuses: Sequence[int] = (200,), + ) -> Dict[str, Any]: + if not path.startswith("/") or "//" in path or ".." in path: + raise RinConfigurationError("invalid_path", "Rin request path is invalid") + body = None + headers = {"Accept": "application/json", "User-Agent": "rin-python/0.5"} + if payload is not None: + if not isinstance(payload, dict): + raise RinProtocolError("invalid_request", "Rin payload must be an object") + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + headers["Content-Type"] = "application/json" + if self.token: + headers["Authorization"] = "Bearer " + self.token + request = Request(self.base_url + path, data=body, headers=headers, method=method) + try: + with self._opener.open(request, timeout=self.timeout) as response: + return self._decode(response, int(response.getcode()), tuple(expected_statuses)) + except HTTPError as exc: + return self._decode_error(exc, int(exc.code)) + except (URLError, TimeoutError, OSError) as exc: + raise RinTransportError("transport_failed", "Rin is unavailable") from exc + + def _decode(self, response: Any, status: int, expected: Tuple[int, ...]) -> Dict[str, Any]: + declared = response.headers.get("Content-Length", "") + if declared: + try: + if int(declared) > self.max_response_bytes: + raise RinProtocolError("response_too_large", "Rin response exceeds the configured limit") + except ValueError as exc: + raise RinProtocolError("invalid_response", "Rin returned an invalid Content-Length") from exc + raw = response.read(self.max_response_bytes + 1) + if len(raw) > self.max_response_bytes: + raise RinProtocolError("response_too_large", "Rin response exceeds the configured limit") + envelope = _parse_envelope(raw) + if status not in expected or envelope.get("ok") is not True: + raise _api_error(envelope, status) + data = envelope.get("data") + if not isinstance(data, dict): + raise RinProtocolError("invalid_response", "Rin response data must be an object") + return data + + def _decode_error(self, response: HTTPError, status: int) -> Dict[str, Any]: + raw = response.read(self.max_response_bytes + 1) + if len(raw) > self.max_response_bytes: + raise RinProtocolError("response_too_large", "Rin error response exceeds the configured limit") + try: + envelope = _parse_envelope(raw) + except RinProtocolError: + envelope = {} + raise _api_error(envelope, status) + + +def _parse_envelope(raw: bytes) -> Dict[str, Any]: + try: + value = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise RinProtocolError("invalid_response", "Rin returned invalid JSON") from exc + if not isinstance(value, dict): + raise RinProtocolError("invalid_response", "Rin response must be an object") + return value + + +def _api_error(envelope: Dict[str, Any], status: int) -> RinAPIError: + detail = envelope.get("error") if isinstance(envelope.get("error"), dict) else {} + return RinAPIError( + _safe_text(detail.get("code"), 96) or "http_error", + _safe_text(detail.get("message"), 500) or "Rin request failed", + status=status, + field=_safe_text(detail.get("field"), 160), + ) + + +def _normalize_base_url(value: str, token: str) -> str: + parsed = urlsplit(str(value or DEFAULT_BASE_URL).strip().rstrip("/")) + try: + port = parsed.port + except ValueError as exc: + raise RinConfigurationError("invalid_base_url", "Rin base URL has an invalid port") from exc + if ( + parsed.scheme not in ("http", "https") + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.query + or parsed.fragment + or parsed.path not in ("", "/") + ): + raise RinConfigurationError("invalid_base_url", "Rin base URL must be an origin") + loopback = _is_loopback(parsed.hostname) + if parsed.scheme == "http" and not loopback: + raise RinConfigurationError("insecure_base_url", "remote Rin endpoints must use HTTPS") + if not loopback and not token: + raise RinConfigurationError("missing_token", "remote Rin endpoints require a token") + if port is not None and not 1 <= port <= 65535: + raise RinConfigurationError("invalid_base_url", "Rin base URL has an invalid port") + return urlunsplit((parsed.scheme, parsed.netloc, "", "", "")).rstrip("/") + + +def _is_loopback(host: str) -> bool: + if host.casefold() == "localhost": + return True + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +def _validate_token(value: str) -> str: + token = str(value or "") + if token != token.strip() or any(character in token for character in ("\x00", "\r", "\n")) or len(token) > 4096: + raise RinConfigurationError("invalid_token", "Rin token must be a bounded single-line value") + return token + + +def _path_id(value: str) -> str: + text = str(value or "") + if ( + not text + or len(text) > 96 + or not all(character.isascii() and (character.isalnum() or character in "._-") for character in text) + ): + raise RinConfigurationError("invalid_identifier", "Rin path identifier is invalid") + return quote(text, safe="._-") + + +def _safe_text(value: Any, maximum: int) -> str: + return " ".join(str(value or "").replace("\x00", "").split())[:maximum] diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py new file mode 100644 index 0000000..96b3237 --- /dev/null +++ b/sdk/python/tests/test_client.py @@ -0,0 +1,114 @@ +import io +import json +import sys +import unittest +from pathlib import Path + + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from rin_sdk import RinAPIError, RinClient, RinConfigurationError # noqa: E402 + + +class _Response: + def __init__(self, status, payload): + self.status = status + self.payload = json.dumps(payload).encode("utf-8") + self.headers = {"Content-Length": str(len(self.payload))} + self.stream = io.BytesIO(self.payload) + + def getcode(self): + return self.status + + def read(self, maximum=-1): + return self.stream.read(maximum) + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + +class _Opener: + def __init__(self): + self.path = "" + self.method = "" + self.authorization = "" + + def open(self, request, timeout): + del timeout + self.path = request.full_url.split("7374", 1)[-1] + self.method = request.get_method() + self.authorization = request.get_header("Authorization", "") + status = 202 if self.path in ("/v1/jobs/propose", "/v1/generation/jobs") else 200 + return _Response(status, {"ok": True, "data": {"status": "ok", "job_id": "job.fixture"}}) + + +class RinClientTests(unittest.TestCase): + def test_routes_and_token(self): + client = RinClient(token="fixture") + client._opener = _Opener() + cases = ( + (client.health, (), "GET", "/health"), + (client.create_session, ({},), "POST", "/v1/session/create"), + (client.observe, ({},), "POST", "/v1/session/observe"), + (client.propose, ({},), "POST", "/v1/agent/propose"), + (client.submit_proposal_job, ({},), "POST", "/v1/jobs/propose"), + (client.get_proposal_job, ("job.fixture",), "GET", "/v1/jobs/job.fixture"), + (client.cancel_proposal_job, ("job.fixture",), "DELETE", "/v1/jobs/job.fixture"), + (client.submit_generation_job, ({},), "POST", "/v1/generation/jobs"), + (client.get_generation_job, ("job.fixture",), "GET", "/v1/generation/jobs/job.fixture"), + (client.cancel_generation_job, ("job.fixture",), "DELETE", "/v1/generation/jobs/job.fixture"), + (client.commit, ({},), "POST", "/v1/action/commit"), + (client.commit_batch, ({},), "POST", "/v1/action/commit-batch"), + (client.set_actor_activity, ({},), "POST", "/v1/session/activity"), + (client.arbitrate, ({},), "POST", "/v1/world/arbitrate"), + (client.state, ({},), "POST", "/v1/session/get"), + (client.snapshot, ({},), "POST", "/v1/session/snapshot"), + (client.restore, ({},), "POST", "/v1/session/restore"), + (client.timeline, ({},), "POST", "/v1/session/timeline"), + (client.replay, ({},), "POST", "/v1/session/replay"), + (client.due_agents, ({},), "POST", "/v1/scheduler/due"), + ) + for method, args, http_method, path in cases: + with self.subTest(path=path): + method(*args) + self.assertEqual(client._opener.path, path) + self.assertEqual(client._opener.method, http_method) + self.assertEqual(client._opener.authorization, "Bearer fixture") + + def test_job_id_is_ascii_and_path_safe(self): + client = RinClient() + client._opener = _Opener() + for invalid in ("", "../job", "job/other", "作业"): + with self.subTest(job_id=invalid), self.assertRaises(RinConfigurationError): + client.get_proposal_job(invalid) + + def test_remote_endpoint_requires_tls_and_token(self): + with self.assertRaises(RinConfigurationError): + RinClient("http://models.example", token="fixture") + with self.assertRaises(RinConfigurationError): + RinClient("https://models.example") + self.assertEqual(RinClient("https://models.example", token="fixture").base_url, "https://models.example") + + def test_api_error_is_bounded(self): + client = RinClient() + + class ErrorOpener: + def open(self, request, timeout): + del request, timeout + from urllib.error import HTTPError + + body = json.dumps({"ok": False, "error": {"code": "invalid_request", "message": "safe"}}).encode() + raise HTTPError("http://127.0.0.1", 400, "Bad", {}, io.BytesIO(body)) + + client._opener = ErrorOpener() + with self.assertRaises(RinAPIError) as caught: + client.health() + self.assertEqual(caught.exception.code, "invalid_request") + self.assertEqual(caught.exception.status, 400) + + +if __name__ == "__main__": + unittest.main() From 649eca2ebf4656ae63f06458f709bf4db764a0d4 Mon Sep 17 00:00:00 2001 From: sunrioa Date: Thu, 23 Jul 2026 10:26:24 +0800 Subject: [PATCH 2/4] feat: complete cross-language SDK suite --- .github/workflows/ci.yml | 39 ++ .gitignore | 3 + Makefile | 27 +- compat/sdk_kits_test.go | 239 +++++++++ sdk/csharp/README.md | 26 + sdk/csharp/Rin.Client.Tests/Program.cs | 147 ++++++ .../Rin.Client.Tests/Rin.Client.Tests.csproj | 12 + sdk/csharp/Rin.Client/AssemblyInfo.cs | 3 + sdk/csharp/Rin.Client/Rin.Client.csproj | 11 + sdk/csharp/Rin.Client/RinClient.cs | 388 ++++++++++++++ sdk/csharp/Rin.Client/RinClientOptions.cs | 12 + sdk/csharp/Rin.Client/RinException.cs | 52 ++ sdk/java/README.md | 28 + .../java/io/github/sunrioa/rin/JsonCodec.java | 10 + .../github/sunrioa/rin/RinApiException.java | 22 + .../java/io/github/sunrioa/rin/RinClient.java | 499 ++++++++++++++++++ .../rin/RinConfigurationException.java | 13 + .../io/github/sunrioa/rin/RinException.java | 29 + .../sunrioa/rin/RinProtocolException.java | 13 + .../sunrioa/rin/RinTransportException.java | 13 + .../io/github/sunrioa/rin/RinClientTest.java | 134 +++++ sdk/javascript/package.json | 1 - sdk/javascript/src/index.js | 106 +++- sdk/javascript/test/client.test.js | 44 +- sdk/lua/README.md | 30 ++ sdk/lua/rin.lua | 339 ++++++++++++ sdk/lua/test_client.lua | 79 +++ sdk/python/pyproject.toml | 1 - sdk/python/src/rin_sdk/client.py | 17 +- sdk/python/tests/test_client.py | 42 +- 30 files changed, 2341 insertions(+), 38 deletions(-) create mode 100644 compat/sdk_kits_test.go create mode 100644 sdk/csharp/README.md create mode 100644 sdk/csharp/Rin.Client.Tests/Program.cs create mode 100644 sdk/csharp/Rin.Client.Tests/Rin.Client.Tests.csproj create mode 100644 sdk/csharp/Rin.Client/AssemblyInfo.cs create mode 100644 sdk/csharp/Rin.Client/Rin.Client.csproj create mode 100644 sdk/csharp/Rin.Client/RinClient.cs create mode 100644 sdk/csharp/Rin.Client/RinClientOptions.cs create mode 100644 sdk/csharp/Rin.Client/RinException.cs create mode 100644 sdk/java/README.md create mode 100644 sdk/java/src/main/java/io/github/sunrioa/rin/JsonCodec.java create mode 100644 sdk/java/src/main/java/io/github/sunrioa/rin/RinApiException.java create mode 100644 sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java create mode 100644 sdk/java/src/main/java/io/github/sunrioa/rin/RinConfigurationException.java create mode 100644 sdk/java/src/main/java/io/github/sunrioa/rin/RinException.java create mode 100644 sdk/java/src/main/java/io/github/sunrioa/rin/RinProtocolException.java create mode 100644 sdk/java/src/main/java/io/github/sunrioa/rin/RinTransportException.java create mode 100644 sdk/java/test/io/github/sunrioa/rin/RinClientTest.java create mode 100644 sdk/lua/README.md create mode 100644 sdk/lua/rin.lua create mode 100644 sdk/lua/test_client.lua 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/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/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/sdk/csharp/README.md b/sdk/csharp/README.md new file mode 100644 index 0000000..b219a4a --- /dev/null +++ b/sdk/csharp/README.md @@ -0,0 +1,26 @@ +# Rin C# SDK + +`Rin.Client` targets .NET 6+ and uses only `HttpClient` and +`System.Text.Json`. Keep one client for the lifetime of the plugin or game. + +```csharp +using Rin.Client; + +using var rin = new RinClient(new RinClientOptions +{ + BaseUrl = "http://127.0.0.1:7374", + Token = Environment.GetEnvironmentVariable("RIN_TOKEN") ?? "", +}); + +var health = await rin.HealthAsync(); +Console.WriteLine(health.GetProperty("status").GetString()); +``` + +Build the source project with: + +```bash +dotnet run --project sdk/csharp/Rin.Client.Tests/Rin.Client.Tests.csproj +``` + +Unity and BepInEx callers must await off the render loop, then marshal the +validated result back to Unity's main thread before touching game objects. diff --git a/sdk/csharp/Rin.Client.Tests/Program.cs b/sdk/csharp/Rin.Client.Tests/Program.cs new file mode 100644 index 0000000..6e83c64 --- /dev/null +++ b/sdk/csharp/Rin.Client.Tests/Program.cs @@ -0,0 +1,147 @@ +using System.Net; +using System.Text; +using Rin.Client; + +var handler = new RecordingHandler(); +using var client = new RinClient(new RinClientOptions { Token = "fixture" }, handler); +var payload = new Dictionary(); +var cases = new (Func Call, HttpMethod Method, string Path)[] +{ + (async () => await client.HealthAsync(), HttpMethod.Get, "/health"), + (async () => await client.CreateSessionAsync(payload), HttpMethod.Post, "/v1/session/create"), + (async () => await client.ObserveAsync(payload), HttpMethod.Post, "/v1/session/observe"), + (async () => await client.ProposeAsync(payload), HttpMethod.Post, "/v1/agent/propose"), + (async () => await client.SubmitProposalJobAsync(payload), HttpMethod.Post, "/v1/jobs/propose"), + (async () => await client.GetProposalJobAsync("job.fixture"), HttpMethod.Get, "/v1/jobs/job.fixture"), + (async () => await client.CancelProposalJobAsync("job.fixture"), HttpMethod.Delete, "/v1/jobs/job.fixture"), + (async () => await client.SubmitGenerationJobAsync(payload), HttpMethod.Post, "/v1/generation/jobs"), + (async () => await client.GetGenerationJobAsync("job.fixture"), HttpMethod.Get, "/v1/generation/jobs/job.fixture"), + (async () => await client.CancelGenerationJobAsync("job.fixture"), HttpMethod.Delete, "/v1/generation/jobs/job.fixture"), + (async () => await client.CommitAsync(payload), HttpMethod.Post, "/v1/action/commit"), + (async () => await client.CommitBatchAsync(payload), HttpMethod.Post, "/v1/action/commit-batch"), + (async () => await client.SetActorActivityAsync(payload), HttpMethod.Post, "/v1/session/activity"), + (async () => await client.ArbitrateAsync(payload), HttpMethod.Post, "/v1/world/arbitrate"), + (async () => await client.StateAsync(payload), HttpMethod.Post, "/v1/session/get"), + (async () => await client.SnapshotAsync(payload), HttpMethod.Post, "/v1/session/snapshot"), + (async () => await client.RestoreAsync(payload), HttpMethod.Post, "/v1/session/restore"), + (async () => await client.TimelineAsync(payload), HttpMethod.Post, "/v1/session/timeline"), + (async () => await client.ReplayAsync(payload), HttpMethod.Post, "/v1/session/replay"), + (async () => await client.DueAgentsAsync(payload), HttpMethod.Post, "/v1/scheduler/due"), +}; + +foreach (var test in cases) +{ + await test.Call(); + Require(handler.Method == test.Method, "wrong method for " + test.Path); + Require(handler.Path == test.Path, "wrong path for " + test.Path); + Require(handler.Authorization == "Bearer fixture", "missing bearer token"); +} + +RequireThrows(() => new RinClient(new RinClientOptions +{ + BaseUrl = "http://models.example", + Token = "fixture", +}), "remote HTTP origin was accepted"); +RequireThrows(() => new RinClient(new RinClientOptions +{ + BaseUrl = "https://models.example", +}), "remote origin without token was accepted"); +RequireThrows( + () => client.GetProposalJobAsync("\u4f5c\u4e1a"), + "Unicode path ID was accepted"); + +var oversized = new RecordingHandler { DeclaredLength = 2048 }; +using var limited = new RinClient(new RinClientOptions { MaxResponseBytes = 1024 }, oversized); +try +{ + await limited.HealthAsync(); + throw new InvalidOperationException("oversized response was accepted"); +} +catch (RinProtocolException exception) +{ + Require(exception.Code == "response_too_large", "wrong response limit error"); +} + +var slow = new RecordingHandler { ContentFactory = () => new StreamContent(new SlowStream()) }; +using var impatient = new RinClient(new RinClientOptions { Timeout = TimeSpan.FromMilliseconds(50) }, slow); +try +{ + await impatient.HealthAsync(); + throw new InvalidOperationException("slow response exceeded the request deadline"); +} +catch (RinTransportException exception) +{ + Require(exception.Code == "transport_timeout", "wrong timeout error"); +} + +Console.WriteLine("Rin C# SDK tests passed"); + +static void Require(bool condition, string message) +{ + if (!condition) throw new InvalidOperationException(message); +} + +static void RequireThrows(Action action, string message) where TException : Exception +{ + try + { + action(); + throw new InvalidOperationException(message); + } + catch (TException) + { + } +} + +sealed class RecordingHandler : HttpMessageHandler +{ + public HttpMethod? Method { get; private set; } + public string Path { get; private set; } = string.Empty; + public string Authorization { get; private set; } = string.Empty; + public long? DeclaredLength { get; init; } + public Func? ContentFactory { get; init; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + Method = request.Method; + Path = request.RequestUri?.AbsolutePath ?? string.Empty; + Authorization = request.Headers.TryGetValues("Authorization", out var values) + ? values.Single() + : string.Empty; + var status = Path is "/v1/jobs/propose" or "/v1/generation/jobs" + ? HttpStatusCode.Accepted + : HttpStatusCode.OK; + var content = ContentFactory?.Invoke() ?? + new ByteArrayContent(Encoding.UTF8.GetBytes("{\"ok\":true,\"data\":{\"status\":\"ok\"}}")); + if (DeclaredLength.HasValue) content.Headers.ContentLength = DeclaredLength.Value; + return Task.FromResult(new HttpResponseMessage(status) { Content = content }); + } +} + +sealed class SlowStream : Stream +{ + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() { } + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } +} diff --git a/sdk/csharp/Rin.Client.Tests/Rin.Client.Tests.csproj b/sdk/csharp/Rin.Client.Tests/Rin.Client.Tests.csproj new file mode 100644 index 0000000..67e3f8b --- /dev/null +++ b/sdk/csharp/Rin.Client.Tests/Rin.Client.Tests.csproj @@ -0,0 +1,12 @@ + + + Exe + net6.0 + enable + enable + true + + + + + diff --git a/sdk/csharp/Rin.Client/AssemblyInfo.cs b/sdk/csharp/Rin.Client/AssemblyInfo.cs new file mode 100644 index 0000000..42a2b0d --- /dev/null +++ b/sdk/csharp/Rin.Client/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Rin.Client.Tests")] diff --git a/sdk/csharp/Rin.Client/Rin.Client.csproj b/sdk/csharp/Rin.Client/Rin.Client.csproj new file mode 100644 index 0000000..726c0d4 --- /dev/null +++ b/sdk/csharp/Rin.Client/Rin.Client.csproj @@ -0,0 +1,11 @@ + + + net6.0 + enable + enable + true + Rin.Game.Client + 0.5.0 + Dependency-free .NET client for the Rin game agent runtime. + + diff --git a/sdk/csharp/Rin.Client/RinClient.cs b/sdk/csharp/Rin.Client/RinClient.cs new file mode 100644 index 0000000..e7474ae --- /dev/null +++ b/sdk/csharp/Rin.Client/RinClient.cs @@ -0,0 +1,388 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Rin.Client; + +public sealed class RinClient : IDisposable +{ + public const string ProtocolVersion = "rin.protocol/v1"; + public const string DefaultBaseUrl = "http://127.0.0.1:7374"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + private readonly HttpClient httpClient; + private readonly string baseUrl; + private readonly string token; + private readonly TimeSpan timeout; + private readonly int maxResponseBytes; + + public RinClient(RinClientOptions? options = null) + : this(options, CreateHandler()) + { + } + + internal RinClient(RinClientOptions? options, HttpMessageHandler handler) + { + options ??= new RinClientOptions(); + token = ValidateToken(options.Token); + baseUrl = NormalizeBaseUrl(options.BaseUrl, token); + timeout = options.Timeout; + if (timeout < TimeSpan.FromMilliseconds(50) || timeout > TimeSpan.FromSeconds(120)) + { + throw new RinConfigurationException("invalid_timeout", "Timeout must be between 50 ms and 120 seconds"); + } + maxResponseBytes = options.MaxResponseBytes; + if (maxResponseBytes < 1024 || maxResponseBytes > 32 * 1024 * 1024) + { + throw new RinConfigurationException("invalid_response_limit", "Response limit must be between 1 KiB and 32 MiB"); + } + + httpClient = new HttpClient(handler ?? throw new ArgumentNullException(nameof(handler)), disposeHandler: true) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("rin-csharp/0.5"); + } + + public Task HealthAsync(CancellationToken cancellationToken = default) => + RequestAsync(HttpMethod.Get, "/health", null, 200, cancellationToken); + + public Task CreateSessionAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/create", payload, 200, cancellationToken); + + public Task ObserveAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/observe", payload, 200, cancellationToken); + + public Task ProposeAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/agent/propose", payload, 200, cancellationToken); + + public Task SubmitProposalJobAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/jobs/propose", payload, 202, cancellationToken); + + public Task GetProposalJobAsync(string jobId, CancellationToken cancellationToken = default) => + RequestAsync(HttpMethod.Get, "/v1/jobs/" + PathId(jobId), null, 200, cancellationToken); + + public Task CancelProposalJobAsync(string jobId, CancellationToken cancellationToken = default) => + RequestAsync(HttpMethod.Delete, "/v1/jobs/" + PathId(jobId), null, 200, cancellationToken); + + public Task SubmitGenerationJobAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/generation/jobs", payload, 202, cancellationToken); + + public Task GetGenerationJobAsync(string jobId, CancellationToken cancellationToken = default) => + RequestAsync(HttpMethod.Get, "/v1/generation/jobs/" + PathId(jobId), null, 200, cancellationToken); + + public Task CancelGenerationJobAsync(string jobId, CancellationToken cancellationToken = default) => + RequestAsync(HttpMethod.Delete, "/v1/generation/jobs/" + PathId(jobId), null, 200, cancellationToken); + + public Task CommitAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/action/commit", payload, 200, cancellationToken); + + public Task CommitBatchAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/action/commit-batch", payload, 200, cancellationToken); + + public Task SetActorActivityAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/activity", payload, 200, cancellationToken); + + public Task ArbitrateAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/world/arbitrate", payload, 200, cancellationToken); + + public Task StateAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/get", payload, 200, cancellationToken); + + public Task SnapshotAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/snapshot", payload, 200, cancellationToken); + + public Task RestoreAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/restore", payload, 200, cancellationToken); + + public Task TimelineAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/timeline", payload, 200, cancellationToken); + + public Task ReplayAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/session/replay", payload, 200, cancellationToken); + + public Task DueAgentsAsync(object payload, CancellationToken cancellationToken = default) => + PostAsync("/v1/scheduler/due", payload, 200, cancellationToken); + + public Task WaitForProposalAsync( + string jobId, + TimeSpan? deadline = null, + TimeSpan? interval = null, + CancellationToken cancellationToken = default) => + WaitForJobAsync( + jobId, + GetProposalJobAsync, + CancelProposalJobAsync, + deadline ?? TimeSpan.FromSeconds(25), + interval ?? TimeSpan.FromMilliseconds(100), + cancellationToken); + + public Task WaitForGenerationAsync( + string jobId, + TimeSpan? deadline = null, + TimeSpan? interval = null, + CancellationToken cancellationToken = default) => + WaitForJobAsync( + jobId, + GetGenerationJobAsync, + CancelGenerationJobAsync, + deadline ?? TimeSpan.FromSeconds(45), + interval ?? TimeSpan.FromMilliseconds(100), + cancellationToken); + + public void Dispose() => httpClient.Dispose(); + + private static HttpMessageHandler CreateHandler() => new HttpClientHandler + { + AllowAutoRedirect = false, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, + }; + + private static async Task WaitForJobAsync( + string jobId, + Func> getter, + Func> canceler, + TimeSpan deadline, + TimeSpan interval, + CancellationToken cancellationToken) + { + if (deadline < TimeSpan.FromMilliseconds(50) || deadline > TimeSpan.FromMinutes(5) || + interval < TimeSpan.FromMilliseconds(10) || interval > TimeSpan.FromSeconds(5)) + { + throw new RinConfigurationException("invalid_polling", "Job deadline or interval is out of range"); + } + var elapsed = Stopwatch.StartNew(); + while (true) + { + var job = await getter(jobId, cancellationToken).ConfigureAwait(false); + var status = TextProperty(job, "status", 32); + if (status == "succeeded") return job; + if (status is "failed" or "stale" or "canceled") + { + var detail = job.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object + ? error + : default; + throw new RinApiException( + TextProperty(detail, "code", 96, "job_" + status), + TextProperty(detail, "message", 500, "Rin job ended as " + status)); + } + if (status is not ("queued" or "running")) + { + throw new RinProtocolException("invalid_job", "Rin returned an unknown job status"); + } + var remaining = deadline - elapsed.Elapsed; + if (remaining <= TimeSpan.Zero) + { + try { await canceler(jobId, CancellationToken.None).ConfigureAwait(false); } + catch (RinException) { } + throw new RinApiException("job_timeout", "Rin job exceeded its deadline"); + } + await Task.Delay(interval < remaining ? interval : remaining, cancellationToken).ConfigureAwait(false); + } + } + + private Task PostAsync(string path, object payload, int expectedStatus, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(payload); + return RequestAsync(HttpMethod.Post, path, payload, expectedStatus, cancellationToken); + } + + private async Task RequestAsync( + HttpMethod method, + string path, + object? payload, + int expectedStatus, + CancellationToken cancellationToken) + { + if (!path.StartsWith("/", StringComparison.Ordinal) || path.Contains("//", StringComparison.Ordinal) || path.Contains("..", StringComparison.Ordinal)) + { + throw new RinConfigurationException("invalid_path", "Rin request path is invalid"); + } + + using var request = new HttpRequestMessage(method, baseUrl + path); + if (payload is not null) + { + byte[] encoded; + try + { + encoded = JsonSerializer.SerializeToUtf8Bytes(payload, payload.GetType(), JsonOptions); + } + catch (Exception exception) when (exception is JsonException or NotSupportedException) + { + throw new RinProtocolException("invalid_request", "Rin payload is not JSON serializable", exception); + } + request.Content = new ByteArrayContent(encoded); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" }; + } + if (token.Length > 0) + { + request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + token); + } + + using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + deadline.CancelAfter(timeout); + HttpResponseMessage response; + try + { + response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new RinTransportException("transport_timeout", "Rin request timed out", exception); + } + catch (HttpRequestException exception) + { + throw new RinTransportException("transport_failed", "Rin is unavailable", exception); + } + + using (response) + { + if (response.Headers.Location is not null && (int)response.StatusCode is >= 300 and < 400) + { + throw new RinTransportException("redirect_rejected", "Rin endpoint attempted to redirect"); + } + if (response.Content.Headers.ContentLength is long declared && declared > maxResponseBytes) + { + throw new RinProtocolException("response_too_large", "Rin response exceeds the configured limit"); + } + + byte[] raw; + try + { + raw = await ReadBoundedAsync(response.Content, deadline.Token).ConfigureAwait(false); + } + catch (OperationCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + throw new RinTransportException("transport_timeout", "Rin request timed out", exception); + } + catch (Exception exception) when (exception is HttpRequestException or IOException) + { + throw new RinTransportException("transport_failed", "Rin response could not be read", exception); + } + JsonDocument document; + try + { + document = JsonDocument.Parse(raw); + } + catch (JsonException exception) + { + if ((int)response.StatusCode != expectedStatus) + { + throw new RinApiException("http_error", "Rin request failed", (int)response.StatusCode); + } + throw new RinProtocolException("invalid_response", "Rin returned invalid JSON", exception); + } + + using (document) + { + var root = document.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + throw new RinProtocolException("invalid_response", "Rin response must be an object"); + } + var ok = root.TryGetProperty("ok", out var okElement) && okElement.ValueKind == JsonValueKind.True; + if ((int)response.StatusCode != expectedStatus || !ok) + { + throw ApiError(root, (int)response.StatusCode); + } + if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object) + { + throw new RinProtocolException("invalid_response", "Rin response data must be an object"); + } + return data.Clone(); + } + } + } + + private async Task ReadBoundedAsync(HttpContent content, CancellationToken cancellationToken) + { + await using var stream = await content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var output = new MemoryStream(); + var buffer = new byte[8192]; + while (true) + { + var count = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false); + if (count == 0) break; + if (output.Length + count > maxResponseBytes) + { + throw new RinProtocolException("response_too_large", "Rin response exceeds the configured limit"); + } + output.Write(buffer, 0, count); + } + return output.ToArray(); + } + + private static RinApiException ApiError(JsonElement root, int status) + { + var detail = root.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object + ? error + : default; + return new RinApiException( + TextProperty(detail, "code", 96, "http_error"), + TextProperty(detail, "message", 500, "Rin request failed"), + status, + TextProperty(detail, "field", 160)); + } + + private static string TextProperty(JsonElement element, string name, int maximum, string fallback = "") + { + if (element.ValueKind == JsonValueKind.Object && element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String) + { + return RinException.SafeText(value.GetString(), maximum, fallback); + } + return fallback; + } + + private static string NormalizeBaseUrl(string? value, string validatedToken) + { + if (!Uri.TryCreate((value ?? DefaultBaseUrl).Trim().TrimEnd('/'), UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) || + uri.Host.Length == 0 || uri.UserInfo.Length > 0 || uri.Query.Length > 0 || uri.Fragment.Length > 0 || + (uri.AbsolutePath.Length > 0 && uri.AbsolutePath != "/")) + { + throw new RinConfigurationException("invalid_base_url", "Rin base URL must be an origin"); + } + var loopback = uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase) || + (IPAddress.TryParse(uri.Host, out var address) && IPAddress.IsLoopback(address)); + if (uri.Scheme == Uri.UriSchemeHttp && !loopback) + { + throw new RinConfigurationException("insecure_base_url", "Remote Rin endpoints must use HTTPS"); + } + if (!loopback && validatedToken.Length == 0) + { + throw new RinConfigurationException("missing_token", "Remote Rin endpoints require a token"); + } + return uri.GetLeftPart(UriPartial.Authority); + } + + private static string ValidateToken(string? value) + { + var candidate = value ?? string.Empty; + if (candidate.Length > 4096 || candidate != candidate.Trim() || candidate.IndexOfAny(new[] { '\0', '\r', '\n' }) >= 0) + { + throw new RinConfigurationException("invalid_token", "Rin token must be a bounded single-line value"); + } + return candidate; + } + + private static string PathId(string? value) + { + if (string.IsNullOrEmpty(value) || value.Length > 96 || value.Any(character => + !((character >= 'a' && character <= 'z') || + (character >= 'A' && character <= 'Z') || + (character >= '0' && character <= '9') || + character is '.' or '_' or '-'))) + { + throw new RinConfigurationException("invalid_identifier", "Rin path identifier is invalid"); + } + return Uri.EscapeDataString(value); + } +} diff --git a/sdk/csharp/Rin.Client/RinClientOptions.cs b/sdk/csharp/Rin.Client/RinClientOptions.cs new file mode 100644 index 0000000..3ed738a --- /dev/null +++ b/sdk/csharp/Rin.Client/RinClientOptions.cs @@ -0,0 +1,12 @@ +namespace Rin.Client; + +public sealed class RinClientOptions +{ + public string BaseUrl { get; init; } = RinClient.DefaultBaseUrl; + + public string Token { get; init; } = string.Empty; + + public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(5); + + public int MaxResponseBytes { get; init; } = 2 * 1024 * 1024; +} diff --git a/sdk/csharp/Rin.Client/RinException.cs b/sdk/csharp/Rin.Client/RinException.cs new file mode 100644 index 0000000..f05a506 --- /dev/null +++ b/sdk/csharp/Rin.Client/RinException.cs @@ -0,0 +1,52 @@ +namespace Rin.Client; + +public class RinException : Exception +{ + public RinException(string code, string message, Exception? innerException = null) + : base(SafeText(message, 500, "Rin request failed"), innerException) + { + Code = SafeText(code, 96, "rin_error"); + } + + public string Code { get; } + + internal static string SafeText(string? value, int maximum, string fallback = "") + { + var cleaned = string.Join(" ", (value ?? string.Empty) + .Replace("\0", string.Empty, StringComparison.Ordinal) + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + return cleaned.Length == 0 ? fallback : cleaned[..Math.Min(cleaned.Length, maximum)]; + } +} + +public sealed class RinConfigurationException : RinException +{ + public RinConfigurationException(string code, string message, Exception? innerException = null) + : base(code, message, innerException) { } +} + +public sealed class RinTransportException : RinException +{ + public RinTransportException(string code, string message, Exception? innerException = null) + : base(code, message, innerException) { } +} + +public sealed class RinProtocolException : RinException +{ + public RinProtocolException(string code, string message, Exception? innerException = null) + : base(code, message, innerException) { } +} + +public sealed class RinApiException : RinException +{ + public RinApiException(string code, string message, int status = 0, string? field = null) + : base(code, message) + { + Status = status; + Field = SafeText(field, 160); + } + + public int Status { get; } + + public string Field { get; } +} diff --git a/sdk/java/README.md b/sdk/java/README.md new file mode 100644 index 0000000..bd1f6df --- /dev/null +++ b/sdk/java/README.md @@ -0,0 +1,28 @@ +# Rin Java SDK + +Requires Java 17+. Transport uses the JDK `HttpClient`; JSON is injected so a +game can reuse its existing Gson, Jackson, or engine codec without creating a +second dependency graph. + +```java +JsonCodec codec = new GsonJsonCodec(gameGson); +RinClient rin = new RinClient( + "http://127.0.0.1:7374", + System.getenv().getOrDefault("RIN_TOKEN", ""), + Duration.ofSeconds(5), + RinClient.DEFAULT_MAX_RESPONSE_BYTES, + codec +); + +rin.health().thenAccept(data -> System.out.println(data.get("status"))); +``` + +`JsonCodec.decodeObject` must reject a non-object root. Calls return +`CompletableFuture`; schedule any Minecraft or other engine mutation back on +the owning game thread. + +Compile the SDK and its dependency-free smoke test with JDK 17: + +```bash +make test-sdk-java +``` diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/JsonCodec.java b/sdk/java/src/main/java/io/github/sunrioa/rin/JsonCodec.java new file mode 100644 index 0000000..3905749 --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/JsonCodec.java @@ -0,0 +1,10 @@ +package io.github.sunrioa.rin; + +import java.util.Map; + +/** JSON boundary supplied by the host engine (for example Gson or Jackson). */ +public interface JsonCodec { + String encode(Map value) throws Exception; + + Map decodeObject(String json) throws Exception; +} diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/RinApiException.java b/sdk/java/src/main/java/io/github/sunrioa/rin/RinApiException.java new file mode 100644 index 0000000..10c88f8 --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/RinApiException.java @@ -0,0 +1,22 @@ +package io.github.sunrioa.rin; + +public final class RinApiException extends RinException { + private static final long serialVersionUID = 1L; + + private final int status; + private final String field; + + public RinApiException(String code, String message, int status, String field) { + super(code, message); + this.status = status; + this.field = safeText(field, 160, ""); + } + + public int status() { + return status; + } + + public String field() { + return field; + } +} diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java b/sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java new file mode 100644 index 0000000..3c0f571 --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/RinClient.java @@ -0,0 +1,499 @@ +package io.github.sunrioa.rin; + +import java.io.ByteArrayOutputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.net.http.HttpTimeoutException; +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Flow; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +public final class RinClient { + public static final String PROTOCOL_VERSION = "rin.protocol/v1"; + public static final String DEFAULT_BASE_URL = "http://127.0.0.1:7374"; + public static final int DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; + + private final String baseUrl; + private final String token; + private final Duration timeout; + private final int maxResponseBytes; + private final JsonCodec codec; + private final HttpClient http; + + public RinClient(JsonCodec codec) { + this(DEFAULT_BASE_URL, "", Duration.ofSeconds(5), DEFAULT_MAX_RESPONSE_BYTES, codec); + } + + public RinClient(String baseUrl, String token, Duration timeout, int maxResponseBytes, JsonCodec codec) { + this.token = validateToken(token); + this.baseUrl = normalizeBaseUrl(baseUrl, this.token); + this.timeout = Objects.requireNonNull(timeout, "timeout"); + if (timeout.compareTo(Duration.ofMillis(50)) < 0 || timeout.compareTo(Duration.ofSeconds(120)) > 0) { + throw new RinConfigurationException("invalid_timeout", "Timeout must be between 50 ms and 120 seconds"); + } + if (maxResponseBytes < 1024 || maxResponseBytes > 32 * 1024 * 1024) { + throw new RinConfigurationException("invalid_response_limit", "Response limit must be between 1 KiB and 32 MiB"); + } + this.maxResponseBytes = maxResponseBytes; + this.codec = Objects.requireNonNull(codec, "codec"); + this.http = HttpClient.newBuilder() + .connectTimeout(timeout) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + } + + public CompletableFuture> health() { + return request("GET", "/health", null, Set.of(200)); + } + + public CompletableFuture> createSession(Map payload) { + return post("/v1/session/create", payload, 200); + } + + public CompletableFuture> observe(Map payload) { + return post("/v1/session/observe", payload, 200); + } + + public CompletableFuture> propose(Map payload) { + return post("/v1/agent/propose", payload, 200); + } + + public CompletableFuture> submitProposalJob(Map payload) { + return post("/v1/jobs/propose", payload, 202); + } + + public CompletableFuture> getProposalJob(String jobId) { + return request("GET", "/v1/jobs/" + pathId(jobId), null, Set.of(200)); + } + + public CompletableFuture> cancelProposalJob(String jobId) { + return request("DELETE", "/v1/jobs/" + pathId(jobId), null, Set.of(200)); + } + + public CompletableFuture> submitGenerationJob(Map payload) { + return post("/v1/generation/jobs", payload, 202); + } + + public CompletableFuture> getGenerationJob(String jobId) { + return request("GET", "/v1/generation/jobs/" + pathId(jobId), null, Set.of(200)); + } + + public CompletableFuture> cancelGenerationJob(String jobId) { + return request("DELETE", "/v1/generation/jobs/" + pathId(jobId), null, Set.of(200)); + } + + public CompletableFuture> commit(Map payload) { + return post("/v1/action/commit", payload, 200); + } + + public CompletableFuture> commitBatch(Map payload) { + return post("/v1/action/commit-batch", payload, 200); + } + + public CompletableFuture> setActorActivity(Map payload) { + return post("/v1/session/activity", payload, 200); + } + + public CompletableFuture> arbitrate(Map payload) { + return post("/v1/world/arbitrate", payload, 200); + } + + public CompletableFuture> state(Map payload) { + return post("/v1/session/get", payload, 200); + } + + public CompletableFuture> snapshot(Map payload) { + return post("/v1/session/snapshot", payload, 200); + } + + public CompletableFuture> restore(Map payload) { + return post("/v1/session/restore", payload, 200); + } + + public CompletableFuture> timeline(Map payload) { + return post("/v1/session/timeline", payload, 200); + } + + public CompletableFuture> replay(Map payload) { + return post("/v1/session/replay", payload, 200); + } + + public CompletableFuture> dueAgents(Map payload) { + return post("/v1/scheduler/due", payload, 200); + } + + public CompletableFuture> waitForProposal(String jobId) { + return waitForJob(jobId, this::getProposalJob, this::cancelProposalJob, Duration.ofSeconds(25), Duration.ofMillis(100)); + } + + public CompletableFuture> waitForProposal(String jobId, Duration deadline, Duration interval) { + return waitForJob(jobId, this::getProposalJob, this::cancelProposalJob, deadline, interval); + } + + public CompletableFuture> waitForGeneration(String jobId) { + return waitForJob(jobId, this::getGenerationJob, this::cancelGenerationJob, Duration.ofSeconds(45), Duration.ofMillis(100)); + } + + public CompletableFuture> waitForGeneration(String jobId, Duration deadline, Duration interval) { + return waitForJob(jobId, this::getGenerationJob, this::cancelGenerationJob, deadline, interval); + } + + private CompletableFuture> waitForJob( + String jobId, + Function>> getter, + Function>> canceler, + Duration deadline, + Duration interval) { + if (deadline == null || interval == null || deadline.compareTo(Duration.ofMillis(50)) < 0 || + deadline.compareTo(Duration.ofMinutes(5)) > 0 || interval.compareTo(Duration.ofMillis(10)) < 0 || + interval.compareTo(Duration.ofSeconds(5)) > 0) { + throw new RinConfigurationException("invalid_polling", "Job deadline or interval is out of range"); + } + long expires = System.nanoTime() + deadline.toNanos(); + CompletableFuture> result = new CompletableFuture<>(); + class Poller { + void poll() { + if (result.isDone()) return; + getter.apply(jobId).whenComplete((job, failure) -> { + if (result.isDone()) return; + if (failure != null) { + result.completeExceptionally(unwrap(failure)); + return; + } + String status = RinException.safeText(job.get("status"), 32, ""); + if (status.equals("succeeded")) { + result.complete(job); + return; + } + if (status.equals("failed") || status.equals("stale") || status.equals("canceled")) { + Object value = job.get("error"); + Map detail = value instanceof Map map ? map : Map.of(); + result.completeExceptionally(new RinApiException( + RinException.safeText(detail.get("code"), 96, "job_" + status), + RinException.safeText(detail.get("message"), 500, "Rin job ended as " + status), + 0, + "")); + return; + } + if (!status.equals("queued") && !status.equals("running")) { + result.completeExceptionally(new RinProtocolException("invalid_job", "Rin returned an unknown job status")); + return; + } + long remaining = expires - System.nanoTime(); + if (remaining <= 0) { + try { + canceler.apply(jobId); + } catch (RinException ignored) { + // Timeout remains the useful result even if best-effort cancellation fails. + } + result.completeExceptionally(new RinApiException("job_timeout", "Rin job exceeded its deadline", 0, "")); + return; + } + long delay = Math.min(interval.toNanos(), remaining); + CompletableFuture.delayedExecutor(delay, TimeUnit.NANOSECONDS).execute(this::poll); + }); + } + } + new Poller().poll(); + return result; + } + + private CompletableFuture> post(String path, Map payload, int expectedStatus) { + return request("POST", path, Objects.requireNonNull(payload, "payload"), Set.of(expectedStatus)); + } + + private CompletableFuture> request( + String method, + String path, + Map payload, + Set expectedStatuses) { + if (!path.startsWith("/") || path.contains("//") || path.contains("..")) { + throw new RinConfigurationException("invalid_path", "Rin request path is invalid"); + } + + HttpRequest.BodyPublisher body = HttpRequest.BodyPublishers.noBody(); + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(baseUrl + path)) + .timeout(timeout) + .header("Accept", "application/json") + .header("User-Agent", "rin-java/0.5"); + if (payload != null) { + final String encoded; + try { + encoded = codec.encode(payload); + } catch (Exception exception) { + throw new RinProtocolException("invalid_request", "Rin payload is not JSON serializable", exception); + } + if (encoded == null) { + throw new RinProtocolException("invalid_request", "JSON codec returned a null request"); + } + body = HttpRequest.BodyPublishers.ofString(encoded, StandardCharsets.UTF_8); + builder.header("Content-Type", "application/json; charset=utf-8"); + } + if (!token.isEmpty()) builder.header("Authorization", "Bearer " + token); + builder.method(method, body); + + CompletableFuture> network = http.sendAsync( + builder.build(), + ignored -> new BoundedBodySubscriber(maxResponseBytes)); + CompletableFuture> wire = new CompletableFuture<>(); + network.whenComplete((response, failure) -> { + if (failure == null) wire.complete(response); + else wire.completeExceptionally(unwrap(failure)); + }); + CompletableFuture.delayedExecutor(timeout.toMillis(), TimeUnit.MILLISECONDS).execute(() -> { + if (wire.completeExceptionally(new HttpTimeoutException("Rin request timed out"))) { + network.cancel(true); + } + }); + CompletableFuture> result = new CompletableFuture<>(); + wire.whenComplete((response, failure) -> { + if (failure != null) { + Throwable cause = unwrap(failure); + if (cause instanceof RinException) { + result.completeExceptionally(cause); + } else if (cause instanceof HttpTimeoutException) { + result.completeExceptionally(new RinTransportException( + "transport_timeout", "Rin request timed out", cause)); + } else { + result.completeExceptionally(new RinTransportException( + "transport_failed", "Rin is unavailable", cause)); + } + return; + } + try { + result.complete(decodeResponse(response, expectedStatuses)); + } catch (RuntimeException exception) { + result.completeExceptionally(exception); + } + }); + result.whenComplete((ignored, failure) -> { + if (result.isCancelled()) { + wire.cancel(true); + network.cancel(true); + } + }); + return result; + } + + private Map decodeResponse(HttpResponse response, Set expectedStatuses) { + int status = response.statusCode(); + if (status >= 300 && status < 400) { + throw new RinTransportException("redirect_rejected", "Rin endpoint attempted to redirect"); + } + String contentLength = response.headers().firstValue("Content-Length").orElse(null); + if (contentLength != null) { + final long declared; + try { + declared = Long.parseLong(contentLength); + } catch (NumberFormatException exception) { + throw new RinProtocolException("invalid_response", "Rin returned an invalid Content-Length", exception); + } + if (declared < 0 || declared > maxResponseBytes) { + throw new RinProtocolException("response_too_large", "Rin response exceeds the configured limit"); + } + } + + byte[] bytes = response.body(); + + final String json; + try { + json = StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (CharacterCodingException exception) { + throw new RinProtocolException("invalid_response", "Rin returned invalid UTF-8", exception); + } + + final Map envelope; + try { + envelope = codec.decodeObject(json); + } catch (Exception exception) { + if (!expectedStatuses.contains(status)) { + throw new RinApiException("http_error", "Rin request failed", status, ""); + } + throw new RinProtocolException("invalid_response", "Rin returned invalid JSON", exception); + } + if (envelope == null) { + throw new RinProtocolException("invalid_response", "Rin response must be an object"); + } + if (!expectedStatuses.contains(status) || !Boolean.TRUE.equals(envelope.get("ok"))) { + throw apiError(envelope, status); + } + Object data = envelope.get("data"); + if (!(data instanceof Map map)) { + throw new RinProtocolException("invalid_response", "Rin response data must be an object"); + } + Map result = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + if (!(entry.getKey() instanceof String key)) { + throw new RinProtocolException("invalid_response", "Rin response data contains a non-string key"); + } + result.put(key, entry.getValue()); + } + return Collections.unmodifiableMap(result); + } + + private static RinApiException apiError(Map envelope, int status) { + Object value = envelope.get("error"); + Map detail = value instanceof Map map ? map : Map.of(); + return new RinApiException( + RinException.safeText(detail.get("code"), 96, "http_error"), + RinException.safeText(detail.get("message"), 500, "Rin request failed"), + status, + RinException.safeText(detail.get("field"), 160, "")); + } + + private static String normalizeBaseUrl(String value, String validatedToken) { + String raw = value == null || value.isBlank() ? DEFAULT_BASE_URL : value.strip(); + while (raw.endsWith("/")) raw = raw.substring(0, raw.length() - 1); + final URI uri; + try { + uri = new URI(raw); + } catch (URISyntaxException exception) { + throw new RinConfigurationException("invalid_base_url", "Rin base URL must be an origin", exception); + } + String scheme = uri.getScheme(); + String host = uri.getHost(); + String path = uri.getRawPath(); + if (!("http".equals(scheme) || "https".equals(scheme)) || host == null || host.isEmpty() || + uri.getRawUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null || + (path != null && !path.isEmpty() && !"/".equals(path)) || uri.getPort() > 65535) { + throw new RinConfigurationException("invalid_base_url", "Rin base URL must be an origin"); + } + boolean loopback = isLoopback(host); + if ("http".equals(scheme) && !loopback) { + throw new RinConfigurationException("insecure_base_url", "Remote Rin endpoints must use HTTPS"); + } + if (!loopback && validatedToken.isEmpty()) { + throw new RinConfigurationException("missing_token", "Remote Rin endpoints require a token"); + } + return uri.getScheme() + "://" + uri.getRawAuthority(); + } + + private static boolean isLoopback(String value) { + String host = value.toLowerCase(); + if (host.startsWith("[") && host.endsWith("]")) host = host.substring(1, host.length() - 1); + if (host.equals("localhost") || host.equals("::1") || host.equals("0:0:0:0:0:0:0:1")) return true; + String[] octets = host.split("\\.", -1); + if (octets.length != 4) return false; + try { + if (Integer.parseInt(octets[0]) != 127) return false; + for (String octet : octets) { + int number = Integer.parseInt(octet); + if (number < 0 || number > 255) return false; + } + return true; + } catch (NumberFormatException ignored) { + return false; + } + } + + private static String validateToken(String value) { + String candidate = value == null ? "" : value; + if (candidate.length() > 4096 || !candidate.equals(candidate.strip()) || + candidate.indexOf('\0') >= 0 || candidate.indexOf('\r') >= 0 || candidate.indexOf('\n') >= 0) { + throw new RinConfigurationException("invalid_token", "Rin token must be a bounded single-line value"); + } + return candidate; + } + + private static String pathId(String value) { + if (value == null || value.isEmpty() || value.length() > 96 || !value.matches("[A-Za-z0-9._-]+")) { + throw new RinConfigurationException("invalid_identifier", "Rin path identifier is invalid"); + } + return value; + } + + private static Throwable unwrap(Throwable failure) { + return failure instanceof CompletionException && failure.getCause() != null ? failure.getCause() : failure; + } + + private static final class BoundedBodySubscriber implements HttpResponse.BodySubscriber { + private final int maximum; + private final ByteArrayOutputStream output; + private final CompletableFuture body = new CompletableFuture<>(); + private Flow.Subscription subscription; + private boolean done; + + private BoundedBodySubscriber(int maximum) { + this.maximum = maximum; + this.output = new ByteArrayOutputStream(Math.min(maximum, 8192)); + } + + @Override + public CompletionStage getBody() { + return body; + } + + @Override + public void onSubscribe(Flow.Subscription value) { + if (subscription != null) { + value.cancel(); + return; + } + subscription = Objects.requireNonNull(value, "subscription"); + subscription.request(1); + } + + @Override + public void onNext(List buffers) { + if (done) return; + try { + for (ByteBuffer buffer : buffers) { + int count = buffer.remaining(); + if ((long) output.size() + count > maximum) { + fail(new RinProtocolException( + "response_too_large", "Rin response exceeds the configured limit")); + return; + } + byte[] chunk = new byte[count]; + buffer.get(chunk); + output.write(chunk, 0, count); + } + subscription.request(1); + } catch (RuntimeException exception) { + fail(exception); + } + } + + @Override + public void onError(Throwable failure) { + if (done) return; + done = true; + body.completeExceptionally(failure); + } + + @Override + public void onComplete() { + if (done) return; + done = true; + body.complete(output.toByteArray()); + } + + private void fail(RuntimeException failure) { + if (done) return; + done = true; + subscription.cancel(); + body.completeExceptionally(failure); + } + } +} diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/RinConfigurationException.java b/sdk/java/src/main/java/io/github/sunrioa/rin/RinConfigurationException.java new file mode 100644 index 0000000..6408a4e --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/RinConfigurationException.java @@ -0,0 +1,13 @@ +package io.github.sunrioa.rin; + +public final class RinConfigurationException extends RinException { + private static final long serialVersionUID = 1L; + + public RinConfigurationException(String code, String message) { + super(code, message); + } + + public RinConfigurationException(String code, String message, Throwable cause) { + super(code, message, cause); + } +} diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/RinException.java b/sdk/java/src/main/java/io/github/sunrioa/rin/RinException.java new file mode 100644 index 0000000..375ba27 --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/RinException.java @@ -0,0 +1,29 @@ +package io.github.sunrioa.rin; + +public class RinException extends RuntimeException { + private static final long serialVersionUID = 1L; + + private final String code; + + public RinException(String code, String message) { + this(code, message, null); + } + + public RinException(String code, String message, Throwable cause) { + super(safeText(message, 500, "Rin request failed"), cause); + this.code = safeText(code, 96, "rin_error"); + } + + public String code() { + return code; + } + + static String safeText(Object value, int maximum, String fallback) { + String cleaned = String.valueOf(value == null ? "" : value) + .replace('\0', ' ') + .trim() + .replaceAll("\\s+", " "); + if (cleaned.isEmpty()) return fallback; + return cleaned.substring(0, Math.min(cleaned.length(), maximum)); + } +} diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/RinProtocolException.java b/sdk/java/src/main/java/io/github/sunrioa/rin/RinProtocolException.java new file mode 100644 index 0000000..9ed8c3b --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/RinProtocolException.java @@ -0,0 +1,13 @@ +package io.github.sunrioa.rin; + +public final class RinProtocolException extends RinException { + private static final long serialVersionUID = 1L; + + public RinProtocolException(String code, String message) { + super(code, message); + } + + public RinProtocolException(String code, String message, Throwable cause) { + super(code, message, cause); + } +} diff --git a/sdk/java/src/main/java/io/github/sunrioa/rin/RinTransportException.java b/sdk/java/src/main/java/io/github/sunrioa/rin/RinTransportException.java new file mode 100644 index 0000000..78bcf3f --- /dev/null +++ b/sdk/java/src/main/java/io/github/sunrioa/rin/RinTransportException.java @@ -0,0 +1,13 @@ +package io.github.sunrioa.rin; + +public final class RinTransportException extends RinException { + private static final long serialVersionUID = 1L; + + public RinTransportException(String code, String message) { + super(code, message); + } + + public RinTransportException(String code, String message, Throwable cause) { + super(code, message, cause); + } +} diff --git a/sdk/java/test/io/github/sunrioa/rin/RinClientTest.java b/sdk/java/test/io/github/sunrioa/rin/RinClientTest.java new file mode 100644 index 0000000..99a5582 --- /dev/null +++ b/sdk/java/test/io/github/sunrioa/rin/RinClientTest.java @@ -0,0 +1,134 @@ +package io.github.sunrioa.rin; + +import com.sun.net.httpserver.HttpServer; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.function.Supplier; + +public final class RinClientTest { + private record RequestCase(Supplier>> call, String method, String path) { } + + public static void main(String[] args) throws Exception { + String[] lastRequest = new String[3]; + String[] mode = {"normal"}; + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + lastRequest[0] = exchange.getRequestMethod(); + lastRequest[1] = exchange.getRequestURI().getPath(); + lastRequest[2] = exchange.getRequestHeaders().getFirst("Authorization"); + if (mode[0].equals("slow")) { + try { + Thread.sleep(200); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } + byte[] body = mode[0].equals("oversized") + ? new byte[2048] + : "{}".getBytes(StandardCharsets.UTF_8); + int status = (lastRequest[1].equals("/v1/jobs/propose") || lastRequest[1].equals("/v1/generation/jobs")) ? 202 : 200; + exchange.sendResponseHeaders(status, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + server.start(); + + JsonCodec codec = new JsonCodec() { + public String encode(Map value) { return "{}"; } + public Map decodeObject(String json) { + return Map.of("ok", true, "data", Map.of("status", "ok")); + } + }; + RinClient client = new RinClient( + "http://127.0.0.1:" + server.getAddress().getPort(), + "fixture", + Duration.ofSeconds(2), + 1024 * 1024, + codec); + Map payload = Map.of(); + List cases = List.of( + new RequestCase(client::health, "GET", "/health"), + new RequestCase(() -> client.createSession(payload), "POST", "/v1/session/create"), + new RequestCase(() -> client.observe(payload), "POST", "/v1/session/observe"), + new RequestCase(() -> client.propose(payload), "POST", "/v1/agent/propose"), + new RequestCase(() -> client.submitProposalJob(payload), "POST", "/v1/jobs/propose"), + new RequestCase(() -> client.getProposalJob("job.fixture"), "GET", "/v1/jobs/job.fixture"), + new RequestCase(() -> client.cancelProposalJob("job.fixture"), "DELETE", "/v1/jobs/job.fixture"), + new RequestCase(() -> client.submitGenerationJob(payload), "POST", "/v1/generation/jobs"), + new RequestCase(() -> client.getGenerationJob("job.fixture"), "GET", "/v1/generation/jobs/job.fixture"), + new RequestCase(() -> client.cancelGenerationJob("job.fixture"), "DELETE", "/v1/generation/jobs/job.fixture"), + new RequestCase(() -> client.commit(payload), "POST", "/v1/action/commit"), + new RequestCase(() -> client.commitBatch(payload), "POST", "/v1/action/commit-batch"), + new RequestCase(() -> client.setActorActivity(payload), "POST", "/v1/session/activity"), + new RequestCase(() -> client.arbitrate(payload), "POST", "/v1/world/arbitrate"), + new RequestCase(() -> client.state(payload), "POST", "/v1/session/get"), + new RequestCase(() -> client.snapshot(payload), "POST", "/v1/session/snapshot"), + new RequestCase(() -> client.restore(payload), "POST", "/v1/session/restore"), + new RequestCase(() -> client.timeline(payload), "POST", "/v1/session/timeline"), + new RequestCase(() -> client.replay(payload), "POST", "/v1/session/replay"), + new RequestCase(() -> client.dueAgents(payload), "POST", "/v1/scheduler/due") + ); + try { + for (RequestCase test : cases) { + test.call().get().join(); + require(test.method().equals(lastRequest[0]), "wrong method for " + test.path()); + require(test.path().equals(lastRequest[1]), "wrong path for " + test.path()); + require("Bearer fixture".equals(lastRequest[2]), "missing bearer token"); + } + try { + client.getProposalJob("\u4f5c\u4e1a"); + throw new AssertionError("Unicode path ID was accepted"); + } catch (RinConfigurationException expected) { + require("invalid_identifier".equals(expected.code()), "wrong identifier error"); + } + + mode[0] = "oversized"; + RinClient limited = new RinClient( + "http://127.0.0.1:" + server.getAddress().getPort(), + "", + Duration.ofSeconds(2), + 1024, + codec); + try { + limited.health().join(); + throw new AssertionError("oversized streamed response was accepted"); + } catch (CompletionException expected) { + require(rootCause(expected) instanceof RinProtocolException, "wrong response limit error"); + } + + mode[0] = "slow"; + RinClient impatient = new RinClient( + "http://127.0.0.1:" + server.getAddress().getPort(), + "", + Duration.ofMillis(50), + 1024, + codec); + try { + impatient.health().join(); + throw new AssertionError("slow response exceeded the request deadline"); + } catch (CompletionException expected) { + Throwable cause = rootCause(expected); + require(cause instanceof RinTransportException, "wrong timeout error type"); + require("transport_timeout".equals(((RinTransportException) cause).code()), "wrong timeout error code"); + } + } finally { + server.stop(0); + } + } + + private static void require(boolean condition, String message) { + if (!condition) throw new AssertionError(message); + } + + private static Throwable rootCause(Throwable error) { + Throwable result = error; + while (result instanceof CompletionException && result.getCause() != null) result = result.getCause(); + return result; + } +} diff --git a/sdk/javascript/package.json b/sdk/javascript/package.json index aae3348..cb31634 100644 --- a/sdk/javascript/package.json +++ b/sdk/javascript/package.json @@ -15,6 +15,5 @@ "engines": { "node": ">=18" }, - "license": "MIT", "private": true } diff --git a/sdk/javascript/src/index.js b/sdk/javascript/src/index.js index 6a8367e..8f248bd 100644 --- a/sdk/javascript/src/index.js +++ b/sdk/javascript/src/index.js @@ -142,53 +142,101 @@ export class RinClient { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), this.timeoutMs); - let response; try { - response = await this.fetch(`${this.baseUrl}${path}`, { + const response = await this.fetch(`${this.baseUrl}${path}`, { method, headers, body, signal: controller.signal, redirect: "error", }); + const declared = response.headers?.get?.("content-length"); + if (declared !== null && declared !== undefined && declared !== "") { + const length = Number(declared); + if (!Number.isSafeInteger(length) || length < 0) { + throw new RinProtocolError("invalid_response", "Rin returned an invalid Content-Length"); + } + if (length > this.maxResponseBytes) { + await cancelBody(response); + throw new RinProtocolError("response_too_large", "Rin response exceeds the configured limit"); + } + } + const raw = await readBoundedBody(response, this.maxResponseBytes); + + let envelope; + try { + envelope = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)); + } catch (cause) { + throw new RinProtocolError("invalid_response", "Rin returned invalid JSON", { cause }); + } + if (!isObject(envelope)) { + throw new RinProtocolError("invalid_response", "Rin response must be an object"); + } + if (!expectedStatuses.includes(response.status) || envelope.ok !== true) { + throw apiError(envelope, response.status); + } + if (!isObject(envelope.data)) { + throw new RinProtocolError("invalid_response", "Rin response data must be an object"); + } + return envelope.data; } catch (cause) { if (cause instanceof RinError) throw cause; - throw new RinTransportError("transport_failed", "Rin is unavailable", { cause }); + const timedOut = controller.signal.aborted; + throw new RinTransportError( + timedOut ? "transport_timeout" : "transport_failed", + timedOut ? "Rin request timed out" : "Rin is unavailable", + { cause }, + ); } finally { clearTimeout(timer); } + } +} - const declared = response.headers?.get?.("content-length"); - if (declared !== null && declared !== undefined && declared !== "") { - const length = Number(declared); - if (!Number.isSafeInteger(length) || length < 0) { - throw new RinProtocolError("invalid_response", "Rin returned an invalid Content-Length"); +async function readBoundedBody(response, maximum) { + const reader = response.body?.getReader?.(); + if (!reader) { + const raw = new Uint8Array(await response.arrayBuffer()); + if (raw.byteLength > maximum) { + throw new RinProtocolError("response_too_large", "Rin response exceeds the configured limit"); + } + return raw; + } + + const chunks = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!(value instanceof Uint8Array)) { + throw new RinProtocolError("invalid_response", "Rin response stream returned an invalid chunk"); } - if (length > this.maxResponseBytes) { + total += value.byteLength; + if (total > maximum) { + await reader.cancel(); throw new RinProtocolError("response_too_large", "Rin response exceeds the configured limit"); } + chunks.push(value); } - const raw = await response.arrayBuffer(); - if (raw.byteLength > this.maxResponseBytes) { - throw new RinProtocolError("response_too_large", "Rin response exceeds the configured limit"); - } + } finally { + reader.releaseLock?.(); + } - let envelope; - try { - envelope = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(raw)); - } catch (cause) { - throw new RinProtocolError("invalid_response", "Rin returned invalid JSON", { cause }); - } - if (!isObject(envelope)) { - throw new RinProtocolError("invalid_response", "Rin response must be an object"); - } - if (!expectedStatuses.includes(response.status) || envelope.ok !== true) { - throw apiError(envelope, response.status); - } - if (!isObject(envelope.data)) { - throw new RinProtocolError("invalid_response", "Rin response data must be an object"); - } - return envelope.data; + const raw = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + raw.set(chunk, offset); + offset += chunk.byteLength; + } + return raw; +} + +async function cancelBody(response) { + try { + await response.body?.cancel?.(); + } catch { + // The response is already being rejected; cancellation is best effort. } } diff --git a/sdk/javascript/test/client.test.js b/sdk/javascript/test/client.test.js index 89fb4f3..006d00b 100644 --- a/sdk/javascript/test/client.test.js +++ b/sdk/javascript/test/client.test.js @@ -69,10 +69,52 @@ test("unsafe identifiers and oversized responses are rejected", async () => { maxResponseBytes: 1024, fetch: async () => response(200, { ok: true, data: {} }, { "content-length": "2048" }), }); - assert.throws(() => client.getProposalJob("作业"), RinConfigurationError); + assert.throws(() => client.getProposalJob("\u4f5c\u4e1a"), RinConfigurationError); await assert.rejects(client.health(), RinProtocolError); }); +test("streamed responses are capped before the full body is buffered", async () => { + let reads = 0; + let canceled = false; + const body = { + getReader: () => ({ + read: async () => { + reads += 1; + return { done: false, value: new Uint8Array(600) }; + }, + cancel: async () => { canceled = true; }, + releaseLock: () => {}, + }), + }; + const client = new RinClient(undefined, { + maxResponseBytes: 1024, + fetch: async () => ({ status: 200, headers: { get: () => null }, body }), + }); + await assert.rejects(client.health(), RinProtocolError); + assert.equal(reads, 2); + assert.equal(canceled, true); +}); + +test("the deadline remains active while a streamed body is read", async () => { + const client = new RinClient(undefined, { + timeoutMs: 50, + fetch: async (_url, options) => ({ + status: 200, + headers: { get: () => null }, + body: { + getReader: () => ({ + read: () => new Promise((_resolve, reject) => { + options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }), + cancel: async () => {}, + releaseLock: () => {}, + }), + }, + }), + }); + await assert.rejects(client.health(), (error) => error.code === "transport_timeout"); +}); + test("API errors expose only the bounded protocol detail", async () => { const client = new RinClient(undefined, { fetch: async () => response(400, { ok: false, error: { code: "invalid_request", message: "safe", field: "actor_id" } }), diff --git a/sdk/lua/README.md b/sdk/lua/README.md new file mode 100644 index 0000000..6918ff5 --- /dev/null +++ b/sdk/lua/README.md @@ -0,0 +1,30 @@ +# Rin Lua SDK + +The client supports Lua 5.1+ and does not assume a particular engine. Supply +three adapters: + +- `http_fetch(request, callback)` returns `{status, body, headers}` and must + honor `follow_redirects = false`; +- `json_encode(table)` and `json_decode(string)` use the engine's JSON codec; +- optional `schedule(seconds, callback)` and a monotonic `now()` enable job + polling without blocking the game loop. Without `now`, the portable but + lower-resolution `os.time` wall clock is used. + +```lua +local rin = dofile("rin.lua") +local client, err = rin.new({ + base_url = "http://127.0.0.1:7374", + http_fetch = engine_http_fetch, + json_encode = engine_json_encode, + json_decode = engine_json_decode, + schedule = engine_schedule, +}) +assert(client, err and err.message) + +client:health(function(data, request_error) + if request_error then print(request_error.code) else print(data.status) end +end) +``` + +The callback convention is `(data, error)`. Network work remains asynchronous; +only apply allowlisted actions from the engine's owning thread. diff --git a/sdk/lua/rin.lua b/sdk/lua/rin.lua new file mode 100644 index 0000000..5b10841 --- /dev/null +++ b/sdk/lua/rin.lua @@ -0,0 +1,339 @@ +local rin = { + PROTOCOL_VERSION = "rin.protocol/v1", + DEFAULT_BASE_URL = "http://127.0.0.1:7374", + DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024, +} + +local Client = {} +Client.__index = Client + +local terminal_job_states = { + succeeded = true, + failed = true, + stale = true, + canceled = true, +} + +local function safe_text(value, maximum, fallback) + local text = tostring(value or ""):gsub("%z", " "):gsub("%s+", " ") + text = text:match("^%s*(.-)%s*$") or "" + if text == "" then text = fallback or "" end + return text:sub(1, maximum) +end + +local function failure(code, message, status, field) + return { + code = safe_text(code, 96, "rin_error"), + message = safe_text(message, 500, "Rin request failed"), + status = tonumber(status) or 0, + field = safe_text(field, 160, ""), + } +end + +local function validate_token(value) + local token = tostring(value or "") + if #token > 4096 or token:find("[%z\r\n]") or token:match("^%s") or token:match("%s$") then + return nil, failure("invalid_token", "Rin token must be a bounded single-line value") + end + return token +end + +local function is_loopback(host) + host = host:lower() + if host == "localhost" or host == "::1" or host == "0:0:0:0:0:0:0:1" then return true end + local first, second, third, fourth = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") + if not first then return false end + local octets = { tonumber(first), tonumber(second), tonumber(third), tonumber(fourth) } + if octets[1] ~= 127 then return false end + for index = 1, 4 do + if octets[index] < 0 or octets[index] > 255 then return false end + end + return true +end + +local function normalize_base_url(value, token) + local base_url = tostring(value or rin.DEFAULT_BASE_URL):match("^%s*(.-)%s*$") + while base_url:sub(-1) == "/" do base_url = base_url:sub(1, -2) end + local scheme, authority = base_url:match("^(https?)://([^/%?#]+)$") + if not scheme or authority:find("@", 1, true) then + return nil, failure("invalid_base_url", "Rin base URL must be an origin") + end + + local host, port + if authority:sub(1, 1) == "[" then + host, port = authority:match("^%[([^%]]+)%]:(%d+)$") + if not host then host = authority:match("^%[([^%]]+)%]$") end + else + host, port = authority:match("^([^:]+):(%d+)$") + if not host and not authority:find(":", 1, true) then host = authority end + end + if not host or host == "" then + return nil, failure("invalid_base_url", "Rin base URL must be an origin") + end + if port and (tonumber(port) < 1 or tonumber(port) > 65535) then + return nil, failure("invalid_base_url", "Rin base URL has an invalid port") + end + + local loopback = is_loopback(host) + if scheme == "http" and not loopback then + return nil, failure("insecure_base_url", "Remote Rin endpoints must use HTTPS") + end + if not loopback and token == "" then + return nil, failure("missing_token", "Remote Rin endpoints require a token") + end + return base_url +end + +local function path_id(value) + local text = tostring(value or "") + if #text < 1 or #text > 96 then + return nil, failure("invalid_identifier", "Rin path identifier is invalid") + end + for index = 1, #text do + local byte = text:byte(index) + local valid = (byte >= 48 and byte <= 57) or (byte >= 65 and byte <= 90) or + (byte >= 97 and byte <= 122) or byte == 45 or byte == 46 or byte == 95 + if not valid then + return nil, failure("invalid_identifier", "Rin path identifier is invalid") + end + end + return text +end + +local function header_value(headers, wanted) + if type(headers) ~= "table" then return nil end + wanted = wanted:lower() + for key, value in pairs(headers) do + if tostring(key):lower() == wanted then return tostring(value) end + end + return nil +end + +function rin.new(options) + options = options or {} + if type(options) ~= "table" then + return nil, failure("invalid_options", "Rin options must be a table") + end + if type(options.http_fetch) ~= "function" or type(options.json_encode) ~= "function" or + type(options.json_decode) ~= "function" then + return nil, failure("missing_adapter", "http_fetch, json_encode, and json_decode are required") + end + + local token, token_error = validate_token(options.token) + if not token then return nil, token_error end + local base_url, url_error = normalize_base_url(options.base_url, token) + if not base_url then return nil, url_error end + local timeout = tonumber(options.timeout or 5) + local max_response_bytes = tonumber(options.max_response_bytes or rin.DEFAULT_MAX_RESPONSE_BYTES) + if not timeout or timeout ~= timeout or timeout < 0.05 or timeout > 120 then + return nil, failure("invalid_timeout", "Timeout must be between 0.05 and 120 seconds") + end + if not max_response_bytes or max_response_bytes ~= math.floor(max_response_bytes) or + max_response_bytes < 1024 or max_response_bytes > 32 * 1024 * 1024 then + return nil, failure("invalid_response_limit", "Response limit must be between 1 KiB and 32 MiB") + end + + return setmetatable({ + base_url = base_url, + token = token, + timeout = timeout, + max_response_bytes = max_response_bytes, + http_fetch = options.http_fetch, + json_encode = options.json_encode, + json_decode = options.json_decode, + schedule = options.schedule, + now = options.now or os.time, + }, Client) +end + +function Client:_request(method, path, payload, expected_status, callback) + if type(callback) ~= "function" then error("Rin callback is required", 2) end + if type(path) ~= "string" or path:sub(1, 1) ~= "/" or path:find("//", 1, true) or path:find("..", 1, true) then + callback(nil, failure("invalid_path", "Rin request path is invalid")) + return + end + + local body + if payload ~= nil then + if type(payload) ~= "table" then + callback(nil, failure("invalid_request", "Rin payload must be an object")) + return + end + local encoded, value = pcall(self.json_encode, payload) + if not encoded or type(value) ~= "string" then + callback(nil, failure("invalid_request", "Rin payload is not JSON serializable")) + return + end + body = value + end + + local headers = { + ["Accept"] = "application/json", + ["User-Agent"] = "rin-lua/0.5", + } + if body then headers["Content-Type"] = "application/json; charset=utf-8" end + if self.token ~= "" then headers["Authorization"] = "Bearer " .. self.token end + local request = { + url = self.base_url .. path, + method = method, + headers = headers, + body = body, + timeout = self.timeout, + follow_redirects = false, + } + + local delivered = false + local function finish(data, err) + if delivered then return end + delivered = true + callback(data, err) + end + local started, start_error = pcall(self.http_fetch, request, function(response) + if type(response) ~= "table" then + finish(nil, failure("transport_failed", "Rin transport returned an invalid response")) + return + end + local status = tonumber(response.status) + if not status or status ~= math.floor(status) or status < 100 or status > 599 then + finish(nil, failure("transport_failed", "Rin transport did not return a valid status")) + return + end + if status >= 300 and status < 400 then + finish(nil, failure("redirect_rejected", "Rin endpoint attempted to redirect", status)) + return + end + local raw = response.body + if type(raw) ~= "string" then + finish(nil, failure("invalid_response", "Rin response body must be a string", status)) + return + end + local declared_text = header_value(response.headers, "content-length") + local declared = declared_text and tonumber(declared_text) or nil + if declared_text and (not declared or declared < 0 or declared ~= math.floor(declared)) then + finish(nil, failure("invalid_response", "Rin returned an invalid Content-Length", status)) + return + end + if (declared and declared > self.max_response_bytes) or #raw > self.max_response_bytes then + finish(nil, failure("response_too_large", "Rin response exceeds the configured limit", status)) + return + end + + local decoded, envelope = pcall(self.json_decode, raw) + if not decoded or type(envelope) ~= "table" then + if status ~= expected_status then + finish(nil, failure("http_error", "Rin request failed", status)) + else + finish(nil, failure("invalid_response", "Rin returned invalid JSON", status)) + end + return + end + if status ~= expected_status or envelope.ok ~= true then + local detail = type(envelope.error) == "table" and envelope.error or {} + finish(nil, failure(detail.code or "http_error", detail.message or "Rin request failed", status, detail.field)) + return + end + if type(envelope.data) ~= "table" then + finish(nil, failure("invalid_response", "Rin response data must be an object", status)) + return + end + finish(envelope.data, nil) + end) + if not started then + if delivered then error(start_error, 0) end + finish(nil, failure("transport_failed", "Rin transport could not start")) + end +end + +function Client:_post(path, payload, status, callback) + self:_request("POST", path, payload, status or 200, callback) +end + +function Client:health(callback) self:_request("GET", "/health", nil, 200, callback) end +function Client:create_session(payload, callback) self:_post("/v1/session/create", payload, 200, callback) end +function Client:observe(payload, callback) self:_post("/v1/session/observe", payload, 200, callback) end +function Client:propose(payload, callback) self:_post("/v1/agent/propose", payload, 200, callback) end +function Client:submit_proposal_job(payload, callback) self:_post("/v1/jobs/propose", payload, 202, callback) end +function Client:get_proposal_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("GET", "/v1/jobs/" .. id, nil, 200, callback) +end +function Client:cancel_proposal_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("DELETE", "/v1/jobs/" .. id, nil, 200, callback) +end +function Client:submit_generation_job(payload, callback) self:_post("/v1/generation/jobs", payload, 202, callback) end +function Client:get_generation_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("GET", "/v1/generation/jobs/" .. id, nil, 200, callback) +end +function Client:cancel_generation_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("DELETE", "/v1/generation/jobs/" .. id, nil, 200, callback) +end +function Client:commit(payload, callback) self:_post("/v1/action/commit", payload, 200, callback) end +function Client:commit_batch(payload, callback) self:_post("/v1/action/commit-batch", payload, 200, callback) end +function Client:set_actor_activity(payload, callback) self:_post("/v1/session/activity", payload, 200, callback) end +function Client:arbitrate(payload, callback) self:_post("/v1/world/arbitrate", payload, 200, callback) end +function Client:state(payload, callback) self:_post("/v1/session/get", payload, 200, callback) end +function Client:snapshot(payload, callback) self:_post("/v1/session/snapshot", payload, 200, callback) end +function Client:restore(payload, callback) self:_post("/v1/session/restore", payload, 200, callback) end +function Client:timeline(payload, callback) self:_post("/v1/session/timeline", payload, 200, callback) end +function Client:replay(payload, callback) self:_post("/v1/session/replay", payload, 200, callback) end +function Client:due_agents(payload, callback) self:_post("/v1/scheduler/due", payload, 200, callback) end + +function Client:_wait_job(job_id, getter, canceler, options, callback) + options = options or {} + local deadline = tonumber(options.deadline or 25) + local interval = tonumber(options.interval or 0.1) + if type(self.schedule) ~= "function" then + callback(nil, failure("missing_scheduler", "A scheduler is required to wait for jobs")) + return + end + if not deadline or deadline ~= deadline or deadline < 0.05 or deadline > 300 or + not interval or interval ~= interval or interval < 0.01 or interval > 5 then + callback(nil, failure("invalid_polling", "Job deadline or interval is out of range")) + return + end + local expires = self.now() + deadline + local poll + poll = function() + getter(self, job_id, function(job, err) + if err then callback(nil, err); return end + local status = tostring(job.status or "") + if status == "succeeded" then callback(job, nil); return end + if terminal_job_states[status] then + local detail = type(job.error) == "table" and job.error or {} + callback(nil, failure(detail.code or ("job_" .. status), detail.message or ("Rin job ended as " .. status))) + return + end + if status ~= "queued" and status ~= "running" then + callback(nil, failure("invalid_job", "Rin returned an unknown job status")) + return + end + if self.now() >= expires then + canceler(self, job_id, function() end) + callback(nil, failure("job_timeout", "Rin job exceeded its deadline")) + return + end + self.schedule(interval, poll) + end) + end + poll() +end + +function Client:wait_for_proposal(job_id, options, callback) + self:_wait_job(job_id, Client.get_proposal_job, Client.cancel_proposal_job, options, callback) +end + +function Client:wait_for_generation(job_id, options, callback) + local configured = {} + for key, value in pairs(options or {}) do configured[key] = value end + if configured.deadline == nil then configured.deadline = 45 end + self:_wait_job(job_id, Client.get_generation_job, Client.cancel_generation_job, configured, callback) +end + +return rin diff --git a/sdk/lua/test_client.lua b/sdk/lua/test_client.lua new file mode 100644 index 0000000..6634e97 --- /dev/null +++ b/sdk/lua/test_client.lua @@ -0,0 +1,79 @@ +local rin = dofile("sdk/lua/rin.lua") + +local last_request +local function fetch(request, callback) + last_request = request + local accepted = request.url:match("/v1/jobs/propose$") or request.url:match("/v1/generation/jobs$") + callback({ status = accepted and 202 or 200, body = "{}", headers = { ["Content-Length"] = "2" } }) +end + +local client, config_error = rin.new({ + token = "fixture", + http_fetch = fetch, + json_encode = function() return "{}" end, + json_decode = function() return { ok = true, data = { status = "ok" } } end, +}) +assert(client, config_error and config_error.message) + +local cases = { + { function(done) client:health(done) end, "GET", "/health" }, + { function(done) client:create_session({}, done) end, "POST", "/v1/session/create" }, + { function(done) client:observe({}, done) end, "POST", "/v1/session/observe" }, + { function(done) client:propose({}, done) end, "POST", "/v1/agent/propose" }, + { function(done) client:submit_proposal_job({}, done) end, "POST", "/v1/jobs/propose" }, + { function(done) client:get_proposal_job("job.fixture", done) end, "GET", "/v1/jobs/job.fixture" }, + { function(done) client:cancel_proposal_job("job.fixture", done) end, "DELETE", "/v1/jobs/job.fixture" }, + { function(done) client:submit_generation_job({}, done) end, "POST", "/v1/generation/jobs" }, + { function(done) client:get_generation_job("job.fixture", done) end, "GET", "/v1/generation/jobs/job.fixture" }, + { function(done) client:cancel_generation_job("job.fixture", done) end, "DELETE", "/v1/generation/jobs/job.fixture" }, + { function(done) client:commit({}, done) end, "POST", "/v1/action/commit" }, + { function(done) client:commit_batch({}, done) end, "POST", "/v1/action/commit-batch" }, + { function(done) client:set_actor_activity({}, done) end, "POST", "/v1/session/activity" }, + { function(done) client:arbitrate({}, done) end, "POST", "/v1/world/arbitrate" }, + { function(done) client:state({}, done) end, "POST", "/v1/session/get" }, + { function(done) client:snapshot({}, done) end, "POST", "/v1/session/snapshot" }, + { function(done) client:restore({}, done) end, "POST", "/v1/session/restore" }, + { function(done) client:timeline({}, done) end, "POST", "/v1/session/timeline" }, + { function(done) client:replay({}, done) end, "POST", "/v1/session/replay" }, + { function(done) client:due_agents({}, done) end, "POST", "/v1/scheduler/due" }, +} + +for _, test in ipairs(cases) do + test[1](function(data, err) assert(data and not err) end) + assert(last_request.method == test[2], "wrong method for " .. test[3]) + assert(last_request.url:sub(-#test[3]) == test[3], "wrong path for " .. test[3]) + assert(last_request.headers.Authorization == "Bearer fixture") + assert(last_request.follow_redirects == false) +end + +client:get_proposal_job(string.char(228, 189, 156, 228, 184, 154), function(data, err) + assert(not data and err.code == "invalid_identifier") +end) + +local remote, remote_error = rin.new({ + base_url = "http://models.example", + token = "fixture", + http_fetch = fetch, + json_encode = function() return "{}" end, + json_decode = function() return {} end, +}) +assert(not remote and remote_error.code == "insecure_base_url") + +local clock = 0 +local canceled = false +local polling_client = assert(rin.new({ + http_fetch = function(request, callback) + if request.method == "DELETE" then canceled = true end + callback({ status = 200, body = "{}", headers = {} }) + end, + json_encode = function() return "{}" end, + json_decode = function() return { ok = true, data = { status = "running" } } end, + schedule = function(seconds, callback) clock = clock + seconds; callback() end, + now = function() return clock end, +})) +polling_client:wait_for_proposal("job.fixture", { deadline = 0.05, interval = 0.01 }, function(data, err) + assert(not data and err.code == "job_timeout") +end) +assert(canceled, "timed-out job was not canceled") + +print("Rin Lua SDK tests passed") diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 147fce8..669ebc0 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -7,7 +7,6 @@ name = "rin-game-sdk" version = "0.5.0" description = "Dependency-free Python client for the Rin game agent runtime" requires-python = ">=3.9" -license = {text = "MIT"} [tool.setuptools] package-dir = {"" = "src"} diff --git a/sdk/python/src/rin_sdk/client.py b/sdk/python/src/rin_sdk/client.py index 8cb650d..a431599 100644 --- a/sdk/python/src/rin_sdk/client.py +++ b/sdk/python/src/rin_sdk/client.py @@ -187,7 +187,10 @@ def _request( if payload is not None: if not isinstance(payload, dict): raise RinProtocolError("invalid_request", "Rin payload must be an object") - body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + try: + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + except (TypeError, ValueError) as exc: + raise RinProtocolError("invalid_request", "Rin payload is not JSON serializable") from exc headers["Content-Type"] = "application/json" if self.token: headers["Authorization"] = "Bearer " + self.token @@ -196,7 +199,10 @@ def _request( with self._opener.open(request, timeout=self.timeout) as response: return self._decode(response, int(response.getcode()), tuple(expected_statuses)) except HTTPError as exc: - return self._decode_error(exc, int(exc.code)) + try: + return self._decode_error(exc, int(exc.code)) + finally: + exc.close() except (URLError, TimeoutError, OSError) as exc: raise RinTransportError("transport_failed", "Rin is unavailable") from exc @@ -204,7 +210,10 @@ def _decode(self, response: Any, status: int, expected: Tuple[int, ...]) -> Dict declared = response.headers.get("Content-Length", "") if declared: try: - if int(declared) > self.max_response_bytes: + length = int(declared) + if length < 0: + raise RinProtocolError("invalid_response", "Rin returned an invalid Content-Length") + if length > self.max_response_bytes: raise RinProtocolError("response_too_large", "Rin response exceeds the configured limit") except ValueError as exc: raise RinProtocolError("invalid_response", "Rin returned an invalid Content-Length") from exc @@ -220,6 +229,8 @@ def _decode(self, response: Any, status: int, expected: Tuple[int, ...]) -> Dict return data def _decode_error(self, response: HTTPError, status: int) -> Dict[str, Any]: + if 300 <= status < 400: + raise RinTransportError("redirect_rejected", "Rin endpoint attempted to redirect") raw = response.read(self.max_response_bytes + 1) if len(raw) > self.max_response_bytes: raise RinProtocolError("response_too_large", "Rin error response exceeds the configured limit") diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index 96b3237..7525ce9 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -7,7 +7,13 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) -from rin_sdk import RinAPIError, RinClient, RinConfigurationError # noqa: E402 +from rin_sdk import ( # noqa: E402 + RinAPIError, + RinClient, + RinConfigurationError, + RinProtocolError, + RinTransportError, +) class _Response: @@ -81,7 +87,7 @@ def test_routes_and_token(self): def test_job_id_is_ascii_and_path_safe(self): client = RinClient() client._opener = _Opener() - for invalid in ("", "../job", "job/other", "作业"): + for invalid in ("", "../job", "job/other", "\u4f5c\u4e1a"): with self.subTest(job_id=invalid), self.assertRaises(RinConfigurationError): client.get_proposal_job(invalid) @@ -109,6 +115,38 @@ def open(self, request, timeout): self.assertEqual(caught.exception.code, "invalid_request") self.assertEqual(caught.exception.status, 400) + def test_invalid_payload_and_content_length_are_protocol_errors(self): + client = RinClient() + with self.assertRaises(RinProtocolError): + client.observe({"recursive": object()}) + + response = _Response(200, {"ok": True, "data": {}}) + response.headers["Content-Length"] = "-1" + + class NegativeLengthOpener: + def open(self, request, timeout): + del request, timeout + return response + + client._opener = NegativeLengthOpener() + with self.assertRaises(RinProtocolError): + client.health() + + def test_redirect_is_rejected(self): + client = RinClient() + + class RedirectOpener: + def open(self, request, timeout): + del request, timeout + from urllib.error import HTTPError + + raise HTTPError("http://127.0.0.1", 302, "Found", {"Location": "https://example.com"}, io.BytesIO(b"")) + + client._opener = RedirectOpener() + with self.assertRaises(RinTransportError) as caught: + client.health() + self.assertEqual(caught.exception.code, "redirect_rejected") + if __name__ == "__main__": unittest.main() From 612fe98a61d7f4967dc7a32be58f828eb4fa7be3 Mon Sep 17 00:00:00 2001 From: sunrioa Date: Thu, 23 Jul 2026 10:27:01 +0800 Subject: [PATCH 3/4] feat: add cross-engine mod integration kits --- README.md | 7 +- ROADMAP.md | 9 + docs/sdk-and-mods.md | 114 ++++++ examples/mods/bepinex-rin-npc/Plugin.cs | 312 ++++++++++++++++ examples/mods/bepinex-rin-npc/README.md | 22 ++ examples/mods/fabric-rin-npc/README.md | 22 ++ .../sunrioa/rin/example/GsonJsonCodec.java | 32 ++ .../github/sunrioa/rin/example/RinNpcMod.java | 229 ++++++++++++ .../src/main/resources/fabric.mod.json | 18 + examples/mods/luanti-rin-npc/README.md | 23 ++ examples/mods/luanti-rin-npc/init.lua | 270 ++++++++++++++ examples/mods/luanti-rin-npc/mod.conf | 5 + examples/mods/luanti-rin-npc/rin.lua | 339 ++++++++++++++++++ examples/mods/luanti-rin-npc/settingtypes.txt | 2 + 14 files changed, 1403 insertions(+), 1 deletion(-) create mode 100644 docs/sdk-and-mods.md create mode 100644 examples/mods/bepinex-rin-npc/Plugin.cs create mode 100644 examples/mods/bepinex-rin-npc/README.md create mode 100644 examples/mods/fabric-rin-npc/README.md create mode 100644 examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/GsonJsonCodec.java create mode 100644 examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java create mode 100644 examples/mods/fabric-rin-npc/src/main/resources/fabric.mod.json create mode 100644 examples/mods/luanti-rin-npc/README.md create mode 100644 examples/mods/luanti-rin-npc/init.lua create mode 100644 examples/mods/luanti-rin-npc/mod.conf create mode 100644 examples/mods/luanti-rin-npc/rin.lua create mode 100644 examples/mods/luanti-rin-npc/settingtypes.txt diff --git a/README.md b/README.md index d92e0e6..55c896d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,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` 让长流程角色行为可以复现和审计。 @@ -94,8 +95,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)。 +跨语言目录规范、线程边界、凭据策略和 Mod 安装步骤见 [SDK 与 Mod 接入文档](docs/sdk-and-mods.md)。 ## 可选模型 Policy @@ -121,11 +125,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 示例 ``` ## 当前有意不做 diff --git a/ROADMAP.md b/ROADMAP.md index 8413257..a2dd55b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -48,4 +48,13 @@ 详细协议、兼容策略、阶段提交与验收矩阵见 [`docs/living-worlds-v0.5-plan.md`](docs/living-worlds-v0.5-plan.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 游戏版本中完成人工安装与交互验收 +- [ ] 发布 SDK 或 Mod 前由维护者选择并添加仓库许可证 + 每个阶段继续保持一个原则:模型可以提出意图和表达,游戏引擎决定现实发生了什么。 diff --git a/docs/sdk-and-mods.md b/docs/sdk-and-mods.md new file mode 100644 index 0000000..3c22e9c --- /dev/null +++ b/docs/sdk-and-mods.md @@ -0,0 +1,114 @@ +# SDK and mod integration kits + +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. +This repository does not currently contain a license file, so choose and add +one before distributing SDK packages or example mods. 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..0dde3aa --- /dev/null +++ b/examples/mods/bepinex-rin-npc/README.md @@ -0,0 +1,22 @@ +# BepInEx Rin NPC example + +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/fabric-rin-npc/README.md b/examples/mods/fabric-rin-npc/README.md new file mode 100644 index 0000000..0222823 --- /dev/null +++ b/examples/mods/fabric-rin-npc/README.md @@ -0,0 +1,22 @@ +# Fabric Rin NPC example + +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/src/main/java/io/github/sunrioa/rin/example/GsonJsonCodec.java b/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/GsonJsonCodec.java new file mode 100644 index 0000000..426d897 --- /dev/null +++ b/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/GsonJsonCodec.java @@ -0,0 +1,32 @@ +package io.github.sunrioa.rin.example; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.reflect.TypeToken; +import io.github.sunrioa.rin.JsonCodec; + +import java.lang.reflect.Type; +import java.util.Map; + +final class GsonJsonCodec implements JsonCodec { + private static final Type OBJECT_MAP = new TypeToken>() { }.getType(); + private final Gson gson; + + GsonJsonCodec(Gson gson) { + this.gson = gson; + } + + @Override + public String encode(Map value) { + return gson.toJson(value); + } + + @Override + public Map decodeObject(String json) { + JsonElement root = gson.fromJson(json, JsonElement.class); + if (root == null || !root.isJsonObject()) { + throw new IllegalArgumentException("Rin envelope must be an object"); + } + return gson.fromJson(root, OBJECT_MAP); + } +} diff --git a/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java b/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java new file mode 100644 index 0000000..b7744d2 --- /dev/null +++ b/examples/mods/fabric-rin-npc/src/main/java/io/github/sunrioa/rin/example/RinNpcMod.java @@ -0,0 +1,229 @@ +package io.github.sunrioa.rin.example; + +import com.google.gson.Gson; +import com.mojang.brigadier.Command; +import io.github.sunrioa.rin.RinClient; +import io.github.sunrioa.rin.RinException; +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +import static net.minecraft.server.command.CommandManager.literal; + +public final class RinNpcMod implements ModInitializer { + private static final String ACTOR_ID = "npc.rin.guide"; + private static final Set ALLOWED_ACTIONS = Set.of("talk", "wait", "refuse"); + + private final String runId = UUID.randomUUID().toString().substring(0, 12); + private final AtomicLong sequence = new AtomicLong(); + private final Map> sessions = new ConcurrentHashMap<>(); + private final Set activePlayers = ConcurrentHashMap.newKeySet(); + private final RinClient rin = new RinClient( + System.getenv().getOrDefault("RIN_URL", RinClient.DEFAULT_BASE_URL), + System.getenv().getOrDefault("RIN_TOKEN", ""), + Duration.ofSeconds(5), + RinClient.DEFAULT_MAX_RESPONSE_BYTES, + new GsonJsonCodec(new Gson())); + + @Override + public void onInitialize() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> + dispatcher.register(literal("rin-npc") + .then(literal("ask").executes(context -> { + requestTurn(context.getSource()); + return Command.SINGLE_SUCCESS; + })))); + } + + private void requestTurn(ServerCommandSource source) { + ServerPlayerEntity player; + try { + player = source.getPlayerOrThrow(); + } catch (Exception ignored) { + source.sendError(Text.literal("This example command must be run by a player.")); + return; + } + + MinecraftServer server = source.getServer(); + UUID playerId = player.getUuid(); + if (!activePlayers.add(playerId)) { + source.sendError(Text.literal("A Rin turn is already running for this player.")); + return; + } + String sessionId = "fabric." + runId + "." + playerId; + long turn = sequence.incrementAndGet(); + long tick = server.getTicks(); + source.sendFeedback(() -> Text.literal("The Rin guide is considering the situation..."), false); + + ensureSession(sessionId, player.getName().getString(), turn) + .thenCompose(ignored -> rin.observe(mapOf( + "protocol_version", RinClient.PROTOCOL_VERSION, + "session_id", sessionId, + "request_id", "observe." + turn, + "event_id", "event." + turn, + "tick", tick, + "observer_ids", List.of(ACTOR_ID), + "source", "fabric-example", + "kind", "dialogue", + "summary", "The player asked the guide what to do next.", + "tags", List.of("conversation", "player-request"), + "importance", 3))) + .thenCompose(ignored -> rin.submitProposalJob(mapOf( + "protocol_version", RinClient.PROTOCOL_VERSION, + "session_id", sessionId, + "request_id", "propose." + turn, + "actor_id", ACTOR_ID, + "tick", tick + 1, + "intent", "Choose one bounded response to the player.", + "tags", List.of("conversation"), + "candidate_actions", List.of( + mapOf("id", "talk", "kind", "dialogue", "description", "offer one concrete hint"), + mapOf("id", "wait", "kind", "wait", "description", "ask the player to observe first"), + mapOf("id", "refuse", "kind", "refuse", "description", "decline an unsafe request"))))) + .thenCompose(job -> rin.waitForProposal(text(job, "job_id"))) + .thenCompose(job -> applyAndCommit(server, playerId, sessionId, turn, tick + 2, job)) + .thenAccept(ignored -> server.execute(() -> { + ServerPlayerEntity current = server.getPlayerManager().getPlayer(playerId); + if (current != null) current.sendMessage(Text.literal("Rin turn committed."), false); + })) + .exceptionally(error -> { + String code = safeCode(error); + server.execute(() -> { + ServerPlayerEntity current = server.getPlayerManager().getPlayer(playerId); + if (current != null) current.sendMessage(Text.literal("Rin request failed: " + code), false); + }); + return null; + }) + .whenComplete((ignored, error) -> activePlayers.remove(playerId)); + } + + private CompletableFuture ensureSession(String sessionId, String playerName, long turn) { + return sessions.computeIfAbsent(sessionId, key -> { + CompletableFuture created = rin.createSession(mapOf( + "protocol_version", RinClient.PROTOCOL_VERSION, + "request_id", "create." + turn, + "session_id", sessionId, + "binding", mapOf( + "game_id", "minecraft-fabric", + "content_id", "rin-npc-example", + "content_version", "0.1.0", + "content_hash", "sha256:" + "0".repeat(64)), + "seed", turn, + "actors", List.of(mapOf( + "id", ACTOR_ID, + "kind", "npc", + "display_name", "Rin Guide", + "traits", List.of("observant", "careful"), + "boundaries", List.of(mapOf( + "id", "boundary.no-griefing", + "description", "Never suggest griefing or bypassing server rules.", + "trigger_tags", List.of("unsafe"), + "response", "refuse")), + "goals", List.of(mapOf( + "id", "goal.help-player", + "description", "Help " + playerName + " make one informed choice.", + "priority", 4, + "preferred_actions", List.of("talk"), + "progress", 0, + "target_progress", 3, + "status", "active")), + "think_every_ticks", 20, + "enabled", true)))) + .thenApply(ignored -> null); + created.whenComplete((ignored, error) -> { + if (error != null) sessions.remove(key, created); + }); + return created; + }); + } + + private CompletableFuture> applyAndCommit( + MinecraftServer server, + UUID playerId, + String sessionId, + long turn, + long tick, + Map job) { + Map proposal = object(job.get("proposal")); + Map action = object(proposal.get("action")); + String actionId = text(action, "id"); + String proposalId = text(proposal, "proposal_id"); + CompletableFuture applied = new CompletableFuture<>(); + + server.execute(() -> { + ServerPlayerEntity player = server.getPlayerManager().getPlayer(playerId); + if (player == null) { + applied.complete(new AppliedAction(false, "Player left before the proposal could be applied.")); + return; + } + if (!ALLOWED_ACTIONS.contains(actionId)) { + applied.complete(new AppliedAction(false, "The game rejected an action outside its allowlist.")); + return; + } + String line = switch (actionId) { + case "talk" -> "Guide: Check the nearby terrain, then choose a route with cover."; + case "wait" -> "Guide: Let us watch one more cycle before acting."; + case "refuse" -> "Guide: I cannot help with an action that breaks the server rules."; + default -> throw new IllegalStateException("allowlist changed during apply"); + }; + player.sendMessage(Text.literal(line), false); + applied.complete(new AppliedAction(true, line)); + }); + + return applied.thenCompose(result -> rin.commit(mapOf( + "protocol_version", RinClient.PROTOCOL_VERSION, + "session_id", sessionId, + "request_id", "commit." + turn, + "proposal_id", proposalId, + "event_id", "outcome." + turn, + "tick", tick, + "accepted", result.accepted(), + "outcome", result.outcome(), + "tags", List.of("fabric-example", "conversation")))); + } + + private static Map mapOf(Object... entries) { + if (entries.length % 2 != 0) throw new IllegalArgumentException("map entries must be key/value pairs"); + Map result = new LinkedHashMap<>(); + for (int index = 0; index < entries.length; index += 2) { + result.put((String) entries[index], entries[index + 1]); + } + return result; + } + + private static Map object(Object value) { + if (!(value instanceof Map source)) return Map.of(); + Map result = new LinkedHashMap<>(); + source.forEach((key, item) -> { + if (key instanceof String text) result.put(text, item); + }); + return result; + } + + private static String text(Map value, String key) { + Object item = value.get(key); + return item instanceof String text ? text : ""; + } + + private static String safeCode(Throwable error) { + Throwable cause = error; + while (cause instanceof CompletionException && cause.getCause() != null) cause = cause.getCause(); + return cause instanceof RinException rinError ? rinError.code() : "integration_failed"; + } + + private record AppliedAction(boolean accepted, String outcome) { } +} diff --git a/examples/mods/fabric-rin-npc/src/main/resources/fabric.mod.json b/examples/mods/fabric-rin-npc/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..ff5a805 --- /dev/null +++ b/examples/mods/fabric-rin-npc/src/main/resources/fabric.mod.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "id": "rin_npc_example", + "version": "0.1.0", + "name": "Rin NPC Example", + "description": "Reference Fabric integration for a Rin-backed NPC turn.", + "authors": ["sunrioa"], + "environment": "server", + "entrypoints": { + "main": ["io.github.sunrioa.rin.example.RinNpcMod"] + }, + "depends": { + "fabricloader": ">=0.16.0", + "fabric-api": "*", + "minecraft": ">=1.21", + "java": ">=21" + } +} diff --git a/examples/mods/luanti-rin-npc/README.md b/examples/mods/luanti-rin-npc/README.md new file mode 100644 index 0000000..2700354 --- /dev/null +++ b/examples/mods/luanti-rin-npc/README.md @@ -0,0 +1,23 @@ +# Luanti Rin NPC example + +This is a complete server-side Luanti mod. The included `rin.lua` is a vendored +copy of `sdk/lua/rin.lua`; the repository test requires both copies to match. + +1. Copy this directory to the Luanti `mods` or world `worldmods` directory. +2. Add `rin_npc_example` to `secure.http_mods` in `minetest.conf`. +3. Start Rin at `http://127.0.0.1:7374`, enable the mod, and restart the world. +4. Run `/rin_npc` or `/rin_npc your message` in chat. + +The mod calls `core.request_http_api()` only at module scope, keeps the returned +API local, uses `HTTPApiTable.fetch` asynchronously, and schedules polling with +`core.after`. It maps only `talk`, `wait`, and `refuse` to fixed game-owned +effects before committing the result. + +Luanti's HTTP implementation follows redirects and the Lua API provides no +per-request switch to disable that behavior. For that reason this example +accepts only explicit loopback HTTP origins and refuses Authorization headers; +do not adapt it to an authenticated remote Rin endpoint without a stricter +native transport. + +Official HTTP API: https://docs.luanti.org/for-creators/api/http-api/ +Official Lua API source: https://github.com/luanti-org/luanti/blob/master/doc/lua_api.md diff --git a/examples/mods/luanti-rin-npc/init.lua b/examples/mods/luanti-rin-npc/init.lua new file mode 100644 index 0000000..dee601a --- /dev/null +++ b/examples/mods/luanti-rin-npc/init.lua @@ -0,0 +1,270 @@ +local http_api = core.request_http_api and core.request_http_api() +local modpath = core.get_modpath(core.get_current_modname()) + +if not http_api then + core.log("error", "[rin_npc_example] HTTP access unavailable; add this mod to secure.http_mods") + return +end + +local function local_origin(value) + value = tostring(value or ""):gsub("/$", "") + if value:match("^http://127%.0%.0%.1:%d+$") or value:match("^http://localhost:%d+$") or + value:match("^http://%[::1%]:%d+$") then + return value + end + return nil +end + +local base_url = local_origin(core.settings:get("rin_npc_example.base_url") or "http://127.0.0.1:7374") +if not base_url then + core.log("error", "[rin_npc_example] base_url must be an explicit loopback HTTP origin") + return +end + +local rin = dofile(modpath .. "/rin.lua") + +local function encode_json(value) + local encoded, err = core.write_json(value) + if not encoded then error(err or "JSON encoding failed") end + return encoded +end + +local function decode_json(value) + local decoded, err = core.parse_json(value, nil, true) + if decoded == nil then error(err or "JSON decoding failed") end + return decoded +end + +local function fetch(request, callback) + if request.headers.Authorization then + callback({}) + return + end + local extra_headers = {} + local user_agent = "rin-luanti-example/0.1" + for key, value in pairs(request.headers) do + if key:lower() == "user-agent" then + user_agent = value + else + table.insert(extra_headers, key .. ": " .. value) + end + end + http_api.fetch({ + url = request.url, + timeout = request.timeout, + method = request.method, + data = request.body, + user_agent = user_agent, + extra_headers = extra_headers, + quiet = true, + }, function(result) + if not result.completed or not result.succeeded then + callback({}) + return + end + callback({ status = result.code, body = result.data or "", headers = {} }) + end) +end + +local client, client_error = rin.new({ + base_url = base_url, + http_fetch = fetch, + json_encode = encode_json, + json_decode = decode_json, + schedule = core.after, + now = function() return core.get_us_time() / 1000000 end, +}) +if not client then + core.log("error", "[rin_npc_example] configuration rejected: " .. client_error.code) + return +end + +local actor_id = "npc.rin.guide" +local allowed_actions = { + talk = "Guide: Check your supplies, then choose a route with a clear return path.", + wait = "Guide: Let us observe one more cycle before acting.", + refuse = "Guide: I cannot help with an action that breaks the world rules.", +} +local sessions = {} +local busy = {} +local sequence = 0 +local run_id = tostring(core.get_us_time()):gsub("[^0-9]", ""):sub(-12) + +local function next_turn() + sequence = sequence + 1 + return sequence +end + +local function safe_id(value) + return tostring(value):gsub("[^A-Za-z0-9._-]", "_"):sub(1, 48) +end + +local function notify(name, message) + core.chat_send_player(name, "[Rin] " .. message) +end + +local function failed(name, err) + busy[name] = nil + notify(name, "Request failed: " .. tostring(err and err.code or "integration_failed")) +end + +local function ensure_session(name, callback) + local existing = sessions[name] + if existing and existing.ready then + callback(existing.id) + return + end + if existing then + table.insert(existing.waiters, callback) + return + end + + local turn = next_turn() + local entry = { + id = "luanti." .. run_id .. "." .. safe_id(name), + ready = false, + waiters = { callback }, + } + sessions[name] = entry + client:create_session({ + protocol_version = rin.PROTOCOL_VERSION, + request_id = "create." .. turn, + session_id = entry.id, + binding = { + game_id = "luanti", + content_id = "rin-npc-example", + content_version = "0.1.0", + content_hash = "sha256:" .. string.rep("0", 64), + }, + seed = turn, + actors = { + { + id = actor_id, + kind = "npc", + display_name = "Rin Guide", + traits = { "observant", "careful" }, + boundaries = { + { + id = "boundary.no-griefing", + description = "Never suggest griefing or bypassing server rules.", + trigger_tags = { "unsafe" }, + response = "refuse", + }, + }, + goals = { + { + id = "goal.help-player", + description = "Help the player make one informed choice.", + priority = 4, + preferred_actions = { "talk" }, + progress = 0, + target_progress = 3, + status = "active", + }, + }, + think_every_ticks = 20, + enabled = true, + }, + }, + }, function(_, err) + local waiters = entry.waiters + entry.waiters = {} + if err then + sessions[name] = nil + for _, waiter in ipairs(waiters) do waiter(nil, err) end + return + end + entry.ready = true + for _, waiter in ipairs(waiters) do waiter(entry.id, nil) end + end) +end + +local function apply_and_commit(name, session_id, turn, tick, job) + local proposal = type(job.proposal) == "table" and job.proposal or {} + local action = type(proposal.action) == "table" and proposal.action or {} + local action_id = tostring(action.id or "") + local line = allowed_actions[action_id] + if type(proposal.proposal_id) ~= "string" then + failed(name, { code = "invalid_response" }) + return + end + local accepted = line ~= nil + local outcome = line or "The game rejected an action outside its allowlist." + + core.after(0, function() + if accepted then notify(name, line) end + client:commit({ + protocol_version = rin.PROTOCOL_VERSION, + session_id = session_id, + request_id = "commit." .. turn, + proposal_id = proposal.proposal_id, + event_id = "outcome." .. turn, + tick = tick, + accepted = accepted, + outcome = outcome, + tags = { "luanti-example", "conversation" }, + }, function(_, err) + busy[name] = nil + if err then failed(name, err) else notify(name, "Turn committed.") end + end) + end) +end + +local function request_turn(name, message) + if busy[name] then + notify(name, "A turn is already running.") + return + end + busy[name] = true + ensure_session(name, function(session_id, session_error) + if session_error or not session_id then failed(name, session_error); return end + local turn = next_turn() + local tick = turn * 3 + client:observe({ + protocol_version = rin.PROTOCOL_VERSION, + session_id = session_id, + request_id = "observe." .. turn, + event_id = "event." .. turn, + tick = tick, + observer_ids = { actor_id }, + source = "luanti-example", + kind = "dialogue", + summary = message, + tags = { "conversation", "player-request" }, + importance = 3, + }, function(_, observe_error) + if observe_error then failed(name, observe_error); return end + client:submit_proposal_job({ + protocol_version = rin.PROTOCOL_VERSION, + session_id = session_id, + request_id = "propose." .. turn, + actor_id = actor_id, + tick = tick + 1, + intent = "Choose one bounded response to the player.", + tags = { "conversation" }, + candidate_actions = { + { id = "talk", kind = "dialogue", description = "offer one concrete hint" }, + { id = "wait", kind = "wait", description = "ask the player to observe first" }, + { id = "refuse", kind = "refuse", description = "decline an unsafe request" }, + }, + }, function(queued, queue_error) + if queue_error then failed(name, queue_error); return end + client:wait_for_proposal(queued.job_id, nil, function(job, job_error) + if job_error then failed(name, job_error); return end + apply_and_commit(name, session_id, turn, tick + 2, job) + end) + end) + end) + end) +end + +core.register_chatcommand("rin_npc", { + params = "[message]", + description = "Ask the example Rin guide for one bounded action.", + func = function(name, param) + local message = tostring(param or ""):gsub("[%z\r\n]", " "):gsub("%s+", " "):sub(1, 300) + if message == "" then message = "The player asked what to do next." end + request_turn(name, message) + return true, "Rin request started." + end, +}) diff --git a/examples/mods/luanti-rin-npc/mod.conf b/examples/mods/luanti-rin-npc/mod.conf new file mode 100644 index 0000000..208301c --- /dev/null +++ b/examples/mods/luanti-rin-npc/mod.conf @@ -0,0 +1,5 @@ +name = rin_npc_example +title = Rin NPC Example +description = Reference Luanti integration for a Rin-backed NPC turn. +author = sunrioa +release = 1 diff --git a/examples/mods/luanti-rin-npc/rin.lua b/examples/mods/luanti-rin-npc/rin.lua new file mode 100644 index 0000000..5b10841 --- /dev/null +++ b/examples/mods/luanti-rin-npc/rin.lua @@ -0,0 +1,339 @@ +local rin = { + PROTOCOL_VERSION = "rin.protocol/v1", + DEFAULT_BASE_URL = "http://127.0.0.1:7374", + DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024, +} + +local Client = {} +Client.__index = Client + +local terminal_job_states = { + succeeded = true, + failed = true, + stale = true, + canceled = true, +} + +local function safe_text(value, maximum, fallback) + local text = tostring(value or ""):gsub("%z", " "):gsub("%s+", " ") + text = text:match("^%s*(.-)%s*$") or "" + if text == "" then text = fallback or "" end + return text:sub(1, maximum) +end + +local function failure(code, message, status, field) + return { + code = safe_text(code, 96, "rin_error"), + message = safe_text(message, 500, "Rin request failed"), + status = tonumber(status) or 0, + field = safe_text(field, 160, ""), + } +end + +local function validate_token(value) + local token = tostring(value or "") + if #token > 4096 or token:find("[%z\r\n]") or token:match("^%s") or token:match("%s$") then + return nil, failure("invalid_token", "Rin token must be a bounded single-line value") + end + return token +end + +local function is_loopback(host) + host = host:lower() + if host == "localhost" or host == "::1" or host == "0:0:0:0:0:0:0:1" then return true end + local first, second, third, fourth = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$") + if not first then return false end + local octets = { tonumber(first), tonumber(second), tonumber(third), tonumber(fourth) } + if octets[1] ~= 127 then return false end + for index = 1, 4 do + if octets[index] < 0 or octets[index] > 255 then return false end + end + return true +end + +local function normalize_base_url(value, token) + local base_url = tostring(value or rin.DEFAULT_BASE_URL):match("^%s*(.-)%s*$") + while base_url:sub(-1) == "/" do base_url = base_url:sub(1, -2) end + local scheme, authority = base_url:match("^(https?)://([^/%?#]+)$") + if not scheme or authority:find("@", 1, true) then + return nil, failure("invalid_base_url", "Rin base URL must be an origin") + end + + local host, port + if authority:sub(1, 1) == "[" then + host, port = authority:match("^%[([^%]]+)%]:(%d+)$") + if not host then host = authority:match("^%[([^%]]+)%]$") end + else + host, port = authority:match("^([^:]+):(%d+)$") + if not host and not authority:find(":", 1, true) then host = authority end + end + if not host or host == "" then + return nil, failure("invalid_base_url", "Rin base URL must be an origin") + end + if port and (tonumber(port) < 1 or tonumber(port) > 65535) then + return nil, failure("invalid_base_url", "Rin base URL has an invalid port") + end + + local loopback = is_loopback(host) + if scheme == "http" and not loopback then + return nil, failure("insecure_base_url", "Remote Rin endpoints must use HTTPS") + end + if not loopback and token == "" then + return nil, failure("missing_token", "Remote Rin endpoints require a token") + end + return base_url +end + +local function path_id(value) + local text = tostring(value or "") + if #text < 1 or #text > 96 then + return nil, failure("invalid_identifier", "Rin path identifier is invalid") + end + for index = 1, #text do + local byte = text:byte(index) + local valid = (byte >= 48 and byte <= 57) or (byte >= 65 and byte <= 90) or + (byte >= 97 and byte <= 122) or byte == 45 or byte == 46 or byte == 95 + if not valid then + return nil, failure("invalid_identifier", "Rin path identifier is invalid") + end + end + return text +end + +local function header_value(headers, wanted) + if type(headers) ~= "table" then return nil end + wanted = wanted:lower() + for key, value in pairs(headers) do + if tostring(key):lower() == wanted then return tostring(value) end + end + return nil +end + +function rin.new(options) + options = options or {} + if type(options) ~= "table" then + return nil, failure("invalid_options", "Rin options must be a table") + end + if type(options.http_fetch) ~= "function" or type(options.json_encode) ~= "function" or + type(options.json_decode) ~= "function" then + return nil, failure("missing_adapter", "http_fetch, json_encode, and json_decode are required") + end + + local token, token_error = validate_token(options.token) + if not token then return nil, token_error end + local base_url, url_error = normalize_base_url(options.base_url, token) + if not base_url then return nil, url_error end + local timeout = tonumber(options.timeout or 5) + local max_response_bytes = tonumber(options.max_response_bytes or rin.DEFAULT_MAX_RESPONSE_BYTES) + if not timeout or timeout ~= timeout or timeout < 0.05 or timeout > 120 then + return nil, failure("invalid_timeout", "Timeout must be between 0.05 and 120 seconds") + end + if not max_response_bytes or max_response_bytes ~= math.floor(max_response_bytes) or + max_response_bytes < 1024 or max_response_bytes > 32 * 1024 * 1024 then + return nil, failure("invalid_response_limit", "Response limit must be between 1 KiB and 32 MiB") + end + + return setmetatable({ + base_url = base_url, + token = token, + timeout = timeout, + max_response_bytes = max_response_bytes, + http_fetch = options.http_fetch, + json_encode = options.json_encode, + json_decode = options.json_decode, + schedule = options.schedule, + now = options.now or os.time, + }, Client) +end + +function Client:_request(method, path, payload, expected_status, callback) + if type(callback) ~= "function" then error("Rin callback is required", 2) end + if type(path) ~= "string" or path:sub(1, 1) ~= "/" or path:find("//", 1, true) or path:find("..", 1, true) then + callback(nil, failure("invalid_path", "Rin request path is invalid")) + return + end + + local body + if payload ~= nil then + if type(payload) ~= "table" then + callback(nil, failure("invalid_request", "Rin payload must be an object")) + return + end + local encoded, value = pcall(self.json_encode, payload) + if not encoded or type(value) ~= "string" then + callback(nil, failure("invalid_request", "Rin payload is not JSON serializable")) + return + end + body = value + end + + local headers = { + ["Accept"] = "application/json", + ["User-Agent"] = "rin-lua/0.5", + } + if body then headers["Content-Type"] = "application/json; charset=utf-8" end + if self.token ~= "" then headers["Authorization"] = "Bearer " .. self.token end + local request = { + url = self.base_url .. path, + method = method, + headers = headers, + body = body, + timeout = self.timeout, + follow_redirects = false, + } + + local delivered = false + local function finish(data, err) + if delivered then return end + delivered = true + callback(data, err) + end + local started, start_error = pcall(self.http_fetch, request, function(response) + if type(response) ~= "table" then + finish(nil, failure("transport_failed", "Rin transport returned an invalid response")) + return + end + local status = tonumber(response.status) + if not status or status ~= math.floor(status) or status < 100 or status > 599 then + finish(nil, failure("transport_failed", "Rin transport did not return a valid status")) + return + end + if status >= 300 and status < 400 then + finish(nil, failure("redirect_rejected", "Rin endpoint attempted to redirect", status)) + return + end + local raw = response.body + if type(raw) ~= "string" then + finish(nil, failure("invalid_response", "Rin response body must be a string", status)) + return + end + local declared_text = header_value(response.headers, "content-length") + local declared = declared_text and tonumber(declared_text) or nil + if declared_text and (not declared or declared < 0 or declared ~= math.floor(declared)) then + finish(nil, failure("invalid_response", "Rin returned an invalid Content-Length", status)) + return + end + if (declared and declared > self.max_response_bytes) or #raw > self.max_response_bytes then + finish(nil, failure("response_too_large", "Rin response exceeds the configured limit", status)) + return + end + + local decoded, envelope = pcall(self.json_decode, raw) + if not decoded or type(envelope) ~= "table" then + if status ~= expected_status then + finish(nil, failure("http_error", "Rin request failed", status)) + else + finish(nil, failure("invalid_response", "Rin returned invalid JSON", status)) + end + return + end + if status ~= expected_status or envelope.ok ~= true then + local detail = type(envelope.error) == "table" and envelope.error or {} + finish(nil, failure(detail.code or "http_error", detail.message or "Rin request failed", status, detail.field)) + return + end + if type(envelope.data) ~= "table" then + finish(nil, failure("invalid_response", "Rin response data must be an object", status)) + return + end + finish(envelope.data, nil) + end) + if not started then + if delivered then error(start_error, 0) end + finish(nil, failure("transport_failed", "Rin transport could not start")) + end +end + +function Client:_post(path, payload, status, callback) + self:_request("POST", path, payload, status or 200, callback) +end + +function Client:health(callback) self:_request("GET", "/health", nil, 200, callback) end +function Client:create_session(payload, callback) self:_post("/v1/session/create", payload, 200, callback) end +function Client:observe(payload, callback) self:_post("/v1/session/observe", payload, 200, callback) end +function Client:propose(payload, callback) self:_post("/v1/agent/propose", payload, 200, callback) end +function Client:submit_proposal_job(payload, callback) self:_post("/v1/jobs/propose", payload, 202, callback) end +function Client:get_proposal_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("GET", "/v1/jobs/" .. id, nil, 200, callback) +end +function Client:cancel_proposal_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("DELETE", "/v1/jobs/" .. id, nil, 200, callback) +end +function Client:submit_generation_job(payload, callback) self:_post("/v1/generation/jobs", payload, 202, callback) end +function Client:get_generation_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("GET", "/v1/generation/jobs/" .. id, nil, 200, callback) +end +function Client:cancel_generation_job(job_id, callback) + local id, err = path_id(job_id) + if not id then callback(nil, err); return end + self:_request("DELETE", "/v1/generation/jobs/" .. id, nil, 200, callback) +end +function Client:commit(payload, callback) self:_post("/v1/action/commit", payload, 200, callback) end +function Client:commit_batch(payload, callback) self:_post("/v1/action/commit-batch", payload, 200, callback) end +function Client:set_actor_activity(payload, callback) self:_post("/v1/session/activity", payload, 200, callback) end +function Client:arbitrate(payload, callback) self:_post("/v1/world/arbitrate", payload, 200, callback) end +function Client:state(payload, callback) self:_post("/v1/session/get", payload, 200, callback) end +function Client:snapshot(payload, callback) self:_post("/v1/session/snapshot", payload, 200, callback) end +function Client:restore(payload, callback) self:_post("/v1/session/restore", payload, 200, callback) end +function Client:timeline(payload, callback) self:_post("/v1/session/timeline", payload, 200, callback) end +function Client:replay(payload, callback) self:_post("/v1/session/replay", payload, 200, callback) end +function Client:due_agents(payload, callback) self:_post("/v1/scheduler/due", payload, 200, callback) end + +function Client:_wait_job(job_id, getter, canceler, options, callback) + options = options or {} + local deadline = tonumber(options.deadline or 25) + local interval = tonumber(options.interval or 0.1) + if type(self.schedule) ~= "function" then + callback(nil, failure("missing_scheduler", "A scheduler is required to wait for jobs")) + return + end + if not deadline or deadline ~= deadline or deadline < 0.05 or deadline > 300 or + not interval or interval ~= interval or interval < 0.01 or interval > 5 then + callback(nil, failure("invalid_polling", "Job deadline or interval is out of range")) + return + end + local expires = self.now() + deadline + local poll + poll = function() + getter(self, job_id, function(job, err) + if err then callback(nil, err); return end + local status = tostring(job.status or "") + if status == "succeeded" then callback(job, nil); return end + if terminal_job_states[status] then + local detail = type(job.error) == "table" and job.error or {} + callback(nil, failure(detail.code or ("job_" .. status), detail.message or ("Rin job ended as " .. status))) + return + end + if status ~= "queued" and status ~= "running" then + callback(nil, failure("invalid_job", "Rin returned an unknown job status")) + return + end + if self.now() >= expires then + canceler(self, job_id, function() end) + callback(nil, failure("job_timeout", "Rin job exceeded its deadline")) + return + end + self.schedule(interval, poll) + end) + end + poll() +end + +function Client:wait_for_proposal(job_id, options, callback) + self:_wait_job(job_id, Client.get_proposal_job, Client.cancel_proposal_job, options, callback) +end + +function Client:wait_for_generation(job_id, options, callback) + local configured = {} + for key, value in pairs(options or {}) do configured[key] = value end + if configured.deadline == nil then configured.deadline = 45 end + self:_wait_job(job_id, Client.get_generation_job, Client.cancel_generation_job, configured, callback) +end + +return rin diff --git a/examples/mods/luanti-rin-npc/settingtypes.txt b/examples/mods/luanti-rin-npc/settingtypes.txt new file mode 100644 index 0000000..f65dfc4 --- /dev/null +++ b/examples/mods/luanti-rin-npc/settingtypes.txt @@ -0,0 +1,2 @@ +# Rin local origin. This example intentionally rejects remote origins and tokens. +rin_npc_example.base_url (Rin local origin) string http://127.0.0.1:7374 From 703f106f38b8e6722edcffb061eaf96c5d64b097 Mon Sep 17 00:00:00 2001 From: sunrioa Date: Thu, 23 Jul 2026 11:10:41 +0800 Subject: [PATCH 4/4] docs: add MIT license and bilingual guides --- LICENSE | 21 + README.en.md | 191 +++++++++ README.md | 16 +- ROADMAP.en.md | 68 ++++ ROADMAP.md | 6 +- SECURITY.en.md | 64 +++ SECURITY.md | 4 +- compat/documentation_test.go | 110 ++++++ docs/README.md | 22 ++ docs/README.zh-CN.md | 21 + docs/architecture.md | 168 ++++++-- docs/architecture.zh-CN.md | 119 ++++++ docs/game-adapters.md | 2 + docs/game-adapters.zh-CN.md | 143 +++++++ docs/living-worlds-v0.5-plan.md | 2 + docs/living-worlds-v0.5-plan.zh-CN.md | 297 ++++++++++++++ docs/model-policy.md | 100 ++--- docs/model-policy.zh-CN.md | 82 ++++ docs/protocol-v1.md | 188 ++++++--- docs/protocol-v1.zh-CN.md | 372 ++++++++++++++++++ docs/rpg-events.md | 2 + docs/rpg-events.zh-CN.md | 108 +++++ docs/sdk-and-mods.md | 6 +- docs/sdk-and-mods.zh-CN.md | 109 +++++ examples/mods/bepinex-rin-npc/README.md | 2 + examples/mods/bepinex-rin-npc/README.zh-CN.md | 24 ++ examples/mods/fabric-rin-npc/README.md | 2 + examples/mods/fabric-rin-npc/README.zh-CN.md | 23 ++ .../src/main/resources/fabric.mod.json | 1 + examples/mods/luanti-rin-npc/README.md | 2 + examples/mods/luanti-rin-npc/README.zh-CN.md | 23 ++ sdk/README.md | 4 + sdk/README.zh-CN.md | 33 ++ sdk/csharp/README.md | 2 + sdk/csharp/README.zh-CN.md | 28 ++ sdk/csharp/Rin.Client/Rin.Client.csproj | 1 + sdk/java/README.md | 2 + sdk/java/README.zh-CN.md | 29 ++ sdk/javascript/README.md | 2 + sdk/javascript/README.zh-CN.md | 23 ++ sdk/javascript/package.json | 1 + sdk/lua/README.md | 2 + sdk/lua/README.zh-CN.md | 31 ++ sdk/python/README.md | 2 + sdk/python/README.zh-CN.md | 22 ++ sdk/python/pyproject.toml | 1 + 46 files changed, 2329 insertions(+), 152 deletions(-) create mode 100644 LICENSE create mode 100644 README.en.md create mode 100644 ROADMAP.en.md create mode 100644 SECURITY.en.md create mode 100644 compat/documentation_test.go create mode 100644 docs/README.md create mode 100644 docs/README.zh-CN.md create mode 100644 docs/architecture.zh-CN.md create mode 100644 docs/game-adapters.zh-CN.md create mode 100644 docs/living-worlds-v0.5-plan.zh-CN.md create mode 100644 docs/model-policy.zh-CN.md create mode 100644 docs/protocol-v1.zh-CN.md create mode 100644 docs/rpg-events.zh-CN.md create mode 100644 docs/sdk-and-mods.zh-CN.md create mode 100644 examples/mods/bepinex-rin-npc/README.zh-CN.md create mode 100644 examples/mods/fabric-rin-npc/README.zh-CN.md create mode 100644 examples/mods/luanti-rin-npc/README.zh-CN.md create mode 100644 sdk/README.zh-CN.md create mode 100644 sdk/csharp/README.zh-CN.md create mode 100644 sdk/java/README.zh-CN.md create mode 100644 sdk/javascript/README.zh-CN.md create mode 100644 sdk/lua/README.zh-CN.md create mode 100644 sdk/python/README.zh-CN.md 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/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 55c896d..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 将“角色思考”和“游戏世界事实”拆开: @@ -81,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)。 离线检查一个会话(会验证日志并只打印脱敏时间线): @@ -98,8 +102,8 @@ go run ./cmd/rin inspect -data ./rin-data -session playthrough-1 -revision 42 - 通用 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)。 -跨语言目录规范、线程边界、凭据策略和 Mod 安装步骤见 [SDK 与 Mod 接入文档](docs/sdk-and-mods.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 @@ -113,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)。 ## 目录 @@ -138,3 +142,7 @@ examples/ Go、Godot、Unity 与 Fabric/BepInEx/Luanti Mod 示例 `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 a2dd55b..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,7 +48,7 @@ - [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 @@ -55,6 +57,6 @@ - [x] 统一 20 路由契约、传输安全约束与跨语言 CI - [x] Fabric、BepInEx 6、Luanti NPC 示例 Mod - [ ] 在真实 Fabric/BepInEx/Luanti 游戏版本中完成人工安装与交互验收 -- [ ] 发布 SDK 或 Mod 前由维护者选择并添加仓库许可证 +- [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/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 index 3c22e9c..8cf8968 100644 --- a/docs/sdk-and-mods.md +++ b/docs/sdk-and-mods.md @@ -1,5 +1,7 @@ # 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. @@ -110,5 +112,5 @@ both Lua 5.1 and 5.4; the other jobs use each SDK's minimum supported runtime. The examples were written for Rin and do not copy implementation code from those projects. Links document host lifecycle, metadata, and transport APIs. -This repository does not currently contain a license file, so choose and add -one before distributing SDK packages or example mods. +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/README.md b/examples/mods/bepinex-rin-npc/README.md index 0dde3aa..7ca7c44 100644 --- a/examples/mods/bepinex-rin-npc/README.md +++ b/examples/mods/bepinex-rin-npc/README.md @@ -1,5 +1,7 @@ # 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 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 index 0222823..d51fbee 100644 --- a/examples/mods/fabric-rin-npc/README.md +++ b/examples/mods/fabric-rin-npc/README.md @@ -1,5 +1,7 @@ # 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. 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/resources/fabric.mod.json b/examples/mods/fabric-rin-npc/src/main/resources/fabric.mod.json index ff5a805..31ea752 100644 --- a/examples/mods/fabric-rin-npc/src/main/resources/fabric.mod.json +++ b/examples/mods/fabric-rin-npc/src/main/resources/fabric.mod.json @@ -5,6 +5,7 @@ "name": "Rin NPC Example", "description": "Reference Fabric integration for a Rin-backed NPC turn.", "authors": ["sunrioa"], + "license": "MIT", "environment": "server", "entrypoints": { "main": ["io.github.sunrioa.rin.example.RinNpcMod"] diff --git a/examples/mods/luanti-rin-npc/README.md b/examples/mods/luanti-rin-npc/README.md index 2700354..753d622 100644 --- a/examples/mods/luanti-rin-npc/README.md +++ b/examples/mods/luanti-rin-npc/README.md @@ -1,5 +1,7 @@ # Luanti Rin NPC example +[English](README.md) | [简体中文](README.zh-CN.md) + This is a complete server-side Luanti mod. The included `rin.lua` is a vendored copy of `sdk/lua/rin.lua`; the repository test requires both copies to match. diff --git a/examples/mods/luanti-rin-npc/README.zh-CN.md b/examples/mods/luanti-rin-npc/README.zh-CN.md new file mode 100644 index 0000000..1f97550 --- /dev/null +++ b/examples/mods/luanti-rin-npc/README.zh-CN.md @@ -0,0 +1,23 @@ +# Luanti Rin NPC 示例 + +[English](README.md) | [简体中文](README.zh-CN.md) + +这是完整的 Luanti 服务器 Mod。内置 `rin.lua` 是 `sdk/lua/rin.lua` 的 +Vendored Copy;仓库测试要求两份文件完全一致。 + +1. 把该目录复制到 Luanti `mods` 或世界 `worldmods` 目录。 +2. 在 `minetest.conf` 中把 `rin_npc_example` 加入 `secure.http_mods`。 +3. 在 `http://127.0.0.1:7374` 启动 Rin,启用 Mod 并重启世界。 +4. 在聊天中执行 `/rin_npc` 或 `/rin_npc your message`。 + +Mod 只在模块作用域调用 `core.request_http_api()`,把返回 API 保持为 local, +通过 `HTTPApiTable.fetch` 异步请求,并用 `core.after` 调度轮询。它只把 +`talk`、`wait` 和 `refuse` 映射到游戏拥有的固定效果,再 Commit 结果。 + +Luanti HTTP 实现会跟随重定向,而 Lua API 没有单请求关闭开关。因此示例 +只接受显式 loopback HTTP Origin,并拒绝 Authorization Header;没有更 +严格的原生 Transport 时,不要把它改为连接经过鉴权的远程 Rin。 + +官方 HTTP API:https://docs.luanti.org/for-creators/api/http-api/ + +官方 Lua API 源码:https://github.com/luanti-org/luanti/blob/master/doc/lua_api.md diff --git a/sdk/README.md b/sdk/README.md index 2b99112..ace7e86 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -1,5 +1,7 @@ # Rin SDKs +[English](README.md) | [简体中文](README.zh-CN.md) + These source SDKs expose the same `rin.protocol/v1` HTTP boundary without moving game authority into the client library. @@ -28,3 +30,5 @@ Game-specific examples live under [`examples/mods`](../examples/mods). They show where host events enter Rin and where the game validates and applies a proposal. They are integration templates, not universal patches for every game version. + +The SDK source is released under the [MIT License](../LICENSE). diff --git a/sdk/README.zh-CN.md b/sdk/README.zh-CN.md new file mode 100644 index 0000000..878370f --- /dev/null +++ b/sdk/README.zh-CN.md @@ -0,0 +1,33 @@ +# Rin SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +这些源码 SDK 暴露同一个 `rin.protocol/v1` HTTP 边界,不会把游戏权威移动 +到客户端库。 + +| 语言 | 运行时 | JSON | 异步建议 | +| --- | --- | --- | --- | +| Python | 3.9+ | 标准库 | 实时游戏从 Worker 调用 | +| JavaScript | Node 18+ / 现代浏览器宿主 | 内置 | 基于 Promise | +| C# | .NET 6+ | `System.Text.Json` | 基于 `Task` | +| Java | 17+ | 宿主提供 JSON 文本 | 基于 `CompletableFuture` | +| Lua | 5.1+ 宿主 | 注入 Codec 与 Transport | 基于 Callback | + +所有客户端遵循以下规则: + +- 只对显式 loopback Origin 接受明文 HTTP; +- 远程 Origin 要求 HTTPS 和 Bearer Token; +- 拒绝重定向; +- 强制请求超时和响应大小限制; +- 错误只暴露有界 Rin Code,不暴露供应商正文或凭据; +- Proposal 保持 Pending,直到游戏应用并 Commit。 + +SDK 有意采用源码优先方式,尚未发布到 PyPI、npm、NuGet 或 Maven Central。 +Vendor 时应固定本仓库 Revision。路由兼容性由 +[`conformance/routes.json`](conformance/routes.json) 定义。 + +游戏专用示例位于 [`examples/mods`](../examples/mods)。它们展示宿主事件 +如何进入 Rin,以及游戏在何处验证并应用 Proposal。它们是接入模板,不是 +适用于每个游戏版本的通用补丁。 + +SDK 源码按 [MIT License](../LICENSE) 发布。 diff --git a/sdk/csharp/README.md b/sdk/csharp/README.md index b219a4a..a13ed34 100644 --- a/sdk/csharp/README.md +++ b/sdk/csharp/README.md @@ -1,5 +1,7 @@ # Rin C# SDK +[English](README.md) | [简体中文](README.zh-CN.md) + `Rin.Client` targets .NET 6+ and uses only `HttpClient` and `System.Text.Json`. Keep one client for the lifetime of the plugin or game. diff --git a/sdk/csharp/README.zh-CN.md b/sdk/csharp/README.zh-CN.md new file mode 100644 index 0000000..e473b85 --- /dev/null +++ b/sdk/csharp/README.zh-CN.md @@ -0,0 +1,28 @@ +# Rin C# SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +`Rin.Client` 面向 .NET 6+,只使用 `HttpClient` 和 `System.Text.Json`。 +在插件或游戏生命周期内复用一个 Client。 + +```csharp +using Rin.Client; + +using var rin = new RinClient(new RinClientOptions +{ + BaseUrl = "http://127.0.0.1:7374", + Token = Environment.GetEnvironmentVariable("RIN_TOKEN") ?? "", +}); + +var health = await rin.HealthAsync(); +Console.WriteLine(health.GetProperty("status").GetString()); +``` + +构建并运行源码测试: + +```bash +dotnet run --project sdk/csharp/Rin.Client.Tests/Rin.Client.Tests.csproj +``` + +Unity 和 BepInEx 调用方必须在渲染循环外 `await`,验证结果后再切回 Unity +主线程操作 GameObject。 diff --git a/sdk/csharp/Rin.Client/Rin.Client.csproj b/sdk/csharp/Rin.Client/Rin.Client.csproj index 726c0d4..5c3c3c0 100644 --- a/sdk/csharp/Rin.Client/Rin.Client.csproj +++ b/sdk/csharp/Rin.Client/Rin.Client.csproj @@ -7,5 +7,6 @@ Rin.Game.Client 0.5.0 Dependency-free .NET client for the Rin game agent runtime. + MIT diff --git a/sdk/java/README.md b/sdk/java/README.md index bd1f6df..a80511c 100644 --- a/sdk/java/README.md +++ b/sdk/java/README.md @@ -1,5 +1,7 @@ # Rin Java SDK +[English](README.md) | [简体中文](README.zh-CN.md) + Requires Java 17+. Transport uses the JDK `HttpClient`; JSON is injected so a game can reuse its existing Gson, Jackson, or engine codec without creating a second dependency graph. diff --git a/sdk/java/README.zh-CN.md b/sdk/java/README.zh-CN.md new file mode 100644 index 0000000..9463ca1 --- /dev/null +++ b/sdk/java/README.zh-CN.md @@ -0,0 +1,29 @@ +# Rin Java SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +要求 Java 17+。Transport 使用 JDK `HttpClient`;JSON 通过接口注入,因此 +游戏可以复用已有 Gson、Jackson 或引擎 Codec,不会产生第二套依赖图。 + +```java +JsonCodec codec = new GsonJsonCodec(gameGson); +RinClient rin = new RinClient( + "http://127.0.0.1:7374", + System.getenv().getOrDefault("RIN_TOKEN", ""), + Duration.ofSeconds(5), + RinClient.DEFAULT_MAX_RESPONSE_BYTES, + codec +); + +rin.health().thenAccept(data -> System.out.println(data.get("status"))); +``` + +`JsonCodec.decodeObject` 必须拒绝非 Object 根节点。调用返回 +`CompletableFuture`;Minecraft 或其他引擎状态修改必须重新安排到引擎 +拥有的游戏线程。 + +使用 JDK 17 编译 SDK 和无依赖 Smoke Test: + +```bash +make test-sdk-java +``` diff --git a/sdk/javascript/README.md b/sdk/javascript/README.md index 4669afb..4238020 100644 --- a/sdk/javascript/README.md +++ b/sdk/javascript/README.md @@ -1,5 +1,7 @@ # Rin JavaScript SDK +[English](README.md) | [简体中文](README.zh-CN.md) + Requires Node.js 18+ or a host that implements the standard Fetch API. The package has no runtime dependencies and includes TypeScript declarations. diff --git a/sdk/javascript/README.zh-CN.md b/sdk/javascript/README.zh-CN.md new file mode 100644 index 0000000..46adc18 --- /dev/null +++ b/sdk/javascript/README.zh-CN.md @@ -0,0 +1,23 @@ +# Rin JavaScript SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +要求 Node.js 18+ 或实现标准 Fetch API 的宿主。Package 没有运行时依赖, +并包含 TypeScript Declaration。 + +```js +import { RinClient } from "@sunrioa/rin-sdk"; + +const rin = new RinClient("http://127.0.0.1:7374"); +console.log(await rin.health()); +``` + +从当前 Checkout 直接运行: + +```bash +node sdk/javascript/examples/quickstart.js +cd sdk/javascript && npm test +``` + +调用基于 Promise。只有回到引擎主线程并用本地白名单验证 Proposal 后, +才能应用引擎状态。 diff --git a/sdk/javascript/package.json b/sdk/javascript/package.json index cb31634..aae3348 100644 --- a/sdk/javascript/package.json +++ b/sdk/javascript/package.json @@ -15,5 +15,6 @@ "engines": { "node": ">=18" }, + "license": "MIT", "private": true } diff --git a/sdk/lua/README.md b/sdk/lua/README.md index 6918ff5..c3af587 100644 --- a/sdk/lua/README.md +++ b/sdk/lua/README.md @@ -1,5 +1,7 @@ # Rin Lua SDK +[English](README.md) | [简体中文](README.zh-CN.md) + The client supports Lua 5.1+ and does not assume a particular engine. Supply three adapters: diff --git a/sdk/lua/README.zh-CN.md b/sdk/lua/README.zh-CN.md new file mode 100644 index 0000000..d5076ea --- /dev/null +++ b/sdk/lua/README.zh-CN.md @@ -0,0 +1,31 @@ +# Rin Lua SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +客户端支持 Lua 5.1+,不假设具体引擎。需要提供三个 Adapter: + +- `http_fetch(request, callback)` 返回 `{status, body, headers}`,并且必须 + 遵守 `follow_redirects = false`; +- `json_encode(table)` 和 `json_decode(string)` 使用引擎的 JSON Codec; +- 可选 `schedule(seconds, callback)` 和单调 `now()` 可在不阻塞游戏循环 + 的情况下轮询 Job。未提供 `now` 时使用可移植但分辨率较低的 `os.time` + 墙上时钟。 + +```lua +local rin = dofile("rin.lua") +local client, err = rin.new({ + base_url = "http://127.0.0.1:7374", + http_fetch = engine_http_fetch, + json_encode = engine_json_encode, + json_decode = engine_json_decode, + schedule = engine_schedule, +}) +assert(client, err and err.message) + +client:health(function(data, request_error) + if request_error then print(request_error.code) else print(data.status) end +end) +``` + +Callback 约定为 `(data, error)`。网络工作保持异步;只能从引擎拥有的线程 +应用白名单动作。 diff --git a/sdk/python/README.md b/sdk/python/README.md index 1c92e63..784e8bd 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -1,5 +1,7 @@ # Rin Python SDK +[English](README.md) | [简体中文](README.zh-CN.md) + Requires Python 3.9+ and has no third-party dependencies. ```python diff --git a/sdk/python/README.zh-CN.md b/sdk/python/README.zh-CN.md new file mode 100644 index 0000000..710f610 --- /dev/null +++ b/sdk/python/README.zh-CN.md @@ -0,0 +1,22 @@ +# Rin Python SDK + +[English](README.md) | [简体中文](README.zh-CN.md) + +要求 Python 3.9+,没有第三方依赖。 + +```python +from rin_sdk import PROTOCOL_VERSION, RinClient + +client = RinClient("http://127.0.0.1:7374") +health = client.health() +``` + +开发时从当前 Checkout 安装并测试: + +```bash +python3 -m pip install -e sdk/python +python3 -m unittest discover -s sdk/python/tests -p 'test_*.py' +``` + +客户端是同步的。桌面工具和回合制服务器可以直接调用;实时游戏应在自己的 +Worker 系统中运行请求,只把返回的普通 Dictionary 切回游戏线程。 diff --git a/sdk/python/pyproject.toml b/sdk/python/pyproject.toml index 669ebc0..147fce8 100644 --- a/sdk/python/pyproject.toml +++ b/sdk/python/pyproject.toml @@ -7,6 +7,7 @@ name = "rin-game-sdk" version = "0.5.0" description = "Dependency-free Python client for the Rin game agent runtime" requires-python = ">=3.9" +license = {text = "MIT"} [tool.setuptools] package-dir = {"" = "src"}