- 删除 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 引用),未动
146 lines
8.7 KiB
GDScript
146 lines
8.7 KiB
GDScript
# 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)
|