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: 12 additions & 6 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ Rin separates character reasoning from game-world facts:
- 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`.
relationships. The game validates and applies or rejects it, then uses
`commit` to report the actual outcome to Rin.
- 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
Expand All @@ -37,6 +37,11 @@ Rin separates character reasoning from game-world facts:
the game.
- If a model is unavailable, Rin falls back to a deterministic policy and
identifies the source with `policy_source`.

The apply-then-report lifecycle and late-outcome merge require new Sessions to
request `outcome-reporting-v1`. Sessions without that Feature retain the
legacy pre-commit/staleness behavior for replay compatibility.

- 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
Expand Down Expand Up @@ -99,8 +104,8 @@ additional persistence allowlist.
| `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/action/commit` | Record an outcome the game already applied or rejected |
| `POST` | `/v1/action/commit-batch` | Atomically record multi-actor outcomes from one original 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 |
Expand All @@ -115,8 +120,9 @@ 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.
error semantics, the [architecture guide](docs/architecture.md) for
responsibility boundaries, and [action outcome reporting](docs/outcome-reporting.md)
for application, recording, and retry order.

Inspect a session offline. The command verifies the log and prints only a
redacted timeline:
Expand Down
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,17 @@ Rin 将“角色思考”和“游戏世界事实”拆开:

- 游戏提交角色实际看见的 `Observation`,而不是把整个存档交给模型。
- 角色根据记忆、目标、边界和当前允许动作生成 `ActionProposal`。
- 提案不能直接改变剧情、背包、任务或关系;游戏验证并调用 `commit` 后才生效。
- 提案不能直接改变剧情、背包、任务或关系;游戏验证并应用或拒绝后,
用 `commit` 向 Rin 回报实际结果。
- 每次状态变化写入带哈希链的 JSONL 事件日志,可重放、可检查。
- 快照绑定 `game/content/version/hash`,篡改或串档会被拒绝。
- 多 NPC 通过 tick 调度按需思考,不需要每帧调用模型。
- 在线模型通过异步 Job 预取,慢请求、取消和状态过期不会冻结游戏主线程。
- 通用结构化 Generation Job 让剧情、任务描述和受限对白也经过 Sidecar,而不是让游戏保存供应商 Key。
- 模型不可用时自动回退确定性 Policy,并用 `policy_source` 标明来源。
- “游戏先处理、再回报”以及延迟结果合并要求新 Session 显式请求
`outcome-reporting-v1`;未启用的 Session 为保持重放兼容,继续使用旧版
Commit/stale 语义。
- Ren'Py、Godot 4 和 Unity 适配器保持同一套 observe / propose / commit 权威边界。
- Python、JavaScript、C#、Java、Lua SDK 与 Fabric、BepInEx、Luanti 示例 Mod 提供快速接入层。
- 可选分层记忆、冲突认知、候选小目标、区域休眠和确定性多角色仲裁均由 Session feature 显式启用。
Expand Down Expand Up @@ -75,8 +79,8 @@ go run ./cmd/rin serve
| `POST` | `/v1/generation/jobs` | 异步提交结构化 JSON 生成任务 |
| `GET` | `/v1/generation/jobs/{job_id}` | 查询生成任务与安全元数据 |
| `DELETE` | `/v1/generation/jobs/{job_id}` | 取消生成任务 |
| `POST` | `/v1/action/commit` | 接受或拒绝提案并记录结果 |
| `POST` | `/v1/action/commit-batch` | 原子提交同一世界版本的多角色结果 |
| `POST` | `/v1/action/commit` | 记录游戏已经应用或拒绝的实际结果 |
| `POST` | `/v1/action/commit-batch` | 原子记录同一原始世界版本的多角色结果 |
| `POST` | `/v1/session/activity` | 更新角色区域与 awake/dormant 状态 |
| `POST` | `/v1/world/arbitrate` | 对并行角色提案进行确定性冲突仲裁 |
| `POST` | `/v1/scheduler/due` | 查询当前 tick 应思考的角色 |
Expand All @@ -88,7 +92,9 @@ go run ./cmd/rin serve

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

完整字段和错误语义见 [协议文档](docs/protocol-v1.zh-CN.md),职责边界见 [架构文档](docs/architecture.zh-CN.md)。
完整字段和错误语义见 [协议文档](docs/protocol-v1.zh-CN.md),职责边界见
[架构文档](docs/architecture.zh-CN.md),应用、结果记账和重试顺序见
[动作结果记账](docs/outcome-reporting.zh-CN.md)。

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

Expand Down
89 changes: 85 additions & 4 deletions adapters/renpy/rin_bridge.rpy
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ init -30 python:
_RIN_REGISTRY = None
_RIN_CONFIG_FINGERPRINT = None
_RIN_LOCAL_RESULTS = {}
_RIN_UNRESOLVED_ATTEMPTS = {}

def _rin_env_enabled(name, default="0"):
value = os.environ.get(name, default).strip().lower()
Expand Down Expand Up @@ -91,8 +92,31 @@ init -30 python:
)),
}

def rin_schedule_proposal(request, fallback_action_id=""):
def _rin_store_unresolved_attempt(request_id, request, fallback_action_id, job_id, error_code):
_RIN_UNRESOLVED_ATTEMPTS[str(request_id)] = {
"status": "unresolved",
"request_fingerprint": _rin_request_fingerprint(request),
"request": json.loads(json.dumps(
request,
ensure_ascii=False,
separators=(",", ":"),
)),
"fallback_action_id": str(fallback_action_id),
"job_id": str(job_id or ""),
"error_code": str(error_code or "job_outcome_unknown"),
"allow_offline_before_submit": False,
}

def rin_schedule_proposal(
request,
fallback_action_id="",
known_job_id="",
resuming=False,
allow_offline_before_submit=True,
):
"""Start one proposal without blocking the Ren'Py interaction thread."""
resuming = bool(resuming)
allow_offline_before_submit = bool(allow_offline_before_submit) and not resuming
request_id = str(request.get("request_id", ""))
if not request_id:
raise rin_client.RinProtocolError("invalid_request", "Proposal request needs request_id")
Expand All @@ -103,8 +127,33 @@ init -30 python:
"Request id was already used with a different proposal payload",
)
return request_id
retained = _RIN_UNRESOLVED_ATTEMPTS.get(request_id)
if retained is not None:
if retained["request_fingerprint"] != _rin_request_fingerprint(request):
raise rin_client.RinProtocolError(
"request_id_conflict",
"Request id was already used with a different proposal payload",
)
request = retained["request"]
fallback_action_id = retained["fallback_action_id"]
known_job_id = retained["job_id"]
resuming = True
allow_offline_before_submit = False
client, registry, disabled_reason = _rin_runtime()
if registry is None:
if resuming or not allow_offline_before_submit or known_job_id:
_rin_store_unresolved_attempt(
request_id,
request,
fallback_action_id,
known_job_id,
disabled_reason or (
"job_outcome_unknown"
if known_job_id
else "proposal_outcome_unknown"
),
)
return request_id
_rin_store_local_result(
request_id,
request,
Expand All @@ -116,18 +165,24 @@ init -30 python:
)
return request_id
config = _rin_config()
return registry.schedule(
scheduled = registry.schedule(
request,
renpy.invoke_in_thread,
fallback_action_id=fallback_action_id,
deadline_seconds=config["deadline"],
poll_interval=config["poll_interval"],
known_job_id=known_job_id,
allow_offline_before_submit=allow_offline_before_submit,
)
_RIN_UNRESOLVED_ATTEMPTS.pop(request_id, None)
return scheduled

def rin_proposal_status(request_id):
request_id = str(request_id)
if request_id in _RIN_LOCAL_RESULTS:
return "ready"
if request_id in _RIN_UNRESOLVED_ATTEMPTS:
return "unresolved"
if _RIN_REGISTRY is None:
return "missing"
status = _RIN_REGISTRY.status(request_id)
Expand All @@ -141,6 +196,8 @@ init -30 python:
local = _RIN_LOCAL_RESULTS.pop(request_id, None)
if local is not None:
return local["result"]
if request_id in _RIN_UNRESOLVED_ATTEMPTS:
return None
if _RIN_REGISTRY is None:
return None
entry = _RIN_REGISTRY.consume(request_id)
Expand All @@ -152,15 +209,39 @@ init -30 python:
"source": "canceled" if entry["status"] == "canceled" else "error",
"committable": False,
"fallback_reason": entry["error_code"],
"job_id": "",
"job_id": entry.get("job_id", ""),
"proposal": None,
}

def rin_proposal_attempt(request_id):
"""Return a plain pending/unresolved record suitable for game persistence."""
request_id = str(request_id)
retained = _RIN_UNRESOLVED_ATTEMPTS.get(request_id)
if retained is not None:
return json.loads(json.dumps(retained, ensure_ascii=False, separators=(",", ":")))
if _RIN_REGISTRY is None:
return None
return _RIN_REGISTRY.attempt(request_id)

def rin_resume_proposal(attempt):
"""Resume a game-persisted attempt with its exact request and known Job."""
if not isinstance(attempt, dict) or not isinstance(attempt.get("request"), dict):
raise rin_client.RinProtocolError("invalid_attempt", "Proposal attempt is invalid")
return rin_schedule_proposal(
attempt["request"],
fallback_action_id=str(attempt.get("fallback_action_id", "")),
known_job_id=str(attempt.get("job_id", "")),
resuming=True,
allow_offline_before_submit=False,
)

def rin_cancel_proposal(request_id):
request_id = str(request_id)
if request_id in _RIN_LOCAL_RESULTS:
_RIN_LOCAL_RESULTS.pop(request_id, None)
return True
if request_id in _RIN_UNRESOLVED_ATTEMPTS:
return False
if _RIN_REGISTRY is None:
return False
return _RIN_REGISTRY.cancel(request_id)
Expand All @@ -171,5 +252,5 @@ init -30 python:
"enabled": _rin_transport_enabled(),
"base_url": config["base_url"],
"token_configured": bool(config["token"]),
"pending_results": len(_RIN_LOCAL_RESULTS),
"pending_results": len(_RIN_LOCAL_RESULTS) + len(_RIN_UNRESOLVED_ATTEMPTS),
}
Loading
Loading