门人招募系统 · 接口契约
状态:实现完成,已验证(2026-09-18 07:40) 遗留:游历奇遇 / 剧情投靠两条支路未实现(二期),主干「山门招贤榜」已全线可用。 验证记录:python -m gamenet.selftest→ 202 通过 / 0 失败;node web/tools/e2e.cjs http://127.0.0.1:8080/→ 50/50 通过(含 4 条招贤榜专项断言); 视觉取证node web/tools/shot_recruit.cjs→render/web/recruit/5 张,横向溢出 0。 ⚠️ 2026-09-17 曾因并发会话(日志簿)撞车而暂停,09-18 恢复并完成。 撞车教训见第五节第 4 条。
一、老大拍板的三条决策
| 决策点 | 结论 |
|---|---|
| 掌门实体怎么落地 | 开局首人当掌门(改动最小,不碰存档格式) |
| 招贤榜入口 | |
| 门人差异 | 要加「资质 + 擅长」(接受碰存档字段,但用默认值兼容旧档) |
🔴 2026-09-20 入口搬家:老大「把招贤榜的操作功能都移动到匾额建筑的浮窗内」。 榜单(声望条 / 催榜 / 候选卡)整条从「门人」页顶部移入 匾额(biane)建筑的浮窗 (hud._insRecruit),判据是建筑数据里的effect.recruit—— 与后端Guild.effect_of("recruit")同一套,按 effect 分派、不按 id。 同时撤除:门人页那整块榜单、门派页那颗goto-members跳转键。 正向验收见web/tools/probe_recruit_inspector.cjs(含 4 条反向断言)。 ⚠️ 匾额开局不预置(要大殿 + 境界 3 + 60木/80银)⇒ 建成之前玩家无招募入口, 与议事厅的MEMORY-detail.md §42.8同一个待拍板问题。
二、设计:双门槛 × 三条路
门槛一 · 掌门境界 = 天花板(强人不投弱主)
可招境界上限 = max(1, min(round(掌门等级 × 0.6), 掌门等级 − 2))
掌门 1 级 → 只招得到 1 级;20 级 → 12 级;55 级 → 33 级。 − 2 是刻意留的:掌门永远比手下强至少 2 级,主角不能被超越(给小孩玩的作品,这点很重要)。
门槛二 · 门派声望 = 席位与效率
声望分 = int(round(建筑 bonus.prestige × 100)) + 门下人数 × 2 + 建筑总数 × 1
分 9 档,提供候选席位数(3→7)、刷新速度倍率、招募花费折扣。
⚠️prestige原本是全代码无人消费的空转属性(只有biane匾额 +0.04、shishi石狮 +0.03 在产出它)。 本系统让它第一次有了去处 —— 装饰建筑不再是白板。
三条路
| 途径 | 触发 | 品质 | 玩家动作 | 管什么 |
|---|---|---|---|---|
| 山门招贤榜 | 每 3 作品日自动刷新 | 中,受掌门封顶 | 手动挑一个 | 主干,掌控感 |
| 游历奇遇 | 随机 | 高,可越级 | 弹窗抉择 | 惊喜、捡漏(未实现,留待二期) |
| 剧情投靠 | 里程碑 | 固定高 | 收下 | 确定性成长(未实现,留待二期) |
为什么是「自动刷新池 + 手动挑选」而不是二选一: 纯手动(现状)零决策、玩两次就腻;纯自动则失去经营掌控感、人满时很难受。 组合后,玩家不盯着屏幕它也在长(离线回来有新鲜面孔),来了要挑(有决策乐趣)。
三、数据契约(已落地 ✅)
game/data/recruit.json —— 数值全部外置,代码不出现魔法数字。已通过合法性 + 可达性推演。
| 段 | 关键数值 |
|---|---|
leader | level_coef=0.6 / level_gap=2 / train_mult=1.5(掌门专属修炼倍率) |
board | refresh_days=3.0(≈7.2 分钟真实时间)/ reroll_cooldown_days=0.4 / reroll_cost_silver=120 / 招募费 = round((160 + 境界×40) × cost_mult) / level_floor_ratio=0.34 |
prestige.levels | 9 档,阈值 0/15/34/58/88/124/166/214/268(差值 15/19/24/30/36/42/48/54 递增) |
quality.grades | 天 4‰权重 ×1.6 / 地 12 ×1.35 / 玄 32 ×1.15 / 黄 52 ×1.0;leader_shift_per_6_levels=0.10 |
specialty | 9 种(弟子/杂役/护院/剑客/刀客/弓手/暗器客/酒客/说书人),对应 scene.MEMBER_MODELS |
数值自洽性已验算:
- 开局声望 12 分(差 3 分到 2 级)→ 建一座匾额就能升门派等级,即时正反馈
- 满配 293 分 > 末档 268 分 → 可达(满配 = 3 匾额 lv3 + 3 石狮 lv3 + 64 门人 + 78 建筑)
四、代码契约
已落地 ✅
datadb.py — FILES 加 "recruit";新增 get_recruit()。
core/models.py — 新增 QualitySystem(grades/info/name/train_mult/color/loyalty_range)与 SpecialtySystem(data/pool/name),与 RealmSystem 同处(纯表查询,避免循环引用)。
Character:
__slots__加quality(默认"huang")、specialty(默认"")__init__/to_dict/from_dict/to_view同步扩展to_view()额外吐quality_name/quality_mult/quality_color/specialty_name
🔑 刻意不升SAVE_VERSION:from_save用的是严格相等判断(不等即拒载), 升版本会让所有旧档作废。这里靠from_dict的.get(字段, 默认值)兜底 —— 旧档读入即「黄 / 无擅长」,外形回退到 crc32 老逻辑,老门人长相不变。 已验证:旧档加载不抛异常;selftest 135/135 全绿。
待落地 ⏳
1. core/recruit.py(新文件,零冲突,可先写)
class PrestigeSystem:
levels() -> list[dict] # recruit.json 的 prestige.levels
score(g: Guild) -> int # 上述公式
info(g: Guild) -> dict # {score, level, name, next_score, progress,
# slots, refresh_mult, cost_mult, at_max}
class RecruitBoard:
leader_level(g) -> int
level_cap(g) -> int # 可招上限公式
refresh_period_days(g) -> float # refresh_days × refresh_mult
ensure(g, game_minutes, force=False) # 每 tick 调;到期则重掷整榜
roll(g, game_minutes) -> None # 重掷整榜(席位数 = 当前档 slots)
reroll(g, game_minutes) -> (ok, msg) # 催榜(冷却 + 扣银两)
hire(g, cand_id) -> (ok, msg, Character|None)
view(g, game_minutes) -> dict # 给前端
候选境界随机区间:[max(1, round(上限 × level_floor_ratio)), 上限] —— 留出「便宜新人」也有「高价高手」可挑。
资质抽样用 Rng.weighted()(已有方法):shift = 1 + floor(掌门等级/6) × 0.10, tian/di 权重 ×shift,xuan/huang 权重 ÷shift,再归一化。
🔑roll()的种子必须含roll_seq(本榜被重掷过多少次,单调递增)。 见第五节第 5 条 —— 只用game_minutes当盐会让「催榜」在暂停时掷回同一批人。roll_seq必须入to_save():否则读档后计数归零,重掷可能撞回已出现过的批次。
2. core/models.py — Guild 加字段
self.leader_id: str = "" # 掌门 char_id
self.recruit_board: dict = {} # {"candidates": [...], "last_refresh_minutes": int, "last_reroll_minutes": int}
leader() -> Character | None(按leader_id反查,找不到返回 None)to_dict/from_dict同步(键缺失给默认空榜 → 旧档可载)train_tick:每人乘QualitySystem.train_mult(quality);掌门额外乘leader.train_mult
(注意:train_tick 已被日志系统改为返回 list[dict] 升级名单,改的时候别丢这个返回值)
3. world/factions.py — PlayerGuild.setup_starting
造完门人后设 guild.leader_id = members[0].char_id。AI 门派不设掌门。
4. scene.py — 身份映射
def member_identity(char_id, level, specialty="", is_leader=False) -> str
def member_model_for(char_id, level, specialty="", is_leader=False) -> str
优先级:is_leader → "master";level >= 26 → "elder";否则 specialty or crc32 池。
⚠️ 掌门判定从「等级 ≥36」改为「leader_id指向」。否则掌门会时而弟子时而掌门。MEMBER_MODEL_MASTER_LEVEL可删,但必须同步清理selftest.py:350的旧断言。
5. engine.py — 指令与快照
- 新增
{"type":"recruit_hire","cand_id":"..."}→RecruitBoard.hire() - 新增
{"type":"recruit_board_reroll"}→RecruitBoard.reroll() - 旧
recruit指令废弃,返回{"ok": False, "msg": "招募已移至「招贤榜」,请到门人页挑选"} - 同步改:
selftest.py:221、web/tools/e2e.cjs:263、web/tools/probe_sidebar.cjs:40 tick()内调RecruitBoard.ensure(self.guild, self.clock.game_minutes)snapshot()的guild段加三段:leader/prestige/recruit_board(字段清单见下)guild.members[]每人加quality/quality_name/quality_color/specialty/specialty_name/is_leader
快照字段(前端按此渲染,别改名字)
"leader": { # 与 members[] 元素同构;无掌门时 null
char_id, name, level, exp, loyalty, equipment, realm, title, power,
exp_progress, exp_next, quality, quality_name, specialty, specialty_name,
identity, model, is_leader: True,
},
"prestige": { score, level, name, next_score, progress, at_max },
"recruit_board": {
slots, refresh_days, days_since_refresh, days_to_refresh,
level_cap, leader_level, can_reroll, reroll_cost: {silver},
reroll_cooldown_days, empty_reason,
candidates: [{
cand_id, name, level, realm, title,
quality, quality_name, quality_mult, quality_color,
specialty, specialty_name, loyalty,
cost: {silver}, affordable, identity, model,
}],
},
6. 前端 web/frontend/
- 🔴 2026-09-20 起:招贤榜在匾额建筑浮窗里(
hud._insRecruit/
_insCandidateCard),由 _insOps(b) 按 b.effect.recruit 分派。 区块内容:门派声望进度条、可招至 / 掌门 / 席位、换榜倒计时、催榜按钮、 候选卡(资质徽章用 quality_color 着色;.task-card + .task-grid,一行多张)、 门人已满时禁用招募、空榜显示 empty_reason
- 匾额数据
game/data/buildings.json的三级effect都带{"recruit":{"board":true}}
—— 改标记要同时改三级,否则升阶后浮窗里的榜单会「长出来又没了」
- 图例文案两处要同步:
effectRows()(效果明细里的「张挂招贤榜」)与
_insPreview()(未营造时的「可张挂山门招贤榜」),漏了玩家看不出这楼能招人
- ⚠️
handleAction(hud.js 的 act 白名单)必须有recruit_hire/recruit_board_reroll,
否则点了没反应也不报错 —— 最容易埋雷的地方。搬家时这两个 act 原样保留,没动
- ⚠️ 所有新字段用可选链兜底(
g.recruit_board || {}),缺字段时不白屏 - ⚠️ 别再往侧栏加回入口:门人页已撤净,
probe_recruit_inspector.cjs与
probe_sidebar.cjs 都有反向断言盯着(一份数据一处渲染)
五、踩过的坑(务必记住)
building.max_count在 JSON 里是null,DataDB会剔除 null 键 →
bdef.get("max_count", 3) 拿到默认值 3。所以每种建筑(含装饰建筑)最多 3 座, prestige 满配约 0.87。定数值锚点时必须按 3 座算。
from_save用严格相等判断SAVE_VERSION(不等即拒载)。加字段千万别升版本,
用 .get() 默认值兼容。
- 时间换算:1 tick = 10 作品分钟 = 1 真实秒;1 作品日 = 1440 作品分钟 ≈ 2.4 分钟真实。
故 refresh_days: 3.0 ≈ 7.2 分钟真实时间。
- 并发修改警告:本系统与日志系统撞过车。
engine.py/models.py被两边同时改,
出现过「from_dict 传 quality 但 __init__ 不接」的破损态。 重启本任务前,先确认日志系统已收工;每次 Edit 前读最新内容,改完立刻跑 selftest。 (09-18 又观测到一次:前端产物被非本会话重新构建,dist/ 的哈希文件换了名。)
- 🔴 「催榜」的钱花了、人没换 —— 两个独立 bug 叠在一起(09-18 实测抓出)。
e2e 报「催榜换了一批人 :: 张霜/周白雪/杨玄 → 张霜/周白雪/杨玄」+「催榜有回执 :: 未知指令」,两条合起来才看清全貌:
| # | 位置 | 病因 | 症状 |
|---|---|---|---|
| 1 | hud.js 催榜按钮 | 漏写 data-cmd | 指令压根没发出去,后端回「未知指令」,钱也没扣、人也没换 |
| 2 | recruit.py roll() | 种子只用 game_minutes | 暂停时时间不推进 → 同盐同种子 → 掷回一模一样的一批(钱照扣、回执照写「新人登门」) |
1的教训:handleAction 有个 act 白名单(不在名单里点了没反应),但进了白名单还不够 —— 按钮必须自己带 data-cmd。两者缺一,症状都是「点了没反应」,且控制台一片干净。 排查手法:grep data-act 逐个核对该按钮附近 3 行内有没有 data-cmd。
2的教训:任何「立刻要看到不同结果」的操作,随机种子不能只依赖时间。 时间在暂停/同 tick 内可以不变,必须掺一个单调递增的计数器。 roll_seq 兼作存档字段,可复现性(同档同刻同批人)照样成立。
发现顺序值得记:先直测后端(REST)→ 成功,说明后端没问题; 再跑 e2e → 回执是「未知指令」,才暴露前端压根没发指令。 只看截图的话,两张图候选一样,会误判成单一后端 bug。
- ⚠️ 别把「构建」与「e2e」并发跑(09-18 踩)。
npx vite build会重写dist/,
而 e2e 是实时抓 dist/index.html 的。并发时 e2e 可能抢在构建落盘前读到旧产物, 于是「招贤榜渲染 ✓ 但候选 0 人」这种假红就出现了 —— 后端明明有 3 个候选。 判据:后端 REST 直测正常 + 页面行为像旧版 → 先怀疑产物过期,别改业务代码。 顺带:dist/ 里的哈希文件名会骗人,核对要用内容 grep(如 grep data-act 找按钮)。
#toasts不是#toast,且取证要「先读后拍」(09-18 踩)。toast 容器 id 是#toasts;
更重要的是 toast 只活 2400ms,而 SwiftShader 拍一张 3D 页面要好几秒 —— 若「点击 → 截图 → 读 toast」,必然读到空,看着像功能坏了,其实是取证晚了。 顺序必须是「点击 → 读 toast → 截图」。
六、验收清单 · 全部通过 ✅(2026-09-18)
- [x]
python -m gamenet.selftest全绿 —— 202 条 / 0 失败 - [x] 掌门存在且
leader_id == members[0].char_id,且首人资质最高(主角光环) - [x] 声望分公式与 9 档分档(含边界:正好等于阈值时升档)
- [x] 可招上限公式(1→1 / 20→12 / 55→33),且永不 ≥ 掌门等级 − 1
- [x] 候选池按期刷新、席位数 = 当前档
slots - [x] 资质权重随掌门等级向高资质倾斜(掌门 1 级 3.93% → 54 级 10.37%)
- [x]
recruit_hire成功后候选移除 + 入列 + 扣银两;门人满时失败且 msg 非空 - [x]
recruit_board_reroll冷却期间失败,且冷却期内不扣费 - [x] 催榜必然换一批人(同一时刻也不例外)← 09-18 补的回归钉子,见第五节第 5 条
- [x] 旧档(无
leader_id/quality/specialty/roll_seq)Guild.from_dict()不抛异常 - [x]
node web/tools/e2e.cjs http://127.0.0.1:8080/—— 50/50 通过
(招贤榜 4 条专项:入口跳转 / 榜面渲染 / 催榜换人 / 催榜回执)
- [x] 前端截图肉眼复核:
render/web/recruit/5 张,
横向溢出 0(scrollWidth == clientWidth == 314),按钮全部可用
后续可选打磨(非阻塞)
- 候选卡片目前不显示「招进来之后会顶替谁」——门人接近上限时可考虑提示
- 催榜冷却用「日」表述(
还需 0.4 日方可再催),对小孩不够直观,可换算成「时辰」 - 二期两条支路:游历奇遇(随机高资质、可越级)、剧情投靠(里程碑固定高资质)