Files
gplan-2/game/core/rng_service.gd
T
DKChan 6c2dbc37c1 docs: ADR 体系废除 + architecture.md 确立为架构唯一事实源(2026-10-15 用户令:决策记录归 git commit log,不另存决策文档)
- 删除 design/dev/architecture/adr-0001~0007 共 7 份文件(内容留 git 历史可查)
- architecture.md:删「ADR 审计」「ADR 立项清单」两节;清除全文 ADR-000X 注记、QQ 表/S 表 ADR 引用;未决表关闭已落定项(QQ-01/02/03),立项清单在途事项转登记 QQ-11~14;补 A1 生成姿态事实、autoload 四件清单、board_generator 性能姿态;文档状态立「事实源地位」条
- CLAUDE.md:恢复 RNG 单点硬性红线(去 ADR-0007 引用);新增「架构事实唯一源」红线;规范导航架构行标注唯一事实源
- doc-conventions:§5 增「架构事实唯一源」行;§11 活文档清单补架构文档
- registry:architecture.yaml 删 18 处 adr 字段、autoload_set 补 SaveManager/MetaProgression、board_rending 更新为 Node2D 已落地事实;entities.yaml 12 处引用清除
- game/ 代码注释约 60 处清 ADR-000X 引用(事实与日期保留);豁免标记 ADR-0007-exempt → rng-seed-exempt(main.gd/static_scan.sh/architecture.md/06/iter27 五处同步)
- GDD/qa 实现报告/research 卡片/roadmap/tests README 引用全清;死链校验 1206 链接 0 死链、gdd_lint 0 不通过
- 代码中 adr/adr5 等为 adventure_runner 既有局部变量名(非 ADR 引用),未动
2026-10-15 23:52:30 +08:00

146 lines
8.7 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# VERTICAL SLICE - NOT FOR PRODUCTION
# Validation Question: 玩家能否无指导在一场完整战斗中经历「开战→挖掘规划→轮末结算逐行兑现→胜/负」并逐行归因(P2)
# Date: 2026-09-10
# RngService autoload(数据配置)——统一随机流单点(Rule 5;2026-09-27 七流并一+全事件日志)。
# 玩法代码禁止 randomize()/自建 RandomNumberGenerator。
# 实现 GDD:design/gdd/systems/表现与支撑/数据配置.md(系统 ID D2·数据配置——registry 追溯锚,命名规范见 CLAUDE.md)
# 统一流口径:唯一主种子 + 唯一派生(单盐)+ 唯一全局取数计数器;open_stream 参数降格为「用途标签」——
# 不参与派生,只进事件日志(并流前七条流名全部保留为合法标签)。号位语义(并流后果,用户裁决承接):
# 全局 scope 只回答「全局第 N 次取数」,不再回答「第 N 次某类事件」——路线依赖为已知代价,单点定位能力由事件日志承载。
# 派生公式(R3 + 统一流):derive = mix64(SINGLE_SALT XOR wrap64(master + scope×GOLDEN))——
# splitmix64 finalizer 变体终末混淆,见 _mix64 注记;scope 0-3 与旧 board_gen 向量逐位连续
# (单盐沿用原 board_gen 盐)。
# 取号语义:全新冒险首个 open_stream = 全局 scope 0;事件日志逐次落盘 user://logs/rng_log_<master_seed>.jsonl。
class_name RngServiceCore
extends Node
# 用途标签封闭词表(并流前七流名全保留;新增用途走扩签通道 = 改代码 + 评审——清单封闭语义由「流」改「标签」)
const PURPOSE_TAGS := ["board_gen", "reward_roll", "encounter", "armor_roll", "behavior_roll", "variant_place", "gp_drop", "node_map", "reward_pick", "shop_draw", "fx_reveal"] # node_map = 关卡节点图生成 v0.4 扩签(M-3 迭代 5 T5);reward_pick = 提升三选一加权洗牌扩签(M-3 迭代 10 奖励重构批——每关大奖励抽签恰一 scope:E-S weight-key 每候选一取,标签不参与派生只进事件日志,派生/黄金向量零改动);shop_draw = 商店房货架抽签扩签(M-4 迭代 11 第②批 T1——每次抽 3〔进店+每次买后重抽〕恰一 scope,与 reward_pick 分签防取数序互扰;shop=off 零消费零扰动);fx_reveal = 专长订阅类揭示效果掷格(迭代 20 T1——见血开窍/斩首学者等「揭示 1 格」,与 reward_pick 分签防取数序互扰;只揭真格=安全格信息红线承载)
# 分段表达式书写(数据配置 R3 实现级规约——禁 ≥2^63 完整字面量)
const GOLDEN_RATIO := (0x9E3779B9 << 32) | 0x7F4A7C15 # 0x9E3779B97F4A7C15
const SINGLE_SALT := (0xA0761D64 << 32) | 0x78BD642F # 0xA0761D6478BD642F(统一单盐——沿用并流前 board_gen 盐)
# R3 混淆终末函数常量(splitmix64 finalizer 变体——互异奇常数 ≠ 乘数;全程 OUT_MASK 63 位掩码保持
# 有符号 int64 移位语义与参考实现逐位一致,见 _mix64 注记)
const MIX_MUL_A := (0xBF58476D << 32) | 0x1CE4E5B9 # 0xBF58476D1CE4E5B9
const MIX_MUL_B := (0x94D049BB << 32) | 0x133111EB # 0x94D049BB133111EB
const OUT_MASK := 0x7FFFFFFFFFFFFFFF
const INT64_MIN := -9223372036854775808
# master_seed 只读出口(E5 封装收口 2026-09-13):写入只经 set_master_seed(计数器全零断言
# 不可绕过);外部读 master_seed 供 存档序列化 快照契约观测。
var _master_seed: int = -1 # -1 = 未播种(取数即致命,EC7)
var master_seed: int:
get: return _master_seed
var _counter: int = 0 # 全局取数计数器(唯一账本——快照契约 = 主种子 + 本计数器)
# 事件日志(丙案新增):每冒险一会话,逐次取数落一行(scope/用途标签/派生种子)——不致命、失败静默降级
var _log_path := ""
var _log_fa: FileAccess = null
var _log_warned := false
# 纯函数(测试/预览/日志回放入口,不推计数器)——单盐单参数(并流前 (流, scope) 双参退役)
func derive_at(scope: int) -> int:
assert(scope >= 0, "负 scope 与 scope+2^63 经输出掩码别名(禁止)")
var t: int = _master_seed + scope * GOLDEN_RATIO # wrap64 自然回绕——禁 float 中间量/禁 %/禁主动修复
return _mix64(SINGLE_SALT ^ t)
# R3 派生终末混淆(splitmix64 finalizer 变体,2026-09-19 掷点测量修复批 B 案):裸派生种子直接作
# PCG 初始状态时,「连续小整数 master × 头部 scope 窗口」的面值彩票会被短局复用放大进聚合统计
# (验收重测批实证:30 连续种子 × 头部窗口 × 40 局复用 → 18 点聚合 +57%);此混淆使任何窄种子窗
# 表现如随机抽样。纯函数、逐 scope 复放语义不变;全程 OUT_MASK 63 位掩码——有符号 int64 的 >>
# 为算术移位,63 位正值域内与逻辑移位逐位一致,这是与参考实现(Python 无符号模型)对齐的关键。
func _mix64(z: int) -> int:
z = (z + GOLDEN_RATIO) & OUT_MASK
z = (z ^ (z >> 30)) * MIX_MUL_A & OUT_MASK
z = (z ^ (z >> 27)) * MIX_MUL_B & OUT_MASK
return (z ^ (z >> 31)) & OUT_MASK
func set_master_seed(seed_val: int) -> void:
if seed_val == INT64_MIN:
push_error("[RngService] master_seed = INT64_MIN 致命(abs 恒等回绕,与 0 静默别名)")
assert(false, "INT64_MIN 致命")
return
if seed_val < 0:
seed_val = -seed_val
push_warning("[RngService] master_seed 负输入 abs 归一化: %d" % seed_val)
assert(_counter == 0, "冒险中途重播种会击穿同冒险无碰撞前提(仅许全局计数器归零时调用)")
_master_seed = seed_val
_counter = 0
# 切片登记:冒险开始重播种入口(正式侧调用权归 冒险结构;切片主菜单 = 冒险起点)——
# 并流批(2026-09-27 统一流裁决):重播种同时开本次冒险的事件日志会话。
func new_adventure(seed_val: int) -> void:
_counter = 0
set_master_seed(seed_val)
_open_log_session(_master_seed)
# 断点续玩恢复入口(迭代 8 卡①——快照契约「主种子+计数器」的回填面,调用权归 冒险结构):
# 回填后首个 open_stream(scope=counter_val) 的派生种子与中断前逐位一致——续玩从断点号位精确
# 续接原时间线(停点前已消耗的号位不重耗,停点后取数逐位可复现)。仅续玩路径调用;模拟/冒烟
# 零触达(MC 对拍零扰动)。
func restore_adventure(seed_val: int, counter_val: int) -> void:
_counter = 0
set_master_seed(seed_val)
_counter = maxi(0, counter_val)
_open_log_session(_master_seed)
# 取号门面:一次调用 = 一次全局 scope 取号(计数器读值 → 派生 → +1 → 落日志)。
# purpose 不参与派生——只作事件日志的用途标签(七流名保留为合法标签词表)。
func open_stream(purpose: String) -> RandomNumberGenerator:
if not PURPOSE_TAGS.has(purpose):
push_error("[RngService] 未知用途标签: %s" % purpose)
assert(false, "未知用途标签(标签词表封闭,EC12)")
if _master_seed < 0:
push_error("[RngService] 未设 master_seed 即取数(EC7——冒险结构 未接线的显式暴露)")
assert(false, "未播种取数致命(EC7)")
var scope: int = _counter
_counter = scope + 1
var rg := RandomNumberGenerator.new()
var dseed: int = derive_at(scope)
rg.seed = dseed
_log_draw(purpose, scope, dseed)
return rg
# 只读计数器(存档序列化 快照契约的观测面——统一流后为全局单值)
func counter() -> int:
return _counter
# ---- 事件日志(丙案:全事件落盘——号位失去事件语义后的单点定位承载面)----
# 会话入口:只由 new_adventure 开(直接 set_master_seed 的测试路径无日志会话,取数照常、不落盘)
func _open_log_session(seed_val: int) -> void:
_log_path = "user://logs/rng_log_%d.jsonl" % seed_val
_log_fa = null
_log_warned = false
var dir := DirAccess.open("user://")
if dir == null:
push_warning("[RngService] 事件日志目录不可达(不致命,本次冒险不落盘)")
return
dir.make_dir_recursive("logs")
if FileAccess.file_exists(_log_path):
_log_fa = FileAccess.open(_log_path, FileAccess.READ_WRITE)
if _log_fa != null:
_log_fa.seek_end() # 同种子重跑追加
else:
_log_fa = FileAccess.open(_log_path, FileAccess.WRITE)
if _log_fa != null:
_log_fa.store_line(JSON.stringify({"type": "session", "master_seed": seed_val}))
func _log_draw(purpose: String, scope: int, dseed: int) -> void:
if _log_fa == null:
return
_log_fa.store_line(JSON.stringify({"type": "draw", "scope": scope, "purpose": purpose, "dseed": dseed}))
_log_fa.flush()
if _log_fa.get_error() != OK and not _log_warned:
_log_warned = true
push_warning("[RngService] 事件日志写盘失败(不致命,本会话静默降级): %s" % _log_path)