An interaction menu is built from a per-character table of rows, filtered by two conditions in a fixed
order (RoleInteract.GetNpcInteracts):
condition2 hides the row while it matches.condition must match for the row to appear at all.So "he wrote to me and there is nothing here" is always one of three things: no row of that kind on that character, a hide-condition that is currently true, or a show-condition that is not. The game never says which. The rows carry a 锁 description for some of the third case.
There are two interaction menus, built by different code, and they do not offer the same things.
The world menu, the one the F key opens, is the surface a player means by sparring someone. It
is built by NpcInteract.GetNpcInteracts, and 切磋 is gated there. The default 切磋 row carries
param0 == 1, which that builder admits only for procedurally generated characters, so a named
character offers it only by carrying a 切磋 row of its own, and 142 of them do. The handler enforces
the same rule a second time: Handle_IT_TATAKAIYI counts the character's own 切磋 rows first, and on
a named character with none it shows one stock line (5000117) and returns. Handle_IT_ROB does the
same for 劫杀 — after paying the +30 胆识. So a mod that merely lets the default row through gets a
button that answers with a refusal; the trainer's 切磋 is offered on anyone switch injects a row of
its own instead, the way a commission does (CheckNpsIsNeedQieCuo). That row lands on your own
teammates as well, and on them the stock spar cannot run: whether two units may hit each other is
UnitController.IsHostile, and for two members of your team it answers from the team table, never
from the hostile colour a spar paints — so the teammate stands with the weapon out, neither of you
can land a blow, the F menu skips anyone mid-spar, and the 12 m walk is the only exit. The trainer
makes the pair hostile to each other while the spar runs, and keeps NpcController.OnDead from
filing the knockdown as a fallen party member, which it would otherwise do before the spar's win
path is reached.
Measured over the trainer's /interacts, which reads that builder: 切磋 and 劫杀 were both offered
by all three procedurally generated characters read (麒无邪 20006733, 香韩明 20005230, 梓凌瑶
20002725) and by none of the three named ones (张玄素 121401, 宁玉成 10009, 太初剑宗长老 121202).
The conversation menu is a separate surface with a builder of its own,
InteractOnChat.InitData, where the injection is unconditional: IT_TATAKAIYI for every character
except a teleport point, with no other test. That is a code reading and has not been checked in
play.
挑战 is authored one character at a time. npc_interact carries 355 rows of type 86
(IT_TiaoZhan), one per character, each pointing back at itself, and only 4 of them carry a
condition at all. Whether someone will take a challenge was decided by hand.
But the hand followed a pattern, and it is the opposite of the obvious guess. Cross-tabulated against the tables:
| 挑战 characters | everyone named | |
|---|---|---|
| carry a sect / genre | 64% | 17% |
| carry a title | 38% | 7% |
| on the 侠云录 | 5 of 355 | 86 exist |
carry a sect-rank record (menpaiinfo) |
0 of 355 | 44 exist |
That last row is exact and worth stating plainly: the 44 characters who hold a formal position
inside a sect — the ones MenPaiMenType sorts into 外门, 内门, 关门, 亲传 and 掌门 — are precisely
the characters you cannot challenge. The two sets do not overlap by one member.
So the rule nobody wrote down is: 挑战 is for notable free agents — 荒冢剑魔, 青莲狂客, 白玉京魁首, the wandering sort — while anyone who holds office in a sect is reached through the sect's own systems instead. It has nothing to do with the 侠云录, where only 5 of the 86 listed characters carry an 挑战 row at all.
Both end by calling SetCanBeWin() on the opponent, so neither can kill them. The difference is
what happens to you, and it is one line:
public void EnterCompareState(...) { m_PlayerEntity.SetCanBeWin(); StartCoroutine(RobCompareRoutine(...)); }
public void EnterTiaoZhanState(...) { StartCoroutine(TiaoZhanRoutine(...)); }
切磋 protects you. 挑战 does not — lose it and you die.
The reward is the same for both, which is easy to get wrong because OnQieCuoFinished has two
call sites and only one of them mentions 挑战:
// NpcController.LeaveCompare — the spar that ends without a defeat
if (pkResult && !IsSpecialQieCuoEventNpc(guid) && !m_TiaoZhanByPlayer) OnQieCuoFinished(...);
// NpcController.Dead — the opponent actually goes down
if (m_Entity.m_CompareWithPlayer && !IsSpecialQieCuoEventNpc(guid)) OnQieCuoFinished(...);
挑战 sets m_CompareWithPlayer as well as m_TiaoZhanByPlayer, so the second site fires for it and
pays the full purse: +8 好感 with the loser, +5 道德, the 悬赏 credit and the sect token whose
count comes off the loser's 境界 band. The !m_TiaoZhanByPlayer on the first site stops the payout
happening twice, not from happening at all.
The loot difference is not real either. SetCanBeWin resolves to EnterHostileRed(canDead: false),
so a 挑战 opponent has m_CanDead = false exactly as a 切磋 one does — they cannot die. The loot
branch reads !m_CompareWithPlayer || (m_TiaoZhanByPlayer && !pkResult), and its !pkResult half
needs a real death, which that flag forbids. It is unreachable in a 挑战.
| 切磋 | 挑战 | |
|---|---|---|
| the opponent can die | no | no |
| you can die | no | yes |
| loot on the body | none | none — the branch cannot be reached |
| +8 好感, +5 道德, 悬赏 credit, sect token | yes | yes |
| your HP and MP afterwards | full if you lost, untouched if you won | same |
That last row is the game's: PlayerController.OnDead refills you in full when an attacker knocked
you down, and a win refills the opponent instead. The trainer's Winning a 切磋 refills you like
losing one switch, on by default, gives a win the same refill.
One row differs in the fight. The approach to the fight is where 挑战 earns its keep, and the two handlers are barely related:
Handle_IT_TATAKAIYI (切磋) |
Handle_IT_TiaoZhan (挑战) |
|
|---|---|---|
| costs the target 15 vigor | yes, and refuses below it | no gate |
| can be turned down | yes — a roll on the 战力 gap, softened by their 胆识 | never |
| before the fight | a 等待思考 random talk, an NpcChatView, then two NpcAutoChatViewXSY dialogs (5001006, 5001009), then the accept/refuse line, then the 烧香 salute and the 3-2-1 |
nothing — it collects the targets and calls EnterTiaoZhanState |
| how many opponents | one | one. The handler does collect every id in the row's paramString, but all 355 rows name only the character themselves, so the list holds the same person twice and the fight is one-on-one |
So 挑战 is the fast, unrefusable version, and that is a real reason to prefer it: you trade the risk
of dying for skipping a preamble that can also end in a refusal. The trainer's FastQieCuo option
removes the 切磋 preamble, which narrows the gap to the refusal roll — and that roll only applies to a
procedurally generated character; a named one always accepts.
That is bearable only because you almost never choose. The two are offered to disjoint hand-picked sets of characters, so in practice the person in front of you decides which fight you are allowed — and the ones who offer only 挑战 carry a death risk the others do not, for the same reward and with nothing on the menu to say so.
The other gates in the menu code are narrow and specific:
IT_ROB) is shown on procedurally generated characters only, and withheld even there when
the character's NpcType is NT_RANDOM_NOJUEDOU, the procedural type that refuses duels. 210 named
characters carry a 劫杀 row of their own.IT_COMPARE) has the same procedural-only test in the code and no row of that type in any
table, on any character — the gate guards nothing.CanNpcBeTiaoZhanInXiaYunBang allows it: while you are unlisted, on any listed character; once listed, only on those above you. The ladder it climbs is in xiayun_rankings.md.npc_interact
row with group == -1 is not an entry in the menu; it is an instruction about it. Its paramString
either reads 0, which suppresses every default row, or lists row ids separated by |, each of
which is dropped. Fifty-eight characters carry one: 46 are 御者 and 船夫 told not to offer 厨事 /
对饮 / 对弈 / 巧取; seven name row ids the builder never checks; one drops 拜师 and 结缘; one drops
most defaults on a character whose fight rows were already excluded; two 算命先生 switch everything
off. Not one takes 切磋, 挑战 or 劫杀 away from anyone who would otherwise have had it.None of this is a rule you could infer from the person in front of you, and that is the honest summary of the whole surface: the menu is authored, not derived. Of the 11,596 named characters the F prompt will open on, 599 carry any of the three fight rows and the rest carry none. 挑战 is 355 hand-written rows, one per character. 切磋 reaches a named character only through one of the 142 rows written for it, 劫杀 through one of 210. The nine characters offering both 切磋 and 挑战 are nine copies of 太初弟子. Procedural or named, child or adult, on the board or not, bounty target or not — those are the only parts that follow a rule. The trainer's 切磋 is offered on anyone and 挑战 is offered on anyone switches, both off by default, put a row on every person who has none; the trainer guide says what that does to the economy.
The method that states the 好感 thresholds is never called. InteractOnChat.InitData(int npcid) has
no callers anywhere in the assembly, and both call sites of IsReachNpcHaoGan sit inside it. So the
only 好感 requirements the assembly spells out for an interaction, 拜师 at 40, 结缘 and 结义 at 70,
and haogan >= 0 for everything else, describe nothing that runs.
情分索要 falls into the same hole, and what replaces the thresholds is simpler than they were.
npc_interact carries exactly one row for it, on the default group, with no
generated-characters-only flag and a condition that does not resolve. So the menu offers it to
everyone, measured on 121401, 20006733 and 10009 alike. The 好感 50 and 情分 1000 in the unreachable
method gate nothing; whatever validates a request runs when you use the interaction rather than when
the menu is drawn, and that path was not traced.
The three or four buttons you first see (交谈, 交互, 交易, 行为) are a row of buttons. Choosing one opens a different kind of thing:
Both are reversed by the same call RoleInteract.ActivateInteracts(true), which is what the form's
own backdrop is wired to on a first press. Anything that reverses only one of them will appear to work
until you take the other route.
InteractWithNpc keeps the interaction alive for a short list of types (赠送, 传送, 情分索要, 厨事,
巧取, 谈论, 地会) and calls DisableInteract for everything else, 交谈 included. So after a chat there
is no menu behind it to step back to. The game took it down when you chose.
The picker is InteractOther and its 容我再想想 calls RoleInteract.DisableInteract to leave the
conversation outright rather than stepping back a layer. The pane describing the item you hover is a
third view again, held by a field on the picker, which is why hiding the picker alone can leave a page
of item text standing over the world.
MailView.OnRefresh takes a ShaiXuanType of None, HaoGan, ShengFen or Gang, and implements all
four: HaoGan splits the correspondent list into the nine 好感 bands from −100..−75 up to 75..100,
and Gang groups it by sect. The dropdown that drives it offers None and ShengFen. The other two are
written, wired into the refresh, and unreachable from the interface.
The menu is drawn inside the world HUD's form rather than in a panel of its own, so anything that reasons about "the top form" will conclude that no panel is open while you are standing in a conversation.