Files
gplan-2/game/core/rng_service.gd
T

135 lines
7.6 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;ADR-0007 裁丙 2026-09-27:七流并一+全事件日志)。
# 玩法代码禁止 randomize()/自建 RandomNumberGenerator。
# 实现 GDD:design/archive/gdd-2026-09-14/d2-data-config.md(系统 ID D2·数据配置——registry 追溯锚,命名规范见 CLAUDE.md)
# 统一流口径:唯一主种子 + 唯一派生(单盐)+ 唯一全局取数计数器;open_stream 参数降格为「用途标签」——
# 不参与派生,只进事件日志(并流前七条流名全部保留为合法标签)。号位语义(ADR-0007 后果,用户裁决承接):
# 全局 scope 只回答「全局第 N 次取数」,不再回答「第 N 次某类事件」——路线依赖为已知代价,单点定位能力由事件日志承载。
# 派生公式(R3 2026-09-19 + 统一流 2026-09-27):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"] # node_map = 关卡节点图生成 v0.4 扩签(M-3 迭代 5 T5——标签不参与派生只进事件日志,派生/黄金向量零改动)
# 分段表达式书写(数据配置 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
# 切片登记:冒险开始重播种入口(正式侧调用权归 冒险结构;切片主菜单 = 冒险起点)——
# 并流批(ADR-0007 裁丙):重播种同时开本次冒险的事件日志会话。
func new_adventure(seed_val: int) -> void:
_counter = 0
set_master_seed(seed_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)