Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

Rin 是一个面向游戏角色的轻量级 Agent Runtime。它作为游戏进程旁边的 Sidecar 运行,也可以直接作为 Go 包嵌入工具链。核心只使用 Go 标准库,不绑定视觉小说、RPG 引擎或任何模型供应商。

当前版本:`v0.4.0`
当前开发线:`v0.5.0`(Living Worlds)

## 它解决什么

Expand All @@ -18,6 +18,8 @@ Rin 将“角色思考”和“游戏世界事实”拆开:
- 通用结构化 Generation Job 让剧情、任务描述和受限对白也经过 Sidecar,而不是让游戏保存供应商 Key。
- 模型不可用时自动回退确定性 Policy,并用 `policy_source` 标明来源。
- Ren'Py、Godot 4 和 Unity 适配器保持同一套 observe / propose / commit 权威边界。
- 可选分层记忆、冲突认知、候选小目标、区域休眠和确定性多角色仲裁均由 Session feature 显式启用。
- 脱敏 Timeline、指定 revision Replay 和 `rin inspect` 让长流程角色行为可以复现和审计。

这套边界既适用于 Ren'Py 角色,也可用于 RPG NPC、队友、经营模拟居民和其他 AI 游戏实体。

Expand Down Expand Up @@ -66,15 +68,27 @@ go run ./cmd/rin serve
| `GET` | `/v1/generation/jobs/{job_id}` | 查询生成任务与安全元数据 |
| `DELETE` | `/v1/generation/jobs/{job_id}` | 取消生成任务 |
| `POST` | `/v1/action/commit` | 接受或拒绝提案并记录结果 |
| `POST` | `/v1/action/commit-batch` | 原子提交同一世界版本的多角色结果 |
| `POST` | `/v1/session/activity` | 更新角色区域与 awake/dormant 状态 |
| `POST` | `/v1/world/arbitrate` | 对并行角色提案进行确定性冲突仲裁 |
| `POST` | `/v1/scheduler/due` | 查询当前 tick 应思考的角色 |
| `POST` | `/v1/session/get` | 读取会话状态 |
| `POST` | `/v1/session/snapshot` | 创建并原子保存快照 |
| `POST` | `/v1/session/restore` | 校验并恢复快照 |
| `POST` | `/v1/session/timeline` | 读取脱敏事件时间线 |
| `POST` | `/v1/session/replay` | 重放到指定 revision 并返回 Snapshot |

所有写请求都带调用方生成的 `request_id`,重复请求返回相同结果,不重复修改状态。同一 ID 被用于不同操作时返回冲突。

完整字段和错误语义见 [协议文档](docs/protocol-v1.md),职责边界见 [架构文档](docs/architecture.md)。

离线检查一个会话(会验证日志并只打印脱敏时间线):

```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
```

## 游戏引擎适配

- Ren'Py:纯标准库 Python 客户端、`renpy.invoke_in_thread` 桥接与 authored 离线回退。
Expand Down Expand Up @@ -116,6 +130,6 @@ examples/ Go、Godot 与 Unity 最小接入示例

## 当前有意不做

`v0.4.0` 不引入供应商 SDK、向量数据库、ORM、WebSocket、动态插件执行或任意文件访问。在线模型仍是可选能力;即使供应商或 Sidecar 不可用,游戏仍可继续使用确定性策略或自己的离线剧情。
`v0.5.0` 不引入供应商 SDK、向量数据库、ORM、WebSocket、动态插件执行或任意文件访问。在线模型仍是可选能力;即使供应商或 Sidecar 不可用,游戏仍可继续使用确定性策略或自己的离线剧情。

后续工作记录在 [ROADMAP.md](ROADMAP.md)。
13 changes: 8 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,13 @@

## v0.5.0 - Living worlds

- [ ] 分层记忆总结与可解释遗忘
- [ ] 角色私有认知、传闻来源和事实冲突
- [ ] 自主小目标与 Game Master 仲裁
- [ ] 多 Agent 批处理与区域休眠
- [ ] 人工调试时间线和决定回放工具
- [x] 分层记忆总结与可解释遗忘
- [x] 角色私有认知、传闻来源和事实冲突
- [x] 自主小目标与 Game Master 仲裁
- [x] 多 Agent 批处理与区域休眠
- [x] 人工调试时间线和决定回放工具

详细协议、兼容策略、阶段提交与验收矩阵见
[`docs/living-worlds-v0.5-plan.md`](docs/living-worlds-v0.5-plan.md)。

每个阶段继续保持一个原则:模型可以提出意图和表达,游戏引擎决定现实发生了什么。
17 changes: 16 additions & 1 deletion adapters/renpy/rin_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,15 @@ def cancel_generation_job(self, job_id: str) -> Dict[str, Any]:
def commit(self, request: Dict[str, Any]) -> Dict[str, Any]:
return self._request("POST", "/v1/action/commit", request)

def commit_batch(self, request: Dict[str, Any]) -> Dict[str, Any]:
return self._request("POST", "/v1/action/commit-batch", request)

def set_actor_activity(self, request: Dict[str, Any]) -> Dict[str, Any]:
return self._request("POST", "/v1/session/activity", request)

def arbitrate(self, request: Dict[str, Any]) -> Dict[str, Any]:
return self._request("POST", "/v1/world/arbitrate", request)

def state(self, request: Dict[str, Any]) -> Dict[str, Any]:
return self._request("POST", "/v1/session/get", request)

Expand All @@ -224,6 +233,12 @@ def snapshot(self, request: Dict[str, Any]) -> Dict[str, Any]:
def restore(self, request: Dict[str, Any]) -> Dict[str, Any]:
return self._request("POST", "/v1/session/restore", request)

def timeline(self, request: Dict[str, Any]) -> Dict[str, Any]:
return self._request("POST", "/v1/session/timeline", request)

def replay(self, request: Dict[str, Any]) -> Dict[str, Any]:
return self._request("POST", "/v1/session/replay", request)

def due_agents(self, request: Dict[str, Any]) -> Dict[str, Any]:
return self._request("POST", "/v1/scheduler/due", request)

Expand Down Expand Up @@ -419,7 +434,7 @@ def _request(
expected_statuses: Sequence[int] = (200,),
) -> Dict[str, Any]:
body = None
headers = {"Accept": "application/json", "User-Agent": "rin-renpy/0.4"}
headers = {"Accept": "application/json", "User-Agent": "rin-renpy/0.5"}
if payload is not None:
if not isinstance(payload, dict):
raise RinProtocolError("invalid_request", "Rin request payload must be an object")
Expand Down
16 changes: 16 additions & 0 deletions adapters/renpy/test_rin_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,13 @@ def __init__(self):
self.generation_polls = 0
self.authorization = ""
self.last_payload = None
self.last_path = ""

def open(self, request, timeout):
self.authorization = request.get_header("Authorization", "")
path = request.full_url.split("//", 1)[-1]
path = path[path.find("/"):] if "/" in path else "/"
self.last_path = path
if request.data is not None:
self.last_payload = json.loads(request.data.decode("utf-8"))
if request.get_method() == "POST" and path == "/v1/jobs/propose":
Expand Down Expand Up @@ -147,6 +149,20 @@ def _client_with_opener(token=""):


class RinClientTests(unittest.TestCase):
def test_living_world_routes(self):
client = _client_with_opener()
cases = (
(client.commit_batch, "/v1/action/commit-batch"),
(client.set_actor_activity, "/v1/session/activity"),
(client.arbitrate, "/v1/world/arbitrate"),
(client.timeline, "/v1/session/timeline"),
(client.replay, "/v1/session/replay"),
)
for method, expected_path in cases:
with self.subTest(path=expected_path):
method({"protocol_version": rin_client.PROTOCOL_VERSION})
self.assertEqual(client._opener.last_path, expected_path)

def test_async_proposal_flow_and_token(self):
client = _client_with_opener("fixture-token")
result = client.propose_with_fallback(
Expand Down
137 changes: 137 additions & 0 deletions cmd/rin/inspect.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package main

import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"

"github.com/sunrioa/rin/policy"
"github.com/sunrioa/rin/protocol"
rintime "github.com/sunrioa/rin/runtime"
"github.com/sunrioa/rin/store"
)

type inspectOutput struct {
ProtocolVersion string `json:"protocol_version"`
SessionID string `json:"session_id"`
Binding protocol.Binding `json:"binding"`
Revision uint64 `json:"revision"`
WorldRevision uint64 `json:"world_revision,omitempty"`
Tick int64 `json:"tick"`
Features []string `json:"features,omitempty"`
ActorCount int `json:"actor_count"`
PendingProposals int `json:"pending_proposals"`
ArbitrationCount int `json:"arbitration_count"`
StateHash string `json:"state_hash"`
Timeline []protocol.TimelineEntry `json:"timeline,omitempty"`
}

func runInspect(arguments []string, output io.Writer) error {
flags := flag.NewFlagSet("rin inspect", flag.ContinueOnError)
flags.SetOutput(io.Discard)
dataDirectory := flags.String("data", envOr("RIN_DATA_DIR", "./rin-data"), "event and snapshot directory")
sessionID := flags.String("session", "", "session identifier")
revision := flags.Uint64("revision", 0, "event-log revision; zero selects current")
timelineLimit := flags.Int("timeline-limit", 50, "number of redacted timeline entries (0-256)")
if err := flags.Parse(arguments); err != nil {
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("unexpected arguments: %v", flags.Args())
}
if *sessionID == "" {
return errors.New("-session is required")
}
if *timelineLimit < 0 || *timelineLimit > 256 {
return errors.New("-timeline-limit must be between 0 and 256")
}
fileStore, err := store.OpenFile(*dataDirectory)
if err != nil {
return err
}
engine, err := rintime.Open(fileStore, policy.Deterministic{})
if err != nil {
return err
}
var snapshot protocol.Snapshot
if *revision == 0 {
state, stateErr := engine.State(protocol.SessionRequest{ProtocolVersion: protocol.Version, SessionID: *sessionID})
if stateErr != nil {
return stateErr
}
snapshot, err = rintime.SnapshotOf(state)
} else {
snapshot, err = engine.Replay(protocol.ReplayRequest{
ProtocolVersion: protocol.Version, SessionID: *sessionID, Revision: *revision,
})
}
if err != nil {
return err
}
timeline, err := inspectTimeline(engine, *sessionID, snapshot.State.Revision, *timelineLimit)
if err != nil {
return err
}
pending := 0
for _, proposal := range snapshot.State.Proposals {
if proposal.Status == "pending" {
pending++
}
}
result := inspectOutput{
ProtocolVersion: protocol.Version, SessionID: snapshot.State.SessionID,
Binding: snapshot.State.Binding, Revision: snapshot.State.Revision,
WorldRevision: snapshot.State.WorldRevision, Tick: snapshot.State.Tick,
Features: append([]string(nil), snapshot.State.Features...),
ActorCount: len(snapshot.State.Actors), PendingProposals: pending,
ArbitrationCount: len(snapshot.State.Arbitrations), StateHash: snapshot.StateHash,
Timeline: timeline,
}
encoder := json.NewEncoder(output)
encoder.SetIndent("", " ")
return encoder.Encode(result)
}

func inspectTimeline(engine *rintime.Engine, sessionID string, revision uint64, limit int) ([]protocol.TimelineEntry, error) {
if limit == 0 {
return nil, nil
}
entries := make([]protocol.TimelineEntry, 0, limit)
after := uint64(0)
for {
pageStart := after
page, err := engine.Timeline(protocol.TimelineRequest{
ProtocolVersion: protocol.Version, SessionID: sessionID,
AfterRevision: after, Limit: 256,
})
if err != nil {
return nil, err
}
reachedTarget := false
for _, entry := range page.Entries {
if entry.Sequence > revision {
reachedTarget = true
break
}
entries = append(entries, entry)
if len(entries) > limit {
entries = append([]protocol.TimelineEntry(nil), entries[len(entries)-limit:]...)
}
after = entry.Sequence
if entry.Sequence == revision {
reachedTarget = true
break
}
}
if reachedTarget || !page.HasMore {
break
}
if page.NextAfterRevision <= pageStart {
return nil, errors.New("timeline pagination did not advance")
}
after = page.NextAfterRevision
}
return entries, nil
}
61 changes: 61 additions & 0 deletions cmd/rin/inspect_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"bytes"
"encoding/json"
"strings"
"testing"

"github.com/sunrioa/rin/policy"
"github.com/sunrioa/rin/protocol"
rintime "github.com/sunrioa/rin/runtime"
"github.com/sunrioa/rin/store"
)

func TestRunInspectPrintsVerifiedRedactedSummary(t *testing.T) {
directory := t.TempDir()
fileStore, err := store.OpenFile(directory)
if err != nil {
t.Fatal(err)
}
engine, err := rintime.Open(fileStore, policy.Deterministic{})
if err != nil {
t.Fatal(err)
}
_, err = engine.CreateSession(protocol.CreateSessionRequest{
ProtocolVersion: protocol.Version, RequestID: "create.inspect", SessionID: "session.inspect",
Binding: protocol.Binding{GameID: "game.inspect", ContentID: "base", ContentVersion: "1", ContentHash: "hash"},
Actors: []protocol.ActorSeed{{
ID: "npc.inspect", Kind: "npc", DisplayName: "Inspector",
ThinkEveryTicks: 1, Enabled: true,
}},
})
if err != nil {
t.Fatal(err)
}
_, err = engine.Observe(protocol.ObserveRequest{
ProtocolVersion: protocol.Version, SessionID: "session.inspect", RequestID: "observe.inspect",
EventID: "event.inspect", Tick: 1, ObserverIDs: []string{"npc.inspect"}, Source: "game",
Kind: "dialogue", Summary: "PRIVATE_SUMMARY", Quote: "PRIVATE_QUOTE", Importance: 3,
})
if err != nil {
t.Fatal(err)
}

var output bytes.Buffer
if err := runInspect([]string{
"-data", directory, "-session", "session.inspect", "-revision", "1", "-timeline-limit", "10",
}, &output); err != nil {
t.Fatal(err)
}
if strings.Contains(output.String(), "PRIVATE_SUMMARY") || strings.Contains(output.String(), "PRIVATE_QUOTE") {
t.Fatalf("inspect output leaked story text: %s", output.String())
}
var result inspectOutput
if err := json.Unmarshal(output.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.SessionID != "session.inspect" || result.Revision != 1 || result.ActorCount != 1 || len(result.Timeline) != 1 {
t.Fatalf("unexpected inspect output: %+v", result)
}
}
3 changes: 3 additions & 0 deletions cmd/rin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ func run(arguments []string) error {
fmt.Println(version)
return nil
}
if len(arguments) > 0 && arguments[0] == "inspect" {
return runInspect(arguments[1:], os.Stdout)
}
if len(arguments) > 0 && arguments[0] == "serve" {
arguments = arguments[1:]
}
Expand Down
9 changes: 9 additions & 0 deletions compat/adapter_examples_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ func TestEngineExamplesPreserveAsyncAuthorityBoundary(t *testing.T) {
"HTTPClient.METHOD_DELETE",
"\"committable\": false",
"\"policy_source\": \"adapter-offline\"",
"/v1/session/activity",
"/v1/world/arbitrate",
"/v1/session/timeline",
},
forbidden: []string{"OS.execute", "FileAccess.open", "Thread.wait_to_finish"},
},
Expand All @@ -36,6 +39,9 @@ func TestEngineExamplesPreserveAsyncAuthorityBoundary(t *testing.T) {
"WaitForSecondsRealtime",
"committable = false",
"policy_source = \"adapter-offline\"",
"/v1/session/activity",
"/v1/world/arbitrate",
"/v1/session/timeline",
},
forbidden: []string{"Thread.Sleep", ".Wait()", "Process.Start"},
},
Expand All @@ -47,6 +53,9 @@ func TestEngineExamplesPreserveAsyncAuthorityBoundary(t *testing.T) {
"class BackgroundProposalRegistry",
"committable\": False",
"adapter-offline",
"/v1/session/activity",
"/v1/world/arbitrate",
"/v1/session/timeline",
},
forbidden: []string{"import requests", "subprocess", "os.system"},
},
Expand Down
Loading
Loading