15.2 反推的可行域

一个指标带反推出的不是一个点,而是二维参数平面上的一条带。本节在 enemyHp×enemyAttack 网格上用「胜率带 + 子池稳定性」两条约束圈出可行域,粗扫后逐行二分把边界钉到步长 1,实测域含 9 行、面积 54;B 层再扫带宽与种子数,看域怎么伸缩。

15.2 反推的可行域

《小游戏开发实战》卷一 12.3 原型迭代决策 判的是「改还是不改」,本节判的是「改成哪些值」。15.1 用单指标单参数反推出一个点;可只要同时要两个参数、或把「等于 T」放宽成「落在 [T−Δ, T+Δ]」,反推出的就不再是点,而是一片可行域——参数平面上满足全部约束的格子集合。关键前提:本节圈出的可行域是代理指标口径下的可行域,它只说明「在这套指标定义与这套种子池下,哪些参数组合落在带内」,只用于相对比较——禁止读成「最优参数」。换一套指标或种子池,域的形状会变。

从一个点到一条带

一个指标带在二维平面上是一条带(ribbon),不是一个点:沿 enemyHp 走,胜率单调下降;沿 enemyAttack 走,胜率也单调下降;两条方向合成一条斜带,带上每个点都落在目标胜率带内。域就是这条带被网格框住的那部分。

两条约束

主约束是目标带:集成胜率 ∈ [400,600]‰——胜率不能太高(无挑战)也不能太低(不可解);副约束是稳定性:三个子种子池的胜率极差 ≤ 100——结果不能只靠某几个种子撑起来。两条约束与网格都写在配置 config/gd_play_15_2_region.json(grid 段:enemyHp 步长 4、enemyAttack 步长 2)。脚本 sim/gd_play_15_2_region.py 先粗扫标出带内格与稳定格,再对每行沿 enemyAttack 二分把带的两条边界钉到步长 1,报出可行行数、面积、包围盒。

#!/usr/bin/env python3
# file: sim/gd_play_15_2_region.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_play_15_2_region.py
"""卷二 15.2 反推的可行域(S 层):一个指标带 → 二维参数平面上的一条带,不是单点。

约束 = 集成胜率 ∈ [bandLo,bandHi]‰(主)+ 子种子池极差 ≤ spreadMax(副)。粗扫标格后,
对每行沿 enemyAttack 二分,把带的两条边从粗网格步长钉到步长 1。
**全部读数是代理指标口径下的可行域**,只用于相对比较——禁读成「最优参数」。
产物 out/play15_region.json + .csv + .svg。全整数、无浮点、无 random。
"""
from __future__ import annotations

import copy, json, os, sys, time
import xml.etree.ElementTree as ET

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_play_15_2_region.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402


def winrate(spec, cfg, over, seeds):
    wins = games = 0
    for st in cfg["strategies"]:
        seq = (["p" + str(st["handIndex"])] * cfg["slotsPerTurn"] + ["e"]) * cfg["turns"]
        for sd in seeds:
            s = copy.deepcopy(spec)
            s["seed"] = sd
            s["params"].update(over)
            wins += 1 if ashlib.run_spec(s, seq)["won"] else 0
            games += 1
    return 1000 * wins // games


def refine_row(spec, cfg, row, col, row_v, lo, hi, band_lo, band_hi, pool, cache):
    """沿 col 维二分找带的两条边界:可行区间 = [first_le(bandHi), last_ge(bandLo)]。"""
    def w(v):
        return cache.setdefault((row_v, v), winrate(spec, cfg, {row: row_v, col: v}, pool))
    if w(hi) > band_hi or w(lo) < band_lo:
        return None
    if w(lo) <= band_hi:
        col_from = lo
    else:
        a, b = lo, hi
        while b - a > 1:
            mid = (a + b) // 2
            a, b = (mid, b) if w(mid) > band_hi else (a, mid)
        col_from = b
    if w(hi) >= band_lo:
        col_to = hi
    else:
        a, b = lo, hi
        while b - a > 1:
            mid = (a + b) // 2
            a, b = (mid, b) if w(mid) >= band_lo else (a, mid)
        col_to = a
    return (col_from, col_to) if col_from <= col_to else None


def draw(rows, band_map, stable_map, path):
    cols = [c for c, _ in rows[0]["cells"]]
    cw, ch, x0, y0 = 26, 20, 76, 56
    root = ashlib.svg_begin(x0 + cw * len(cols) + 40, y0 + ch * len(rows) + 48)
    ashlib.svg_text(root, 16, 24, "可行域:集成胜率 ∈ [400,600]‰(亮格)", 15)
    ashlib.svg_text(root, 16, 44, "粗边框 = 同时满足子池极差 ≤ 100;代理指标口径,非最优参数", 11, "#94a3b8")
    for i, r in enumerate(rows):
        y = y0 + i * ch
        ashlib.svg_text(root, 16, y + 14, str(r["rowValue"]), 10)
        for j, (cv, mean) in enumerate(r["cells"]):
            t, on = min(255, mean * 255 // 1000), band_map[i][j]
            fill = f"#{40 + t * 180 // 255:02x}{120 + t * 90 // 255:02x}40" if on else f"#{30 + t // 4:02x}2438"
            ET.SubElement(root, "rect", {"x": str(x0 + j * cw), "y": str(y), "width": str(cw - 2),
                                         "height": str(ch - 2), "fill": fill, "rx": "2",
                                         "stroke": "#f59e0b" if stable_map[i][j] else ("#38bdf8" if on else "none"),
                                         "stroke-width": "2" if stable_map[i][j] else "1"})
    return ashlib.svg_save(root, path)


def main() -> int:
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    pool, n_sub = cfg["seedPool"], cfg["subPools"]
    subs = [pool[i * (len(pool) // n_sub):(i + 1) * (len(pool) // n_sub)] for i in range(n_sub)]
    rp, cp = cfg["grid"]["row"], cfg["grid"]["col"]
    band_lo, band_hi, spread_max = cfg["target"]["bandLo"], cfg["target"]["bandHi"], cfg["target"]["spreadMax"]
    row_vals = list(range(rp["from"], rp["to"] + 1, rp["step"]))
    col_vals = list(range(cp["from"], cp["to"] + 1, cp["step"]))

    rows, band_map, stable_map, csv = [], [], [], []
    for rv in row_vals:
        cells, brow, srow = [], [], []
        for cv in col_vals:
            mean = winrate(spec, cfg, {rp["param"]: rv, cp["param"]: cv}, pool)
            vals = [winrate(spec, cfg, {rp["param"]: rv, cp["param"]: cv}, p) for p in subs]
            spread = max(vals) - min(vals)
            on = band_lo <= mean <= band_hi
            cells.append((cv, mean))
            brow.append(on)
            srow.append(on and spread <= spread_max)
            csv.append([rv, cv, mean, spread, 1 if on else 0, 1 if srow[-1] else 0])
        rows.append({"rowValue": rv, "cells": cells})
        band_map.append(brow)
        stable_map.append(srow)
    band_cells = sum(sum(1 for x in r if x) for r in band_map)
    stable_cells = sum(sum(1 for x in r if x) for r in stable_map)

    cache, refined, area = {}, [], 0
    for rv in row_vals:
        iv = refine_row(spec, cfg, rp["param"], cp["param"], rv, col_vals[0], col_vals[-1],
                        band_lo, band_hi, pool, cache)
        if iv is not None:
            refined.append({"rowValue": rv, "colFrom": iv[0], "colTo": iv[1], "width": iv[1] - iv[0] + 1})
            area += iv[1] - iv[0] + 1
    total = len(row_vals) * len(col_vals)
    bbox = {"rowFrom": row_vals[0], "rowTo": row_vals[-1]}
    if refined:
        bbox.update({"colFrom": min(r["colFrom"] for r in refined), "colTo": max(r["colTo"] for r in refined)})

    elapsed_ms = (time.perf_counter_ns() - t0) // 1000000
    n = ashlib.write_csv(os.path.join(OUT, "play15_region.csv"),
                         ["rowValue", "colValue", "winratePermille", "spread", "inBand", "stable"], csv)
    svg = draw(rows, band_map, stable_map, os.path.join(OUT, "play15_region.svg"))
    report = {"id": "gd_play_15_2_region", "rowParam": rp["param"], "colParam": cp["param"],
              "target": cfg["target"], "rowValues": row_vals, "colValues": col_vals,
              "bandCells": band_cells, "stableCells": stable_cells, "totalCells": total,
              "bandFracPermille": 1000 * band_cells // total, "refinedRows": refined,
              "refinedArea": area, "bbox": bbox, "refineEvals": len(cache), "elapsedMs": elapsed_ms}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play15_region.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"网格 {rp['param']}×{cp['param']} = {total} 格(粗扫步长 {rp['step']}×{cp['step']})")
    print(f"目标带 胜率∈[{band_lo},{band_hi}]‰  子池极差≤{spread_max}")
    print(f"粗扫带内格 = {band_cells}/{total} = {1000 * band_cells // total}‰  其中稳定格 = {stable_cells}")
    print(f"细化后可行行数 = {len(refined)}  可行面积(列宽合计)= {area}  细化正推格 = {len(cache)}")
    print("逐行可行区间(enemyAttack,步长 1):")
    for r in refined:
        print(f"  enemyHp={r['rowValue']:>3}  enemyAttack∈[{r['colFrom']},{r['colTo']}]  宽{r['width']}")
    print(f"包围盒 rows=[{bbox['rowFrom']},{bbox['rowTo']}] cols=[{bbox.get('colFrom')},{bbox.get('colTo')}]")
    print(f"csv={n} 行  svg={svg} bytes  elapsed={elapsed_ms} ms")
    print("wrote out/play15_region.json out/play15_region.csv out/play15_region.svg")
    ET.parse(os.path.join(OUT, "play15_region.svg"))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

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

网格 enemyHp×enemyAttack = 272 格(粗扫步长 4×2)
目标带 胜率∈[400,600]‰  子池极差≤100
粗扫带内格 = 28/272 = 102‰  其中稳定格 = 9
细化后可行行数 = 9  可行面积(列宽合计)= 54  细化正推格 = 95
逐行可行区间(enemyAttack,步长 1):
  enemyHp= 40  enemyAttack∈[30,32]  宽3
  enemyHp= 44  enemyAttack∈[24,29]  宽6
  enemyHp= 48  enemyAttack∈[22,23]  宽2
  enemyHp= 52  enemyAttack∈[22,23]  宽2
  enemyHp= 56  enemyAttack∈[18,21]  宽4
  enemyHp= 60  enemyAttack∈[18,21]  宽4
  enemyHp= 64  enemyAttack∈[15,17]  宽3
  enemyHp= 68  enemyAttack∈[2,16]  宽15
  enemyHp= 72  enemyAttack∈[2,16]  宽15
包围盒 rows=[40,100] cols=[2,32]
csv=272 行  svg=30500 bytes  elapsed=1721 ms
wrote out/play15_region.json out/play15_region.csv out/play15_region.svg

读数:粗扫 272 格里带内 28 格(102‰),其中 9 格同时满足稳定性;逐行二分细化后,可行域覆盖 9 行、面积(列宽合计)54,从 (40, 30–32) 一路斜到 (72, 2–16)。这条斜带正是 500‰ 等值线被 [400,600] 带加宽后的样子——域是一条带,不是一格。格数、行数、面积这些由机制与配置决定,每次跑完全一致;末尾的 elapsed 是墙钟耗时,逐次跑会有几毫秒到几十毫秒抖动,只作量级参考。

两处要诚实说明。其一,enemyHp=68 与 72 两行的左端 enemyAttack=2 触到网格边界:在低 enemyAttack 一侧可行域比网格更宽,这个左端是网格的边界、不是域的真实边界。其二,稳定格(9)比带内格(28)少得多,说明大部分带内格靠的是「凑巧几个种子赢」——这正是副约束的价值:它把不稳定的格剔了出去。全部读数都在代理指标口径下,域的边界随指标定义移动。

B 层:可行域的面积随什么变

域的面积不是机制常数,它随两件事伸缩:目标带宽(带越宽,域越大)与种子数(越少,分辨率越粗)。B 层脚本 sim/gd_play_15_2_region_warn.py 对 4 个带宽(50/100/200/400‰)与 3 个种子数(8/16/24)各算一次细化后的可行面积。域为空、或面积占网格比例过大只打印软告警,进程始终 exit 0。

#!/usr/bin/env python3
# file: sim/gd_play_15_2_region_warn.py
# tier: B
# verify: /opt/homebrew/bin/python3 sim/gd_play_15_2_region_warn.py
"""卷二 15.2 反推的可行域(B 层):域面积对「目标带宽」与「种子数」的敏感度——软告警。

域面积随目标带宽变宽而变大、随种子数变粗而抖。对 4 个带宽(50/100/200/400‰)与 3 个种子数
(8/16/24)各算一次细化后的可行面积;域为空或面积占网格比例过大只**打印软告警**,进程始终
exit 0。产出 out/play15_region_warn.json。全整数、无浮点、无 random。
"""
import json
import os
import sys
import time

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_play_15_2_region.json")
sys.path.insert(0, HERE)
import ashlib  # noqa: E402
from gd_play_15_2_region import refine_row  # noqa: E402


def area(spec, cfg, pool, band_lo, band_hi):
    rp, cp = cfg["grid"]["row"], cfg["grid"]["col"]
    lo, hi = cp["from"], cp["to"]
    cache, total, rows = {}, 0, 0
    for rv in range(rp["from"], rp["to"] + 1, rp["step"]):
        iv = refine_row(spec, cfg, rp["param"], cp["param"], rv, lo, hi, band_lo, band_hi, pool, cache)
        if iv is not None:
            total += iv[1] - iv[0] + 1
            rows += 1
    return total, rows


def main():
    t0 = time.perf_counter_ns()
    spec = ashlib.load_spec()
    with open(CONFIG, encoding="utf-8") as f:
        cfg = json.load(f)
    pool, rb = cfg["seedPool"], cfg["robustness"]
    rp, cp = cfg["grid"]["row"], cfg["grid"]["col"]
    rows_n = len(range(rp["from"], rp["to"] + 1, rp["step"]))
    grid_units = rows_n * (cp["to"] - cp["from"] + 1)

    width_rows, warnings = [], []
    for wdt in rb["bandWidths"]:
        lo, hi = 500 - wdt // 2, 500 + wdt // 2
        a, rws = area(spec, cfg, pool, lo, hi)
        frac = 1000 * a // grid_units
        width_rows.append({"bandWidth": wdt, "bandLo": lo, "bandHi": hi,
                           "area": a, "rows": rws, "fracPermille": frac})
        if a == 0:
            warnings.append(f"带宽 {wdt}:可行域为空——目标带过窄,该网格内无解")
        if frac > rb["largeFracPermille"]:
            warnings.append(f"带宽 {wdt}:可行面积占网格 {frac}‰ > {rb['largeFracPermille']}‰——约束过松")

    seed_rows = []
    for cnt in rb["seedCounts"]:
        a, rws = area(spec, cfg, pool[:cnt], 400, 600)
        seed_rows.append({"seedCount": cnt, "area": a, "rows": rws})

    report = {"id": "gd_play_15_2_region_warn", "widthRows": width_rows, "seedRows": seed_rows,
              "gridUnits": grid_units, "largeFracPermille": rb["largeFracPermille"],
              "warnings": warnings, "elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "play15_region_warn.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print(f"基准带 [400,600]‰,网格 {rows_n}×{cp['to'] - cp['from'] + 1} = {grid_units} 单位")
    print(f"{'带宽':>6}{'带范围':>14}{'可行面积':>10}{'行数':>6}{'占比‰':>8}")
    for r in width_rows:
        print(f"{r['bandWidth']:>6}{'[' + str(r['bandLo']) + ',' + str(r['bandHi']) + ']':>14}"
              f"{r['area']:>10}{r['rows']:>6}{r['fracPermille']:>8}")
    print(f"{'种子数':>8}{'可行面积':>10}{'行数':>6}")
    for r in seed_rows:
        print(f"{r['seedCount']:>8}{r['area']:>10}{r['rows']:>6}")
    print(f"largeFrac={rb['largeFracPermille']}‰  warnings={len(warnings)}")
    for wmsg in warnings:
        print(f"  [WARN] {wmsg}")
    print(f"elapsed={report['elapsedMs']} ms  exit=0(软告警不硬失败)")
    print("wrote out/play15_region_warn.json")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
基准带 [400,600]‰,网格 16×33 = 528 单位
    带宽           带范围      可行面积    行数     占比‰
    50     [475,525]        11     4      20
   100     [450,550]        31     6      58
   200     [400,600]        54     9     102
   400     [300,700]       111     9     210
     种子数      可行面积    行数
       8        53     9
      16        52     9
      24        54     9
largeFrac=200‰  warnings=1
  [WARN] 带宽 400:可行面积占网格 210‰ > 200‰——约束过松
elapsed=1876 ms  exit=0(软告警不硬失败)
wrote out/play15_region_warn.json

读数:带宽从 50‰ 放到 400‰,面积从 11 涨到 111——带越宽,域越大,这是必然的。带宽 400‰ 的面积占比 210‰ 超过 200‰ 阈值,触发「约束过松」告警:域宽到快盖住半个网格时,它已经不能区分「好参数」与「坏参数」了。种子数从 8 到 24,面积几乎不动(53/52/54)——域的位置对种子数敏感(见 15.1 的 ±2 漂移),但域的大小不敏感。

自检表

检查项合格标准
口径声明正文显式写明可行域是代理指标口径,禁「最优参数」
双约束目标带 + 稳定性,两条都报,不能只留主约束
粗扫 + 二分粗扫标格、二分钉边,报出细化后的面积
边界诚实触网格边界的行要注明「这是网格的边界」
面积敏感度扫带宽与种子数,超阈值软告警
无浮点胜率、面积、占比全是整数或千分比

可行域告诉你「哪些参数组合落在带内」,但没告诉你「把域算到多细、值不值」。域的细化是要花钱的:二分一次一行,二维全网格则是平方代价——下一节把反推的代价与取舍算清楚。

阅读导航:上一节:15.1 从指标反推参数 · 下一节:15.3 反推的代价与取舍 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡