7.3 顺序约束与解锁

带条件的边是顺序约束:lighthouse_base 到 tide_cave 要先有 knows_tide_rule。本节把 5 条约束折成一张表,做两项判定——环检测(DFS 三色法加 Kahn 拓扑序,实测无环)与「不可达的解锁条件」(钥匙会不会锁在门后,实测 0 条),并输出约束表 CSV。

7.3 顺序约束与解锁

独立游戏关卡设计与环境叙事 讲「这一关要先拿到钥匙才开得了门」,讲的是空间与环境叙事的节奏经验——什么时候给钥匙、门后面是什么。本节把这件事写成规格:带条件的边就是顺序约束,而「钥匙会不会被锁在门后」是一个可以判定的问题。

顺序约束:带条件的边在说什么

7.1 的邻接表里,5 条带条件的边各带一个 when。把它们单独抄出来,就是一张约束表:

边条件读作
tavern → counting_housemet_hui见过船帮把头,才进得了商会账房
chapel → watch_postmet_shen见过教会执事,才进得了守望者哨所
lighthouse_base → tide_caveknows_tide_rule知道潮汐规律,才进得了潮汐洞窟
tide_cave → harbor_officeledger_page手上有账页,才进得了港务所
market_row → drowned_bellknows_bell_legend听过沉钟传说,才进得了沉钟塔

这 5 条约束有一个共同形状:条件引用的都是一个状态(flag 或 item),而这个状态必须在进入目标场景之前被产生。于是「解锁」的正确性问题就变成:每条约束引用的状态,能不能在进门之前拿到?

两种坏情况

顺序约束会以两种方式坏掉,两种都可以判定:

坏情况症状判定法
环A 等 B 的状态、B 等 A 的状态,两个门互相锁死DFS 三色法找后向边
钥匙锁在门后进 Y 需要 X 的状态,而 X 只能在 Y 或其下游产生生产者场景是否在入口前可达

第一种是结构病:有环的图根本没有「解锁顺序」这个概念,因为不存在一个拓扑序。第二种是设计病:图没有环,但某个门的钥匙被放在了门后面——玩家按设计路径走,永远凑不齐进门的条件。

S 层:环检测与解锁可达性

下面这段是 S 层脚本 sim/na_07_3_order_unlock.py。它从 dialogue 里建一张生产者映射(effects.set 写 flag、effects.grant 写 item,按对话节点的 scene 归到场景),然后对 5 条带条件边逐条判定:条件引用的状态,是否至少有一个生产者能在「进入目标场景之前」被走到(生产者从起点可达,且从生产者能走到目标场景)。

#!/usr/bin/env python3
# file: sim/na_07_3_order_unlock.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/na_07_3_order_unlock.py
"""卷一 7.3 顺序约束与解锁(S 层)。

带条件的边表达的是**顺序约束**:`lighthouse_base -> tide_cave` 的 when 是
`knows_tide_rule`,读作「先拿到潮汐规则,才进得了洞窟」。本节把 5 条带条件边折成
一张**约束表**,并做两项判定:

  1. **环检测**:场景图是不是 DAG。有环则「解锁顺序」不可定义——A 要等 B 的状态、
     B 又要等 A 的状态,两个门互相锁死。DFS 三色法找后向边;Kahn 算法顺带给出一个
     拓扑序(无环时它与幕序一致)。
  2. **不可达的解锁条件**:某条边的条件引用了 flag / item,而它只在**目标场景或
     其后**才可能被产生——「要进去得先有钥匙,钥匙却在门后面」。判定法:该引用的
     生产者场景里,是否至少有一个能在「进入目标场景之前」被走到(生产者从起点可达,
     且从生产者能走到目标场景)。

生产者映射来自 `dialogue`:`effects.set` 写 flag、`effects.grant` 写 item;把每个对话
节点按 scene 归到场景,就得到「哪个场景会产出哪个 flag / item」。

产出 out/narr07_constraints.csv;有环或有不可达解锁条件即非零退出。
"""
from __future__ import annotations

import csv
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
SPEC = os.path.join(ROOT, "config/story_spec.json")


def cond_label(c) -> str:
    if c is None:
        return ""
    k = c[0]
    if k in ("flag", "item"):
        return f"{k}:{c[1]}=={c[2]}"
    if k == "aff":
        return f"aff:{c[1]}{c[2]}{c[3]}"
    if k in ("and", "or"):
        return f"({cond_label(c[1])} {'&' if k == 'and' else '|'} {cond_label(c[2])})"
    if k == "not":
        return f"!{cond_label(c[1])}"
    return k


def cond_refs(c, acc) -> None:
    """收集条件里引用的 (kind, id):flag / item 是场景级锁,aff 不是。"""
    if c is None:
        return
    if c[0] in ("flag", "item"):
        acc.append((c[0], c[1]))
    elif c[0] in ("and", "or"):
        cond_refs(c[1], acc)
        cond_refs(c[2], acc)
    elif c[0] == "not":
        cond_refs(c[1], acc)


def succ_map(graph):
    succ = {n["id"]: [] for n in graph["nodes"]}
    for e in graph["edges"]:
        succ[e["from"]].append(e["to"])
    return succ


def reach(succ, src):
    seen, stack = {src}, [src]
    while stack:
        for v in succ[stack.pop()]:
            if v not in seen:
                seen.add(v)
                stack.append(v)
    return seen


def find_cycle(succ):
    """DFS 三色法:返回一条后向边构成的环,无环返回空列表。"""
    color = {k: 0 for k in succ}
    stack = []

    def dfs(u):
        color[u] = 1
        stack.append(u)
        for v in succ[u]:
            if color[v] == 1:
                return stack[stack.index(v):] + [v]
            if color[v] == 0:
                got = dfs(v)
                if got:
                    return got
        color[u] = 2
        stack.pop()
        return []

    for u in succ:
        if color[u] == 0:
            got = dfs(u)
            if got:
                return got
    return []


def topo_order(graph):
    """Kahn 算法:入度归零者按字典序入队,返回一个拓扑序。"""
    succ = succ_map(graph)
    indeg = {k: 0 for k in succ}
    for u in succ:
        for v in succ[u]:
            indeg[v] += 1
    order, ready = [], sorted(k for k, d in indeg.items() if d == 0)
    while ready:
        u = ready.pop(0)
        order.append(u)
        for v in succ[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                ready.append(v)
        ready.sort()
    return order


def producers(spec):
    """(kind, id) -> 会产出它的场景集合。set 写 flag,grant 写 item。"""
    scene_of = {n["id"]: n["scene"] for n in spec["dialogue"]}
    prod = {}
    for n in spec["dialogue"]:
        for c in (n.get("choices") or []):
            eff = c.get("effects") or {}
            for f in (eff.get("set") or {}):
                prod.setdefault(("flag", f), set()).add(scene_of[n["id"]])
            for it in (eff.get("grant") or {}):
                prod.setdefault(("item", it), set()).add(scene_of[n["id"]])
    return {k: sorted(v) for k, v in prod.items()}


def main():
    with open(SPEC, encoding="utf-8") as fh:
        spec = json.load(fh)
    graph = spec["scene_graph"]
    succ = succ_map(graph)
    start = next(n["scene"] for n in spec["dialogue"] if n["id"] == spec["meta"]["start"])

    cycle = find_cycle(succ)
    order = topo_order(graph)
    print(f"环检测:{'有环 ' + str(cycle) if cycle else '无环(DAG)'}  节点={len(graph['nodes'])}")
    print(f"拓扑序(Kahn):{' -> '.join(order)}")

    prod = producers(spec)
    from_start = reach(succ, start)
    rows, bad = [], 0
    for e in graph["edges"]:
        if not e.get("when"):
            continue
        refs = []
        cond_refs(e["when"], refs)
        for kind, rid in refs:
            ps = prod.get((kind, rid), [])
            ok_scenes = [p for p in ps if p in from_start and p != e["to"] and e["to"] in reach(succ, p)]
            ok = bool(ok_scenes)
            bad += 0 if ok else 1
            rows.append({"edge": f"{e['from']}->{e['to']}", "cond": cond_label(e["when"]),
                         "ref_kind": kind, "ref_id": rid, "producer_scenes": "|".join(ps),
                         "unlock_before_entry": "|".join(ok_scenes), "ok": ok})

    print(f"带条件边={sum(1 for e in graph['edges'] if e.get('when'))}  约束条目={len(rows)}  不可达解锁条件={bad}")
    for r in rows:
        print(f"  [{'OK' if r['ok'] else 'FAIL'}] {r['edge']:<32} 需 {r['ref_kind']}:{r['ref_id']:<16} "
              f"生产于 {r['producer_scenes']:<28} 入口前可满足于 {r['unlock_before_entry'] or '(无)'}")

    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "narr07_constraints.csv"), "w", encoding="utf-8", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=["edge", "cond", "ref_kind", "ref_id",
                                           "producer_scenes", "unlock_before_entry", "ok"])
        w.writeheader()
        for r in rows:
            w.writerow(r)
    print(f"wrote out/narr07_constraints.csv rows={len(rows)}")

    if cycle:
        print(f"FAIL 场景图有环:{cycle}")
        return 1
    if bad:
        print(f"FAIL 有 {bad} 个解锁条件在进入目标场景前无法满足")
        return 1
    print("OK na_07_3_order_unlock:场景图无环,5 条顺序约束的解锁条件都在入口前可满足")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/na_07_3_order_unlock.py:

环检测:无环(DAG)  节点=14
拓扑序(Kahn):pier -> chapel -> tavern -> counting_house -> watch_post -> lighthouse_base -> market_row -> drowned_bell -> tide_cave -> harbor_office -> lighthouse_top -> breakwater -> fog_gate -> dawn_quay
带条件边=5  约束条目=5  不可达解锁条件=0
  [OK] tavern->counting_house           需 flag:met_hui          生产于 tavern                       入口前可满足于 tavern
  [OK] chapel->watch_post               需 flag:met_shen         生产于 chapel                       入口前可满足于 chapel
  [OK] lighthouse_base->tide_cave       需 flag:knows_tide_rule  生产于 harbor_office|lighthouse_base|pier 入口前可满足于 lighthouse_base|pier
  [OK] tide_cave->harbor_office         需 item:ledger_page      生产于 counting_house               入口前可满足于 counting_house
  [OK] market_row->drowned_bell         需 flag:knows_bell_legend 生产于 chapel|market_row|pier       入口前可满足于 chapel|market_row|pier
wrote out/narr07_constraints.csv rows=5
OK na_07_3_order_unlock:场景图无环,5 条顺序约束的解锁条件都在入口前可满足

同一趟写出的 out/narr07_constraints.csv:

edge,cond,ref_kind,ref_id,producer_scenes,unlock_before_entry,ok
tavern->counting_house,flag:met_hui==1,flag,met_hui,tavern,tavern,True
chapel->watch_post,flag:met_shen==1,flag,met_shen,chapel,chapel,True
lighthouse_base->tide_cave,flag:knows_tide_rule==1,flag,knows_tide_rule,harbor_office|lighthouse_base|pier,lighthouse_base|pier,True
tide_cave->harbor_office,item:ledger_page==1,item,ledger_page,counting_house,counting_house,True
market_row->drowned_bell,flag:knows_bell_legend==1,flag,knows_bell_legend,chapel|market_row|pier,chapel|market_row|pier,True

判读:无环、无锁死的门

两项判定都通过了,但通过的方式值得逐条看:

约束生产者入口前可满足于读法
tavern → counting_housetaverntavern钥匙就在门边:把头本人给 met_hui
chapel → watch_postchapelchapel同上,执事给 met_shen
lighthouse_base → tide_cavepier / lighthouse_base / harbor_officepier / lighthouse_base三条路能拿到潮汐规律,最早在码头
tide_cave → harbor_officecounting_housecounting_house账页在第一夜的账房拿,先于第二夜的洞窟
market_row → drowned_bellpier / chapel / market_row三处都可沉钟传说三个场景都能听到

三点值得记:

  • 第一、二条是「门边给钥匙」:met_hui 在 tavern 场景内产生,而 tavern 正是这条边的起点。这是最常见的解锁形态——先在同一个场景里和 NPC 打交道拿到状态,再从同一场景走出去。
  • 第三条有多个生产者:knows_tide_rule 能在 pier(问少年)、lighthouse_base(用潮汐表推算)、harbor_office(问十七年前)三处拿到。判定结果 lighthouse_base|pier 说明前两处都在入口前可达,第三条路(harbor_office)在洞窟下游,对这条边没用——但它对别的路径有用。同一个状态有多个生产者,是解锁设计里的冗余,不是缺陷。
  • 第四条是跨幕解锁:ledger_page 在第一夜的 counting_house 拿到,tide_cave → harbor_office 这条边在第二夜——钥匙早于门整整一幕。这类「长距离解锁」最容易出问题(钥匙和门隔得远,容易忘),脚本把它标成 OK 的依据是「生产者在入口前可达」,而不是「生产者离门近」。

至于环:DFS 三色法一条后向边都没找到,Kahn 算法也顺利吐出了全部 14 个节点的拓扑序。这条拓扑序与幕序一致(第一夜 → 第二夜 → 第三夜),说明场景图是一张按幕分层的 DAG。

自检表

检查项合格标准
环检测有实现用 DFS 三色法找后向边,并用 Kahn 算法交叉验证拓扑序
解锁判定有依据判定用「生产者从起点可达且能走到目标场景」,不靠场景名相似
生产者从规格来从 dialogue 的 effects 反推,不手写映射表
多生产者不误判只要有一个生产者入口前可达即算满足
口径诚实声明判定是「存在一条解锁路径」,不是「每一局都解锁」

场景层的三节到此为止:7.1 把场景写成图,7.2 判可达,7.3 判顺序。这三件事合起来回答了「这张场景图自不自洽」。但场景图只说了「走到哪里」,没说「在那里分了几条岔、又在哪里汇回来」——那是分支拓扑的事,第 8 章接着写。

阅读导航:上一节:7.2 场景的可达性 · 下一节:8.1 分支拓扑:分叉、汇聚、并行 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「narrative」更多文章

  1. 15.3 规模告警
  2. 15.2 求值与遍历的开销
  3. 15.1 规格规模的增长