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 反推的代价与取舍 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。