10.1 好感度的初始化与增减
Godot 对话选择历史与后果日志 做的是引擎里的选择历史与回放界面:玩家点过什么、看到过什么后果,落成一条可以回看的日志。本节往下沉一层,落到数值层:好感度是一个可枚举的整数状态——它有唯一的起点、唯一的写入算子、恒定的值域。日志记的是「发生了什么」,本节算的是「这个数怎么变、会不会越界」。
把好感度说清楚只要三句话:从哪来(characters[].affinity.init)、怎么变(只走 add 算子)、越界怎么办(每次写完 clamp 夹回 [0,100])。本节先做一次静态清点,再用采样路径把「值域恒定」写成可断言的测试。
从哪来:声明域 6 个角色,状态域 4 个变量
story_spec.json 里有两个互不相同的角色集合:
- 声明域:
characters[]里每个角色都带一个affinity块(init/range/thresholds/gates),本案例 6 个。 - 状态域:
state.vars列出的、真正会被读写的变量,本案例 4 个。
两者差出来的是 lin 与 hai。它们在 characters[] 里有完整的 affinity 声明(init 分别是 40 与 60),却不在 state.vars 里,全规格也没有任何一次 add 写到它们。这就是「声明域 > 状态域」:声明了却从不推进的变量,实际是一个常量——它不会进任何条件、不会触发任何门限。静态脚本第一件事就是把这两个名字点出来,因为它们是规格里最容易腐烂的地方:看着像状态,其实是布景。
4 个真实变量的起点与增减合计如下(init 一律取 characters[].affinity.init):
| 变量 | init | 被 add 次数 | 正增量和 | 负增量和 | 全 spec 增量和口径的可达区间 |
|---|---|---|---|---|---|
hui | 50 | 13 | +62 | -40 | [10, 100] |
mu | 50 | 12 | +75 | -25 | [25, 100] |
shen | 50 | 18 | +135 | -15 | [35, 100] |
yan | 20 | 23 | +170 | -25 | [0, 100] |
最后一列要专门说清口径:它是把全规格所有 add 都当成会走到时的理论上界与下界,不是某条路径的真实区间。真实路径只会取到其中一小段。列它的用处是看上下界会不会被 clamp 吸收——hui/mu/shen 的 init + 正增量和 都超过 100,yan 的 init + 负增量和 小于 0,也就是说这四个变量的可达区间上下两端都摸到了夹取边界。
怎么变:66 次写入,8 种增量
好感度的写入算子只有 add 一个(set 只写 flag、grant/revoke 只写物品,那是 9.2 节的事)。把全规格的 add 摊平数一遍:66 次写入,落在 23 种「变量:增量」组合上,去重后只有 8 种增量值 [-15, -10, -5, 2, 5, 10, 15, 20]。
这里有一个细节值得记:增量集合里出现了 +2——一个与其余取值不在同一量级上的数。它不属于 ±5/±10/±15/±20 那套「档位」,是某处细调留下的。静态分布的意义正在于此:增量的取值集合是设计意图的指纹,多出一个 +2 就该回头看一眼那处写入是不是有意为之。
各变量的增量分布(增量×次数)逐条列在下面 S 层脚本的实跑输出里。
yan 的写入次数最多(23 次),也最容易被正增量顶到上界;hui 是唯一有 -15 大幅回落的变量——它有一条「翻脸」线。
越界怎么办:clamp 是必要算子
整数状态最常见的坏法有两种:溢出(一路加,加到 130)与下溢(一路减,减到 -20)。规格的解法是把值域写死在 affinity.range(本案例全部 [0, 100]),并在每次写入后夹回区间。
这不是理论担忧。上表里 hui 的正增量和 62 叠在 init 50 上是 112,shen 的 135 叠在 50 上是 185,yan 的 170 叠在 20 上是 190——都超过 100。也就是说:如果采样足够长,这四个变量一定会撞到上界,clamp 一定会有实际作用。它不是一个「防御性但从不触发」的冗余算子。
S 层:静态清点脚本
下面这段是 S 层脚本 sim/na_10_1_affinity_init_delta.py。它是纯静态的——不跑路径,只读 config/story_spec.json,把上面三张表算成整数,并断言 I1(全部角色的 init ∈ [0,100] 且 range == [0,100])。
#!/usr/bin/env python3
# file: sim/na_10_1_affinity_init_delta.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_10_1_affinity_init_delta.py
"""卷一 10.1 好感度的初始化与增减(S 层,纯静态,不跑路径)。
只读 config/story_spec.json:
初始值 = characters[].affinity.init(**声明域**,6 个角色都有声明)
状态域 = state.vars(4 个变量),add 的落点必须在这里
增减 = 只走 add 算子;每次写完由 clamp 夹回 [0,100]
统计每个变量的 init、被 add 的次数、全部 add 的取值分布,并给出「全 spec 增量和」
口径下的理论可达区间 [clamp(init+负增量和), clamp(init+正增量和)]。产出
out/na_10_1_affinity_init_delta.csv。整数口径:全部 int,禁浮点。
"""
from __future__ import annotations
import csv
import json
import os
import sys
AFF_LO, AFF_HI = 0, 100
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CSV_PATH = os.path.join(OUT, "na_10_1_affinity_init_delta.csv")
def clamp(v: int, lo: int = AFF_LO, hi: int = AFF_HI) -> int:
return lo if v < lo else hi if v > hi else v
def load_spec() -> dict:
with open(os.path.join(ROOT, "config/story_spec.json"), encoding="utf-8") as fh:
return json.load(fh)
def tally(spec: dict) -> tuple:
"""声明域 / 状态域 / add 写入统计,全部整数。"""
declared = {c["id"]: c["affinity"] for c in spec["characters"]}
vars_ = sorted(spec["state"]["vars"])
cnt = {v: 0 for v in vars_}
pos = {v: 0 for v in vars_}
neg = {v: 0 for v in vars_}
dist: dict = {}
writes = 0
for n in spec["dialogue"]:
for c in (n.get("choices") or []):
for k, d in ((c.get("effects") or {}).get("add") or {}).items():
writes += 1
dist[(k, d)] = dist.get((k, d), 0) + 1
if k in cnt:
cnt[k] += 1
(pos if d > 0 else neg)[k] += d
return declared, vars_, cnt, pos, neg, dist, writes
def main() -> int:
spec = load_spec()
declared, vars_, cnt, pos, neg, dist, writes = tally(spec)
print(f"spec={spec['meta']['title']} 声明域={len(declared)} 个角色 状态域={len(vars_)} 个变量")
orphan = sorted(set(declared) - set(vars_))
print(f"声明域 > 状态域:{orphan}(有 affinity 声明,不在 state.vars,全 spec 无 add 写入)")
for v in orphan:
assert all(k[0] != v for k in dist), f"{v} 不应出现在任何 add 里"
print("变量 init 被 add 次数 正增量和 负增量和 理论可达区间(全 spec 增量和口径)")
rows = []
for v in vars_:
init = declared[v]["init"]
lo, hi = clamp(init + neg[v]), clamp(init + pos[v])
print(f" {v:<5} {init:>3} {cnt[v]:>6} {pos[v]:>5} {neg[v]:>5} [{lo:>3}, {hi:>3}]")
rows.append({"var": v, "init": init, "add_count": cnt[v], "pos_sum": pos[v], "neg_sum": neg[v],
"naive_upper": init + pos[v], "reach_lo": lo, "reach_hi": hi,
"clamp_low_hit": 1 if init + neg[v] < AFF_LO else 0,
"clamp_high_hit": 1 if init + pos[v] > AFF_HI else 0})
print(f"add 取值分布(共 {writes} 次写入,{len(dist)} 种 变量:增量 组合):")
for v in vars_:
items = sorted((k[1], c) for k, c in dist.items() if k[0] == v)
print(" " + v + ": " + " ".join(f"{d:+d}×{c}" for d, c in items))
deltas = sorted({k[1] for k in dist})
print(f"全 spec add 增量取值集合 {deltas}({len(deltas)} 种,最小 {deltas[0]} 最大 {deltas[-1]})")
hit_hi = [r["var"] for r in rows if r["clamp_high_hit"]]
hit_lo = [r["var"] for r in rows if r["clamp_low_hit"]]
print(f"上界会被 clamp 吸收的变量 {hit_hi}(init+正增量和 > 100)")
print(f"下界会被 clamp 吸收的变量 {hit_lo}(init+负增量和 < 0)")
# I1:init 与 range 都是合法好感度
bad = [c["id"] for c in spec["characters"]
if not (AFF_LO <= c["affinity"]["init"] <= AFF_HI) or c["affinity"]["range"] != [AFF_LO, AFF_HI]]
print(f"I1 全部角色的 init ∈ [0,100] 且 range == [0,100]:{not bad}")
if bad:
print(f"FAIL {bad}")
return 1
os.makedirs(OUT, exist_ok=True)
with open(CSV_PATH, "w", encoding="utf-8", newline="") as fh:
w = csv.DictWriter(fh, fieldnames=["var", "init", "add_count", "pos_sum", "neg_sum",
"naive_upper", "reach_lo", "reach_hi",
"clamp_low_hit", "clamp_high_hit"])
w.writeheader()
w.writerows(rows)
print(f"wrote out/na_10_1_affinity_init_delta.csv rows={len(rows)}")
print("OK na_10_1_affinity_init_delta:init 全部来自声明域,add 全部落在状态域,I1 成立")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/na_10_1_affinity_init_delta.py:
spec=雾港夜航 声明域=6 个角色 状态域=4 个变量
声明域 > 状态域:['hai', 'lin'](有 affinity 声明,不在 state.vars,全 spec 无 add 写入)
变量 init 被 add 次数 正增量和 负增量和 理论可达区间(全 spec 增量和口径)
hui 50 13 62 -40 [ 10, 100]
mu 50 12 75 -25 [ 25, 100]
shen 50 18 135 -15 [ 35, 100]
yan 20 23 170 -25 [ 0, 100]
add 取值分布(共 66 次写入,23 种 变量:增量 组合):
hui: -15×1 -10×2 -5×1 +2×1 +5×5 +10×2 +15×1
mu: -10×2 -5×1 +5×5 +10×2 +15×2
shen: -5×3 +5×7 +10×5 +15×2 +20×1
yan: -10×1 -5×3 +5×8 +10×8 +15×2 +20×1
全 spec add 增量取值集合 [-15, -10, -5, 2, 5, 10, 15, 20](8 种,最小 -15 最大 20)
上界会被 clamp 吸收的变量 ['hui', 'mu', 'shen', 'yan'](init+正增量和 > 100)
下界会被 clamp 吸收的变量 ['yan'](init+负增量和 < 0)
I1 全部角色的 init ∈ [0,100] 且 range == [0,100]:True
wrote out/na_10_1_affinity_init_delta.csv rows=4
OK na_10_1_affinity_init_delta:init 全部来自声明域,add 全部落在状态域,I1 成立
同一趟写出的 out/na_10_1_affinity_init_delta.csv 每个变量一行,列依次是 init、add_count、pos_sum、neg_sum、未夹取的 naive_upper、夹取后的 reach_lo/reach_hi,以及两个夹取命中标志(clamp_low_hit/clamp_high_hit)。四个变量的 clamp_high_hit 全是 1,yan 的 clamp_low_hit 也是 1。
P 层:把值域不变量写成测试
静态清点只能说「按全规格增量和,会撞到边界」;它不说「实际路径上真的不会越界」。后者要靠 P 层:core/test/na_10_1_affinity_bounds.test.ts 用同一个 spec 跑采样,断言每一条路径、每一步的好感度都是 [0,100] 内的整数。
// file: core/test/na_10_1_affinity_bounds.test.ts
// tier: P
// verify: node --test 'core/test/na_10_1_affinity_bounds.test.ts'
// 10.1 好感度的初始化与增减:init 来源 / 采样全程不越界 / 负例(人为放大 add 仍被 clamp)。
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { initState, applyChoice, replay, samplePaths, clamp } from "../src/inkwell/engine.ts";
import type { Spec, State } from "../src/inkwell/engine.ts";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "../..");
const readJson = (p: string) => JSON.parse(readFileSync(p, "utf8"));
const spec = readJson(join(ROOT, "config/story_spec.json")) as Spec;
const SEED = 20261016;
test("初始值唯一来自 characters[].affinity.init", () => {
const s = initState(spec);
assert.deepEqual(Object.keys(s.aff).sort(), [...spec.state.vars].sort());
for (const v of spec.state.vars) {
const c = spec.characters.find((x) => x.id === v);
assert.ok(c, `状态域变量 ${v} 必须有角色声明`);
assert.equal(s.aff[v], c.affinity.init, `${v} 的 init 应取自声明域`);
}
});
test("声明域 > 状态域:有 affinity 却不在 state.vars 的角色存在且从未被 add", () => {
const orphan = spec.characters
.map((c) => c.id)
.filter((id) => !spec.state.vars.includes(id))
.sort();
assert.deepEqual(orphan, ["hai", "lin"]);
const added = new Set<string>();
for (const n of spec.dialogue) {
for (const c of n.choices ?? []) {
for (const k of Object.keys(c.effects?.add ?? {})) added.add(k);
}
}
for (const id of orphan) assert.equal(added.has(id), false, `${id} 不应出现在任何 add 里`);
});
test("采样全程好感度恒在 [0,100](300 条路径)", () => {
const paths = samplePaths(spec, 300, SEED);
assert.equal(paths.length, 300);
for (const p of paths) {
const s = replay(spec, p);
for (const k of Object.keys(s.aff)) {
const v = s.aff[k];
assert.ok(Number.isInteger(v), `${k}=${v} 必须是整数`);
assert.ok(v >= 0 && v <= 100, `好感度越界 ${k}=${v}`);
}
}
});
test("负例:把某个 add 人为放大到百万级,clamp 仍把结果夹回 [0,100]", () => {
const huge = JSON.parse(JSON.stringify(spec)) as Spec;
const n0 = huge.dialogue.find((x) => x.id === spec.meta.start)!;
const c = n0.choices.find((x) => x.id === "c_pier_1b")!;
assert.ok(c.effects.add && "hui" in c.effects.add);
c.effects.add!.hui = 1000000;
const up = applyChoice(huge, initState(huge), "c_pier_1b");
assert.equal(up.aff.hui, 100, "正溢出必须被夹到上界 100");
c.effects.add!.hui = -1000000;
const down = applyChoice(huge, initState(huge), "c_pier_1b");
assert.equal(down.aff.hui, 0, "负溢出必须被夹到下界 0");
assert.equal(clamp(1000000), 100);
assert.equal(clamp(-1000000), 0);
});
test("单变量正增量合计已超过上界,clamp 是必要算子(不是冗余)", () => {
const posSum: Record<string, number> = {};
for (const v of spec.state.vars) posSum[v] = 0;
for (const n of spec.dialogue) {
for (const c of n.choices ?? []) {
for (const [k, d] of Object.entries(c.effects?.add ?? {})) {
if (k in posSum && d > 0) posSum[k] += d;
}
}
}
for (const v of spec.state.vars) {
const init = spec.characters.find((x) => x.id === v)!.affinity.init;
assert.ok(init + posSum[v] > 100, `${v} 的 init+正增量和 ${init + posSum[v]} 应超过 100`);
}
console.log(`init+正增量合计:${spec.state.vars.map((v) => `${v}=${spec.characters.find((x) => x.id === v)!.affinity.init + posSum[v]}`).join(" ")}`);
});
本机实跑 node --test 'core/test/na_10_1_affinity_bounds.test.ts'(摘录):
init+正增量合计:yan=190 hui=112 shen=185 mu=125
✔ 初始值唯一来自 characters[].affinity.init (0.956833ms)
✔ 声明域 > 状态域:有 affinity 却不在 state.vars 的角色存在且从未被 add (0.170125ms)
✔ 采样全程好感度恒在 [0,100](300 条路径) (18.702917ms)
✔ 负例:把某个 add 人为放大到百万级,clamp 仍把结果夹回 [0,100] (0.656042ms)
✔ 单变量正增量合计已超过上界,clamp 是必要算子(不是冗余) (0.758958ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
第四个测试是这一节的负例:它把 c_pier_1b 的 effects.add.hui 人为改成 1000000,再断言结果仍是 100;改成 -1000000 则断言结果仍是 0。这个负例的用途不是「证明实现正确」,而是证明 clamp 真的在被执行——如果哪次重构把夹取挪到别处、或漏掉了某个写入路径,这个负例会立刻红。
自检表
| 检查项 | 合格标准 |
|---|---|
| 起点唯一 | 好感度初始值只来自 characters[].affinity.init,不散落在别处 |
| 写入唯一 | 只走 add 算子;set/grant/revoke 不碰好感度 |
| 声明域诚实 | 有 affinity 声明却不在 state.vars 的角色必须显式点出 |
| 值域恒定 | 采样路径上每一步都是 [0,100] 内的整数 |
| 夹取非冗余 | 有负例证明 clamp 真的触发(人为放大 add 后仍被夹回) |
起点与增减说清了,好感度还只是一个「会变的数」。它变成可判定的条件要等下一个动作:把某个整数值当作门限。下一节看阈值门——["aff", 名, ">=", 整数] 怎么在选项上生效,以及怎么判断一条门限是不是永远不会被跨过。
阅读导航:上一节:9.3 条件与状态的闭包检验 · 下一节:10.2 阈值门 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。