Compare commits

...
5 Commits
Author SHA1 Message Date
hanfuit c58168406b 启动器:游戏更新入口移到开始旁,移除设置页面
- 侧边栏顺序调整为 开始 → 游戏更新 → GM工具 → 一键换装 → 游戏设置
- 删除「设置」页面(侧边栏入口 + page-settings section)
- shell.js 移除 loadSettings 函数及 setRefresh 事件监听
- shell.css 清理 .settings-* 相关样式(明暗主题两套)
2026-11-04 20:38:22 +08:00
hanfuit 0ced464168 启动器新增游戏设置页(频道开关+活动开关),窗口标题改名
- 服务端新增活动开关运行时配置与 GM 接口(GET/POST /dnf/gm/activity-switches),
  持久化到 Settings,接入月签到/周签到/PC房↔累计在线的实时下发
- 服务端新增频道开关 GM 接口(channel-switches),目录生成时过滤禁用频道
- channelcatalog/channelinfo 支持按禁用频道 ID 过滤目录与客户端脚本
- 启动器新增「游戏设置」页面:频道开关 Tab + 活动开关 Tab
- native.py/server.py 新增对应转发 API
- 窗口标题/页面 title/侧边栏「90轮回」统一改为「DNF90 灵狐小竹整合单机版」
2026-11-04 20:08:50 +08:00
hanfuit 977a4a1196 启动器:移除服务端工具/支援兵/插件页面,支援兵模板独立保存
- 删除服务端工具、支援兵、插件三个页面(侧边栏入口 + HTML section)
- server.py: 删除 servertool(9个)、companion/basic-attack 相关接口及常量
- native.py: 删除无人引用的 get_server_features/save_server_features/
  companion_state/companion_save(保留 GM 工具依赖的 gameplay 方法)
- shell.js: 删除 loadServerTool/loadCompanion 全部逻辑及状态变量
- shell.css: 清理 st-/cp- 相关样式
- templates/支援兵页面模板.md: 支援兵页面完整导出为独立模板
- 侧边栏「更新日志」更名为「游戏更新」
2026-11-04 19:32:01 +08:00
hanfuit 56236496a0 启动器 UI 改用仓库新源码,不上传本机旧 UI 2026-11-04 19:24:41 +08:00
hanfuit 70b3c5a6dc 合并本地领先源码:旧UI、GM疲劳燃烧、白金徽章孔、宠物修复、支援兵普攻开关 2026-11-04 19:20:49 +08:00
70 changed files with 5579 additions and 901 deletions

No files matched your search

+44
View File
@@ -261,6 +261,14 @@ bool CompanionEquipmentProbeEnabled() {
return enabled;
}
// 支援兵普攻开关:启动器在客户端目录放置 90CN_basic_attack.disabled。
// 只影响 CompanionBasicAttack 回退程序,12 格技能程序不变。
bool CompanionBasicAttackEnabled() {
static const bool disabled =
CompanionFlagFilePresent(L"90CN_basic_attack.disabled");
return !disabled;
}
bool CompanionEquipmentApplyEnabled() {
// Production default: the mode1 equipment rows are replayed for every
// companion APC. A disable flag exists for a one-restart rollback.
@@ -1924,6 +1932,42 @@ bool ApplyDynamicRuntimeKeyPrograms(
return false;
}
// 关闭普攻时把 CompanionBasicAttack 的技能码改成空占位码,
// 只禁用技能冷却后的普攻回退,不触碰 0..11 的 12 格技能程序。
if (!CompanionBasicAttackEnabled()) {
void* basicProgram =
programs[Contract::kBasicAttackProgramIndex];
std::uint8_t* basic =
reinterpret_cast<std::uint8_t*>(basicProgram);
if (basic) {
std::uintptr_t basicBegin =
*reinterpret_cast<std::uintptr_t*>(
basic +
g_native.kRuntimeKeyProgramBytecodeBeginOffset);
std::uintptr_t basicEnd =
*reinterpret_cast<std::uintptr_t*>(
basic +
g_native.kRuntimeKeyProgramBytecodeEndOffset);
std::uintptr_t basicCapacity =
*reinterpret_cast<std::uintptr_t*>(
basic +
g_native.kRuntimeKeyProgramBytecodeCapacityOffset);
if (basicBegin && basicEnd >= basicBegin &&
basicCapacity >= basicEnd &&
basicEnd - basicBegin >=
g_native.kRuntimeKeySkillCodeOffset + 4) {
*reinterpret_cast<std::uint32_t*>(
basicBegin +
g_native.kRuntimeKeySkillCodeOffset) =
Contract::kSkillCodeBias +
Contract::kPlaceholderSkillId;
Log("companion basic attack disabled actor=%p program=%p",
actor,
basicProgram);
}
}
}
std::uint8_t* directBytecode[
Contract::kQuickSlotCount] = {};
std::size_t directBytecodeSize[
+95
View File
@@ -0,0 +1,95 @@
// Included inside 90CN.cpp's namespace after its existing patch helpers.
// Only the evidenced UI update iterator call-site is patched, never the CRT.
uintptr_t g_uiIteratorNativeAdvance = 0;
uintptr_t g_uiIteratorAdvanceResume = 0;
uintptr_t g_uiIteratorNextBucket = 0;
volatile LONG g_uiIteratorInvalidations = 0;
int __stdcall CanAdvanceUiUpdateIterator(unsigned int bucket,
const uintptr_t* owner, const uintptr_t* iterator)
{
dnf90::UiIteratorSnapshot s = {};
__try {
s.owner = reinterpret_cast<uintptr_t>(owner);
if (owner && iterator) {
s.proxy = owner[0]; s.iteratorProxy = iterator[0];
s.begin = owner[1]; s.end = owner[2]; s.current = iterator[2];
if (s.proxy && s.iteratorProxy == s.proxy)
s.proxyOwner = *reinterpret_cast<const uintptr_t*>(s.proxy);
}
} __except (EXCEPTION_EXECUTE_HANDLER) { s.proxyOwner = 0; }
if (dnf90::CanAdvanceUiIterator(s)) return 1;
LONG n = InterlockedIncrement(&g_uiIteratorInvalidations);
if (n <= 16) LogLine("ui-update iterator invalidated bucket=%u owner=%08X "
"proxy=%08X iter_proxy=%08X begin=%08X end=%08X current=%08X "
"action=defer_remaining_bucket_until_next_frame hit=%ld",
bucket, s.owner, s.proxy, s.iteratorProxy, s.begin, s.end, s.current, n);
return 0;
}
__declspec(naked) void ProxyUiUpdateIteratorAdvance()
{
__asm {
// Entry is a JMP replacing CALL; result pointer and dummy are on stack.
push eax
push ecx
push edx
push ecx
push dword ptr [ebp - 14h]
push dword ptr [ebp - 1Ch]
call CanAdvanceUiUpdateIterator
test eax, eax
pop edx
pop ecx
pop eax
jz invalidated
call dword ptr [g_uiIteratorNativeAdvance]
jmp dword ptr [g_uiIteratorAdvanceResume]
invalidated:
// Native ++ would have consumed two arguments (ret 8). Do not compare
// an invalid iterator again; the next bucket constructs a fresh one.
add esp, 8
jmp dword ptr [g_uiIteratorNextBucket]
}
}
bool InstallUiIteratorAdvanceCompatibility()
{
const uintptr_t site = g_dnfBase + 0x02BF2732;
static const unsigned char kLoopContract[] = {
0x8B,0x0F,0x8B,0x11,0x8B,0x42,0x10,0xFF,0xD0,0x6A,0x00,
0x8D,0x4D,0xB4,0x51,0x8D,0x4D,0xD4,0xE8,0x19,0x02,0x7F,0xFD,
0xE9,0x77,0xFF,0xFF,0xFF,0x8B,0x45,0xE4,0x83,0x45,0xEC,0x14,
0x40,0x89,0x45,0xE4,0x3D,0x87,0x04,0x00,0x00,0x0F,0x8C,0x55,0xFF,0xFF,0xFF
};
static const unsigned char kBeginContract[] = {
0x55,0x8B,0xEC,0x8B,0x45,0x08,0x8B,0x51,0x04,0x8B,0x09,
0xC7,0x40,0x04,0,0,0,0,0x89,0x50,0x08,0x89,0x08,0x5D,0xC2,0x04,0
};
static const unsigned char kAdvanceContract[] = {
0x55,0x8B,0xEC,0x8B,0x45,0x08,0x8B,0x11,0x56,0xC7,0x00,0,0,0,0
};
if (!BytesMatch(reinterpret_cast<unsigned char*>(g_dnfBase+0x02BF2720),
kLoopContract,sizeof(kLoopContract)) ||
!BytesMatch(reinterpret_cast<unsigned char*>(g_dnfBase+0x00A44ED0),
kBeginContract,sizeof(kBeginContract)) ||
!BytesMatch(reinterpret_cast<unsigned char*>(g_dnfBase+0x003E2950),
kAdvanceContract,sizeof(kAdvanceContract))) {
LogLine("ui-update iterator compatibility disabled: native signature mismatch");
return false;
}
g_uiIteratorNativeAdvance = g_dnfBase + 0x003E2950;
g_uiIteratorAdvanceResume = g_dnfBase + 0x02BF2737;
g_uiIteratorNextBucket = g_dnfBase + 0x02BF273C;
DWORD oldProtection = 0;
if (!VirtualProtect(reinterpret_cast<void*>(site),5,PAGE_EXECUTE_READWRITE,&oldProtection)) {
LogLine("ui-update iterator compatibility protection failed error=%lu",GetLastError());
return false;
}
unsigned char jump[5] = {0xE9};
*reinterpret_cast<int32_t*>(jump+1) = static_cast<int32_t>(
reinterpret_cast<intptr_t>(&ProxyUiUpdateIteratorAdvance)-static_cast<intptr_t>(site+5));
memcpy(reinterpret_cast<void*>(site),jump,sizeof(jump));
DWORD ignored = 0;
VirtualProtect(reinterpret_cast<void*>(site),5,oldProtection,&ignored);
FlushInstructionCache(GetCurrentProcess(),reinterpret_cast<void*>(site),5);
LogLine("ui-update mutation-safe iterator installed site=%08X next_bucket=%08X",site,g_uiIteratorNextBucket);
return true;
}
@@ -0,0 +1,703 @@
'� � �
�
�
�
�
�
�
�
� � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � �#%� � � � � � � � � � � � � �뇦�MNN�MNN�MNN�utr�utr�utr�MNN�MNN�MNN�MNN�뇦�
� � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � �  � �뇦�MNN�MNN�MNN�MNN�utr�utr�utr�MNN�MNN�MNN�뇦� � � � � � � � � � � � �뇦�
� � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � �뇦� � � � � � � � � � � � ���撞�����������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������utr�utr�MNN�MNN�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�MNN�MNN�MNN�MNN�MNN�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�MNN�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�MNN�MNN�MNN�MNN�MNN�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�MNN�MNN�utr�utr� �MNN�MNN� �utr� � �utr� � � � � � �MNN� � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � � �MNN� � � � � � �utr� � �utr� �MNN�MNN� � �뇦� �MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN� � �蔽$�遡q�蔽$�蔽$�蔽$�駭)�駭)�駭)�蔽$�駭)�駭)�駭)�駭)�駭)�蔽$�駭)�駭)�蔽$�蔽$�蔽$�蔽$�駭)�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�駭)�駭)�蔽$�蔽$�蔽$�蔽$�蔽$�駭)�駭)�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�駭)�蔽$�蔽$�蔽$�蔽$�蔽$�駭)�蔽$�駭)�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�駭)�蔽$�蔽$�蔽$�蔽$�駭)�駭)�蔽$�駭)�駭)�駭)�駭)�駭)�蔽$�駭)�駭)�駭)�蔽$�蔽$�蔽$�遡q�蔽$� � �MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN�MNN� �뇦� �MNN�utr�MNN�MNN� � � � � � � � � � � �蔽$�駭)�駭)��+��+�棋_�棋_�棋_�棋_��+��s*��s*��s*��s*��x,��+��s*��s*��+��+��+��x,��+��+��s*��x,��x,��+��s*��+��s*��+��x,��x,��+��+��+��+��+��+��s*��s*��+��+��+��+��+��x,��s*��s*��+��x,��+��+��s*��s*��+��+��+��s*��+��x,��x,��s*��+��+��x,��+��+��+��s*��s*��+��x,��s*��s*��s*��s*��+�棋_�棋_�棋_�棋_��+��+�駭)�駭)�蔽$� � � � � � � � � � � �MNN�MNN�utr�MNN� �MNN� �MNN� �蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�駭)�駭)��+��+��s*�쮟3�똣4�냍0�냍0�냍0�냍0�똣4�켷G�켷G���������oP1�����냍0�냍0�냍0�냍0�냍0�냍0�냍0�����냍0�냍0�냍0�냍0�����냍0�oP1�냍0�냍0�냍0�냍0�����냍0�냍0�냍0�냍0�����냍0�����냍0�냍0�냍0�냍0�냍0�냍0�냍0�oP1�oP1�����냍0�냍0�냍0�냍0�냍0�냍0�oP1�냍0�����냍0�냍0�냍0�냍0�냍0�냍0�냍0�냍0�냍0�����냍0�냍0�냍0�����냍0�똣4�켷G�켷G�똣4�냍0�냍0�냍0�냍0�똣4�쮟3��s*��+��+�駭)�駭)�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$�蔽$� �MNN� �MNN� � �뇦� �MNN� �蔽$�蔽$�駭)��+��+��+��+��s*�쮟3�쮟3�냍0�oP1�M,�M5(�M5(�M5(�M5(�M5(�M5(�������������M5(�M5(�����M5(�0$�����M5(�M5(�M5(�����M5(�M5(�M5(�M5(�M5(�����M5(�M5(�M5(�M5(�M5(�M5(�����M5(�M5(�M5(�M5(�����0$�M5(�����M5(�M5(�0$�M5(�����M5(�M5(�M5(�����0$�M,�0$�M5(�M5(�M5(�M5(�M5(�����M5(�M5(�M5(�M5(�M5(�M5(�M5(�M5(�M5(�����0$�����������������������������M5(�M5(�M5(�M5(�M5(�M5(�M,�oP1�냍0�쮟3�쮟3��s*��+��+��+��+�駭)�蔽$�蔽$� �MNN� �뇦� �MNN�뇦� �MNN� �蔽$��+��s*�쮟3�냍0�냍0�냍0�냍0�sK�M,�M5(�,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�����,**�,**����� � � �����,**���������������������,**�,**�����������������,**�,**�,**�������������,**�,**����� �,**�,**�,**�,**�,**� �,**����� �,**�����,**� �,**�,**�,**�,**�,**�,**�����,**�,**� �,**�,**�,**�,**�,**����� � � �,**�����,**� �,**�,**�,**�,**�,**�,**�,**�,**�,**�,**�M5(�M,�sK�냍0�냍0�냍0�냍0�쮟3��s*��+�蔽$� �MNN� �뇦�MNN� �MNN�MNN� � �駭)��s*�냍0�YF/�M5(�M5(�YF/�0$�M5(�,**�,**�) �,**�,**�,**� �,**�,**�,**�,**�,**�����,**� �����,**� �,**� � �����,**�,**�,**�����,**�����,**�,**�,**�����,**� � �����,**�,**�,**�,**�����������������,**�,**�,**�,**�,**�,**�����,**�����,**�,**� �,**�����������������������������������������,**�,**� �����,**� �������������������������,**�,**�,**�,**� �,**�,**�,**�) �h딧�h딧�M5(�0$�YF/�M5(�h딧�h딧�냍0��s*�駭)� � �MNN�MNN� � �MNN� � �蔽$��+�냍0�M5(�,**� �,**�,**� �,**� �,**�,**�,**�,**� �,**� �,**��������������������������������������������� �����,**�,**�,**���������,**�,**�,**�,**�����,**�,**�,**�����,**�,**������������� �,**�,**� �,**�����,**�,**�,**�,**�,**����� �,**�,**�,**�,**�,**�,**�,**�����,**�,**�,**�,**�����,**�,**Line truncated
�
�
�
�%FO�U「�Q넄�+"�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �0�0�2!�2!�2!�2!�2!�0�/�0�0�*�*�*�*�*�*�*�(�*�(�+�+�(�(�(�*�*�*�(�(�(�*�*�,�(�(�(�*�*�(�*�(�*�*�*�*�*�*�*�(�*�(�+�+�(�(�(�*�*�*�(�(�(�*�*�,�(�(�(�*�*�(�*�(�*�*�*�*�*�*�*�(�*�(�+�+�(�(�(�+�[윸�2`m�
�
)Z�Q�b�b�Q�
)Z�
�2`m�[〕�+�)�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �/�0�2!�2!�2!�0�0�0�0�0�0�/�,�,�*�*�*�*�(�*�(�*�+�(�*�*�*�*�(�(�(�(�(�*�*�+�+�(�*�*�(�*�(�*�*�*�*�*�*�*�(�*�(�*�+�(�*�*�*�*�(�(�(�(�(�*�*�+�+�(�*�*�(�*�(�*�*�*�*�*�*�*�(�*�(�*�+�(�*�)�P꼺�5dr� �O� V�.`����,]�O� �5dr�R꼹�*�*�*�*�*�+� �뇦� �MNN�MNN� �뇦� �/�0�0�0�0�0�0�0�2!�2!�0�0�0�-�-�,�(�(�(�(�(�(�(�(�(�(�(�(�*�*�*�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�*�*�*�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�365�U「� �L� k�-\�뀶�븝�븝�븝��:y�L� �U「�587�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �/�/�/�0�2!�2!�2!�0�2!�2!�0�0�/�/�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�O걥�%FO�
)Y�a�?��e깒�븝�Ldk��샗�⒩� �a�
)Y�#BL�O걤�+�+�*�+�+� �뇦� �MNN�MNN� �뇦� �/�0�/�0�2!�2!�2!�0�0�/�/�0�0�-�,�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�b꽈�
�J�
a�6�샗�샗�6�4i�e깒�븝��
a�J� �b꽃�+�*�*�*�+� �뇦� �MNN�MNN� �뇦� �-�0�0�0�0�0�0�0�/�/�0�/�/�-�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�b밉�
�R�Z�:w�1�1�#J��⒩�샗�1�Z�R�
�b밉�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �0�0�2!�2!�0�0�0�/�0�0�/�,�,�,�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�E��
�N� R�K�K�3j�Ldk�븝�샗�,�3k� R�M��C}�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �0�0�2!�2!�0�0�0�/�0�0�/�-�,�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�6Zi��B� K�C�A�(�샗�샗�(�,_�C� K�B��6Yg�+�*�*�+�+� �뇦� �MNN�MNN� �뇦� �,�/�/�/�/�0�/�/�/�/�-�-�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�2EN�#)�
$Q�E�>�8}�%T�#��D�8}�>�E�
$Q�!'�2EM�*�*�+�+�+� �뇦� �MNN�MNN� �뇦� �-�/�/�0�0�0�0�/�-�-�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�,�,�,�,�+�+�+�+�+�+�+�+�*�*�*�*�+�+�*�+�+�+�+�,�+�,�,�,�,�,�,�,�+�+�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�-*'�,Q`� �<� @�7|� !�샗�⒩��7|� @�<� �,Q`�-)%�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �/�-�/�/�0�0�/�/�-�/�-�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�+�+�+�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�*�*�*�)�3KS�5?�
�9�=�%�샗�⒩��=�9�
�5?�4LT�+�+�+�+�*�+� �뇦� �MNN�MNN� �뇦� �/�-�,�-�-�/�/�/�/�/�-�,�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�+�+�+�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�+�*�*�*�*�+�+�+�*�*�*�*�*�*�*�)�)�9\h�6?� �
!L� &X�)�� H�
!L� �6?�8]i�+�+�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �-�-�,�,�-�/�/�/�-�,�,�,�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�+�+�*�*�+�+�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�*�*�*�*�*�*�*�*�*�*�*�*�*�)�)�*�7Q[�3_p�)0��� ��)0�3_p�8T^�+�+�+�+�+�+�*�+� �뇦� �MNN�MNN� �뇦� �-�-�-�,�,�,�,�,�,�,�,�*�*�*�*�*�*�*�*�*�*�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�%!#�(9�,L� 0Z�2g�6p�8v�:|�:|�8v�6p�3g� 1[�,K�'9�# "�*�*�*�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�*�+�)�*�*�)�,+(�8S]�>o�;p�;n�>m~�8S]�.-*�+�+�+�+�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �,�-�-�/�0�0�/�(�(�-�/�/�/�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�'�"/�,R�5p�M�.g�A{�F궹�K눻�O뚒�J뗓�O뤫�W쇹�U쀠�Y쇸�V뺎�P띝�D��0j�M�5p�,R�"/�'�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)�(�(�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �,�,�,�-�/�0�/�-�(�,�,�,�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�#�*I�;~�$_�D�M뚒�J뤫�I롕�J먝�J믛�F띧�I뤬�E}�2K�2K�P눬�Y쒋�Z싞�X싟�X쎺�]잋�^졿�\쒊�K눵�$_�;~�*I�#�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)�(�(�)�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �-�-�,�,�-�-�/�/�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�%#)�3W�N�B}�Q먚�C눾�A댲�J볖�J뫽�N볖�R쀟�M먝�F듫�G됓�+Ow�3O�3O�(Mw�3|�.x�,v�-w�.x�2w�:��G듫�S붭�X뽡�C~�J�-R�$"(�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�-�,�+�,�+�+�*�*�*�*�*�*�+�+�+�+�+�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �,�-�-�.�,�,�,�,�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�)!"�0S�X�?j�Fu�F눾�@됖�L붯�S쀠�R볓�G댮�<��4x�.u�.x�5~�5f�!>_�!>_�8i�G롗�F롖�O뽤�L붱�G먟�A뗙�9녧�2~�5��3z�B끦�[샽�N댪�R�.P�("#�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�-�,�-�,�,�*�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �,�-�-�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�*F�A�E�<d�)@�)@�6e�L믙�N뫺�C꺧�1u�)n�+n�!]�M�?u�
5h�<m�Bn�Bn�<m� 6h�@u�P�*b�9z�J먝�J믛�M붱�N뺖�N뺖�6c�(>� ;\�L눵�C{�A�*F�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�-�,�-�,�,�*�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �,�-�-�+�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(� )�,S�(b�U믗�P뤫�+Nw�9W�8V�2W�B꺨�/s� \�G��(W�-]�2d� 7i�:n�<q�?s�Cx�Cx�?s�<q�:n� 7i�2d�-]�(W�H��.i�H뚓�T섁�1K�6R�1L�4e�5{�E꾅�(b�,S� )�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)�(�(�)�)�*�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �+�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�*?�?�B~�O뤫�@꺫�;꺪�B}�)Jp�)Kr�;p�Q�)X�0`� 8j�>q�@t�Au�Au�Av�Bv�Bv�Bw�Bw�Bv�Bv�Av�Au�Au�@t�>q� 8j�0`�)X�.c�-Ou�"Ad� =\�O됅�L먟�>눿�>끨�=y�?�*?�,�,�,�,�,�,�,�,�,�,�,�,�,�+�*�+�*�+�)�*�*�*�*�*�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �,�+�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�+H�J�Q롍�P먛�>곉�9곍�G띢�J롒�I끟�!V�1`� 5f�=q�?t�@v�Aw�Bw�By�Cy�Cz�Dz�D{�D{�D{�D{�Dz�Cz�Cy�By�Bw�Aw�@v�?t�=q�9j�=m�/Z�H~�L먝�H뚓�P볔�M뫾�G띥�D꺥�J�+H�*�*�*�*�*�*�*�*�*�*�*�*�)�)�*�)�)�(�)�)�)�)�)�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)H�T�S믖�Q뫸�>곉�4|�L먜�M롐�"V�*X�
4e�>r�@t�Av�Bw�Cx�Dz�Cp�!AR�)>6�/<#�3:�69 �88�88�69 �3:�/<#�)>6�!AR�Cp�Dz�Cx�Bw�Av�Au�@t�9j�.]�&[�K뚒�G뚒�O믙�T쀝�D뚕�C꾅�S�)H�(�(�(�(�(�(�(�(�(�(�(�(�(�)�(�(�'�'�(�(�(�(�'�'�'�'�'�'�'�'�'�'� �뇦� �MNN�MNN� �뇦� �)�)�+�-�/�0�/�/�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)H�V�\쇵�Q믗�H듨�9곋�B댲�=|�N�/_�;o�?s�@u�Bw�Cx�Cq�$?D�1;�66�01�+-�'*�$'�"%�!%�!%�"%�$'�'*�+-�01�66�1;�$?D�Cq�Cx�Bw�@u�?s�<o�/_�I�:v�K롒�T쀞�S쀞�N믙�E녟�U�)H�)�)�)�)�)�)�)�)�)�)�)�)�*�(�)�' �' �' �' �' �' �' �' �' �' �' �' �' �' �' �' � �뇦� �MNN�MNN� �뇦� �,�,�,�+�0�0�/�-�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�+G�V�\싛�V쀝�M롏�4~�J뫻�.f�+X� 5e�>q�?s�Au�Bw�Ah�'=7�66�./�&(�$&�13�@@�JI�SP�XU�[X �[X �XU�SP�JI�@@�23�$&�&(�./�66�(=4�Ae�Bw�Au�?s�>q� 5e�+X�.e�O믗�Y싞�\쎷�P붫�>��S�)E�.�,�.�.�,�,�,�,�,�,�*�+�*�+�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �-�+�-�-�/�/�/�-�)�)�)�)�(�(�(�)�)�)�)�)�)�)�)�)�)�)�)�%;�Q�O궩�@j�T먓�9�G뤪�.c�,Y�9j�>q�?s�Au�Aq�&<8�54�+-�"&�24�HG�_\ �rn�뀅%�뵍1�쥮>�エH�괸O�괵M�ぇE�젦;�뵍0�뀅%�ro�_\ �HG�24�"&�*,�54�&<8�Aq�Au�?s�>q�9j�,Y�.b�S볊�^왺�_졻�H뚑�J댫�L�&;�)�)�)�)�)�)�)�)�)�)�*�' �'�' �' �' �' �' �' �' �' �' �( �(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �-�/�+�/�/�0�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�%� C�Q꽴�+B�+B�!<[�@됕�-a�,Z�:k�=q�?s�Av�!=H�66�-.�#&�9:�\Y
�uq�뼇8��J�병[�좀j�횬v�股�路�畓�畓�騏�箝�퓻t�윽e�릴U��G�븧5�so�\Y
�9:�#&�-.�66�!=H�Av�?s�=q�:k�,Z�/a�^앦�.Ln�%9�%Cf�M녚�A�$�(�(�(�(�(�(�(�'�(�)�' �'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'� �뇦� �MNN�MNN� �뇦� �0�0�-�/�0�0�0�.�.�/�/�/�.�.�.�.�.�.�.�.�.�.�(�)�)�(�+Q�L끚�Hy�6S�;[�6Q�7n�,Z�:l�=p�?s�@u�%;8�23�&)�46�PO�}y'�│I�뭅^�컁q�膏�驥�畓�沓�畓�頓�錟�錟�沓�露�錟�騏�邱�愷|�쩌m�럴X�짛D�zw$�NM �46�&)�23�%;7�@u�?s�=p�:l� .]�7f�0J�7U�0J�:s�D~�,Q�(�)�+�(�(�(�)�)�*�,�*�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,� �뇦� �MNN�MNN� �뇦� �2!�0�2!�2!�0�0�0�0�/�/�0�/�/�+�+�+�+�+�+�.�+�.�(�)�,�'C�*d�R믖�Y쀚�5Z�'In�5a�7d�8i�<o�>q�@t�&:2�/0�$(�BC�li�줉G�섯e�珏�駑�路�路�根��匡y�訣q�迦e�퓟]�탁X�타Y�횐_�介g�皐p�光y�根��饑�騏�芹�프x�뭅\�쐶>�hd�BC�$(�/0�&:2�@t�>q�<o�9k�>k�&Eg�#Be�(Hk�D됑�<~�*c�(D�(�(�+�(�)�.�)�)�+�(�,�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �2!�0�2!�2!�2!�0�0�0�0�0�/�-�(�(�(�(�(�(�)�+�.�.�(�)�(!#�@�S뫵�M뫹�P믖�@끥�?겾�5b�8i�;n�=q�?s�%80�-.�*,�GF ��.�뎡]�첸r�謙�慤x�퓸n�탈i�캡b�첨[�쨉V�천S�척L�캑H�척G�처G�척I�캑K�캔Q�천U�첨Z�캣`�탈g�퓸o�慤x�愷}�은j��P�{x$�ED�*,�-.�%80�?s�=q�<o�<m�>k�Gz�`쒃�R샽�-v�I끠�@�"�(�)�(�)�.�)�)�*�+�*�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �2!�0�0�0�2!�/�/�0�0�/�-�)�)�)�)�(�(�(�(�)�+�.�(�)�-P�Az�R볎�Y싙�@끥�:꺩�'X� 3b�:l�<o�>q�$81�,.�.1�c`�몠<�릿^�커s��w�캥j�윳\�쓩T�쉼P�쉴L�쒼J�육F�윤C�챵@�캐=�캐;�캑<�캑<�처=�짼@�윤D�엽H�쒼K�쒼N�엾U�융^�캬j�횰v�쩌l�눕T�뙃3�_\�.1�,.�$81�>q�<o�;m�5d�)Z�Y싛�U샽�:꺪�2t�Bz�,O�)�(�(�)�.�)�)�+�*�(�(�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �0�0�2!�0�0�/�0�0�/�-�+�/�)�)�)�)�)�)�(�(�+�.�(�!&�L�R뫷�N먙�J뚎�7�,a�
/\�9k�;m�=p�#75�-/�-1�da�뿎C�릿_�첵n�청i�쉿X�변O�벽M�벼K�벵K�벵G�쉰C�쒜@�육<�琡�鋏�琡�틂1�博�町�穆�유<�쒜A�서E�벵H�벼K�벽M�변N�쓴[�청k�좀k�눗V�뮀9�`^�-1�-/�#75�=p�;m�9k�
/\�2e�X싙�D뗕�)q�I꾁�L�%�)�(�)�,�)�)�+�(�(�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �0�/�0�0�0�0�0�/�/�/�/�/�-�+�(�(�)�)�)�(�+�.�(�,Q�E�S붪�S볊�5z�A궶� -X�6f�9k�<n�8C�./�(+�QP�뵎B�뎠Z�슐a�섧\�릭P�랸K�략H�랴G�랴F�랴D�릉B�벴>�쉭9�졈:�莢�和�和�홴)�杭�和�標�졀9�쉭:�벴?�릊B�랴E�랴G�략H�릎K�묻Q�섧]�섬`�낟U�룎:�LK�&)�./�8C�<n�9k�6f� -X�O띘�P뺎�6곋�+n�D|�,Q�(�(�)�,�)�)�+�(�)�)�)�(�(�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �0�0�0�0�/�0�/�/�0�/�-�-�+�*�*�(�(�)�)�(�+�.�#�I�R뫵�T뺊�J뚎�>꺧�+]� 1_�7i�9l�:j�//�+-�MM�냳7�괸S�럭W�묻S�땝I�뎀F�델D�뇽C�뇻B�뇻A�뇹@�덴=�릇:�섕6�횐P�迪�薑8�外�홴%�迹�聃Y�桎�획M�뺄6�릇:�덴>�뇻@�뇻B�뇽C�델D�뎀F�땝K�별V�럭W��O��~/�II�(+�//�:i�9l�7i� 1_�/_�Y쎵�I뤪�(o�D�I�#�(�)�,�)�)�,�(�)�)�(�(�(�)�)�)�)�(�(�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �2!�0�0�/�/�/�/�/�-�+�+�(�(�(�(�*�(�)�)�(�+�.�-M�G�V뺎�P뫷�2u�A궴� .X�5e�8i�9l�"32�-.�AB�uq)�ЖN��K�눋K�꺽F�괜C�괘@�괘?�광>�괏>�괏=�괏<�낄:�때7�벳2��R�蕁e�蒼�澧\��"�襪b�音�栗p��R�벱2�덱7�낄:�괏<�괏>�광>�괘?�괘@�괜C�꺽F�뎅M��K�ぇL�pl"�<=
�,-�"32�9l�8i�5e� .X�V볂�Q붫�+t�3r�By�,K�(�)�,�)�)�,�(�)�)�(�(�)�(�(�(�(�(�(�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �/�0�0�/�-�/�/�-�-�+�(�(�(�(�(�*�(�)�)�(�+�- �K�3T{�6P�:e�9~�*]�1^�6g�8j�7[�,,�/3
�][�쐶C�ªA�괬G��C��@��=��<�췅:�췃:�췃9�췃9�츪8�가6�끽3�름/�潔Z�暾e�和�背o�� �朗Z�和�槁O�鱇Z�름/�끽3�가6�츪8�췃9�췃:�췅:��<��>��@��C�괬I�ĦC�슄?�XV�+.�,,�7Z�8j�6g�1^�4e�Y쒆�?w�'Lu�Dq� E�'�)�.�)�)�+�(�)�)�(�(�)�(�(�(�(�(�(�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �/�/�/�/�-�-�+�+�-�-�+�(�)�(�(�*�(�)�)�(�+�#)7�/]�.G�3O�.F�I뗉�/X�4c�6h�8k�"1.�-.�II�{.�ℓC�ぃA��A�А=�쳽:�첑8�찟6�찞6�찞5�찞5�찞5�찞3�쵞2�가/�땋+�痼a�和�出�和�풍�和�和�和�缺_�땅+�가/�쵞2�첏4�찞5�찞5�찞6�찟6�첑8�쳽:�А=��B�イC�ℓC�|x)�CD �,.�"1-�8k�6h�4c�/X�]샭�-D�.G�,B�0b�#0�)�,�)�)�+�(�)�)�(�(�)�(�)�)�(�)�)�)�)�)�(�(�)� �뇦� �MNN�MNN� �뇦� �/�/�/�-�/�-�-�-�+�+�(�)�)�(�(�*�(�)�)�(�+�1W�K}� <[�"@b�$Ac�:t�1[�4d�6g�7b�++�03
�gc�쐷?�짗;�ィ@�쯃<�쯀8�쬄6�쬂4�쪠3�쪟2�쪟2�쪟2�쪟2�쬁0�쮾/�췂,�끽(�錤n�箱�홴-�緣�틀�蔭�풩*�軾�懃l�끽(�췂,�쮾/�쬂0�쪟2�쪟2�쪟2�쪠3�쬂4�쬄6�쯀8�쯃<�ィA�쩅<�썥>�b_�-/�**�7b�6g�4d�1[�L�6T�!>_�6T�Fw�-S�)�,�)�)�+�(�)�)�(�(�)�(�)�)�*�(�)�)�)�)�(�(�)� �뇦� �MNN�MNN� �뇦� �-�-�-�-�+�+�,�(�(�*�(�(�(�(�(�*�(�(�)�(�+�7p�[볆�G��=l�>y�*V�2^�5e�7g�1;�--�AB�wu'�쩆?�짔9�쭫<�쪢7�짌3�쥥1�죾0�죻/�죻/�죻/�죻/�죻/�짉-�쪟,�쳺*�깻&�勒��決O�替�蠟W�틂�澧]�足�痼T�隷}�깼&�쳺*�쬂+�쥤-�죻/�죻/�죻/�죻/�죾0�쥥1�짌3�쪢7�쭫;�쩄:�짘>�vr#�=> �+,�1;�7g�5e�3_�1]�Gx�-V�4b�[붥�6o�)�,�)�)�+�(�)�)�(�(�)�(�)�)�(�(�(�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �+�-�,�,�+�,�*�*�*�*�*�-�-�+�*�*�(�(�)�(�%"�K�R먖�B눻�N띜�.o�0X�3a�5e�6g�$*�.0�TR�럩3�윓7�쩃9�짎5�죾2�윍/�옊-�쓳,�쓳,�쓳+�옉,�옉,�쓳+�젗*�짉)�첎'�깻#�璹�槁M�杭�例[�틀�隙D�杭�枯L�屑�깻#�첎&�짉)�윋)�쓳*�옉,�쓳,�쓳,�쓳,�옊-�윍/�죾2�짎5�쩃:�윓7�뜈2�QO�-/�$*�6g�5e�3a�1Z�M뤧�8~�*j�T뫳�K�#�,�)�)�+�(�)�)�(�(�)�(�)�)�(�(�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�+�-�/�/�-�(�+�+�(�(�*�(�(�)�(�&8�-e�N뚎�K뤧�I듡�+i�0Y�2b�4e�5^�((�36�ea�윖<�쓼5�줂6�윎1�쐭-�쉹*�쉸)�쉷(�쉷(�쉷(�솉(�쉷(�썚'�쐫&�죺%�찚"�뇩�珽�枯?�抗�抗�튿�抗�抗�暠@�迹�뇩�첍#�쥟%�쓲&�썚'�솉(�솉(�쉷(�솉(�쉸)�쉹*�쐮-�윎1�줃6�쓼5�윖<�c_�14�''�4\�4e�2b�0Y�C�3}�a�N뚌�-d�$5�,�)�)�+�(�)�)�(�(�)�(�)�)�(�(�)�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�+�-�+�+�+�(�(�(�(�*�(�)�)�(�+K�Ay�G됍�P믔�A꾁�([�0[�2b�4e�/C�*+�@A �so �씂7�쐳2�옏2�쉻,�삇)�뼂'�븡&�븡%�븡%�븡%�븡%�븡$�뿃#�쐪#�죺!�찚�쒜3�標�講:�標�標�짯�標�標�鵑=�標�쉭0�쳹�죺!�썜#�뿃$�븡$�븡%�뻿%�뻿%�븡&�뼂'�삆)�쉻,�옏2�쐳3�씂7�rn�=>
�)*�/B�4e�2b�0[�9j�;곉�"d�H꽾�@w�)H�,�)�)�+�(�)�)�(�(�)�(�)�)�(�(�)�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�,�,�+�,�,�)�)�(�(�*�(�)�)�(� 0[�M녍�D눸�L뤧�9}�N�1\�3c�4e�+-�--�KJ��~(�솗4�슂2�솑.�븤*�뱣&�뭸$�몘$�몘$�몘$�몘$�몘#�뭸#�븞 �뿂�쓯�첌�狼r�跛�抉B�跛�跛�젱�跛�跛�횅5�跛�瑙j�찙�윇�솉�븠#�뭷#�몘#�몘$�몘$�몘$�뭸$�뱣&�븣*�솑.�슂3�슄5��~(�IH �+,�+-�4e�3c�1\�.Z�?녟�_�@|�N녎� /Z�,�)�)�+�(�)�)�(�(�)�(�)�)�(�(�)�(�)�)�(�(�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�,�+�-�-�-�+�)�(�(�*�(�)�)�(�2f�T뚇�H듡�K뤥�.r�Es�1]�3c�5e�)�.0�RP�땲.�뿋2�삊/�븤+�몚&�럢#�뛽"�뛼 �뛼 �럟 �룂 �룂���럞�뿁�쨺�뇨�音�坼�坼�坼�坼�윕�坼�坼�坼�坼�羽�깹�짃�뿁�몗�몖�릢�럠 �럟 �뛼 �뛽!�럢#�몚&�븤+�삊/�뿋2�땲.�QO �-/�(�5e�3c�1]�$Kv�F뗎�c�9x�V뤝�2f�,�)�)�+�(�)�)�(�(�)�(�)�)�(�(�)�(�)�(�(�(�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�,�,�,�+�,�(�(�(�(�*�(�)�)�(�6o�U먒�K띛�R붨�0v�<e�1_�3c�4e� $�13�XU�뱪4�뵍1�뵊-�릧'�똽#�뒃�����똼�뛼�똻��~�뛻�븞�젒�캕�講E�瑃�瑃�瑃�瑃�瑃�엮�瑃�瑃�瑃�瑃�瑃�회:�츦�윇�뵁�럞�뛽�룄�똼�땫�땬�땫�뛽#�릧'�뵊.�뵍1�뱪4�VS�02�#�4e�3c�1_�>f�C댭�b�A~�\뽗�6p�,�)�)�+�(�)�)�(�(�)�(�(�(�(�(�)�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �,�*�*�,�,�,�*�*�,�,�*�*�*�*�*�*�(�)�)�(�=y�\믅�8a�G{�5z�8^�1`�3d�4e�!�36�[V�뼋6�뭾/�뭽+�뛿$�뒃 �����럟�릢�뢾�뢿�뱟�뿁�죸�츧�챰,�音�涕�涕�涕�涕�涕�쑵�涕�涕�涕�涕�涕�嵬�윗#�췀�젒�뿁�몖�몖�뭷�룄�뛼�뛼�똽 �럣$�뱦+�뭾.�뼋6�ZU�25� �4e�3d�1`�8_�B눺�+e�?n�`쇭�;y�,�)�)�+�(�)�)�(�(�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�(�)�,�,�,�(�(�(�(�(�(�(�(�(�*�(�)�)�(�F�%>Z�,D�*@�7o�7\�1a�3d�4f��47�\W�솘8�릩.�뜃(�땬#��~��~�럞�봿�뻽�솆�쓮�젏�찙�천K�裴�適�蒼�蒼�蒼�蒼�蒼�蒼�쉠�蒼�蒼�蒼�蒼�蒼�蒼�靷�隷s�쉰<�쪜�윇�썙�삃�뿁�뱡�룂�똼�뛾#�럥(�릩-�삎7�\W�36��4f�3d�1a�7\�@y�)@�(>�0Mn� F�,�)�)�+�(�)�)�(�(�)�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �(�(�(�*�,�,�(�(�(�(�(�(�(�(�(�*�(�)�)�(�H�8U� <\�6T�7h�8\�1a�3d�4f��47�\W�뼌6�럧.�뒆'�� ��~�땫�뱡�쐩�짅�랬9�睾o�횔W�퓔H�桎�桎�桎�桎�桎�桎�桎�桎�桎�섄�桎�桎�桎�桎�桎�桎�桎�桎�桎�획M�캘N�횝a�괌+�짃�쓮�븞�뛽�땬 �뜂&�럧-�뼌6�\W�36��4f�3d�1a�;`�2b�5R�:Y�5P�I�,�)�)�+�(�)�)�(�(�)�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �(�(�(�*�(�)�)�)�)�)�)�(�(�(�(�*�(�)�)�(� @|�Jy�*Mt�7^�<}�:`�1`�3d�4f��47�[V�뱪4�뜆.�늹&��~ ��}�릢�변[�凜�飮�卽�卽�芋�섄�詣�卽�卽�卽�卽�卽�卽�卽�兪�빻�音�卽�卽�卽�卽�卽�卽�卽�芋�섄�詣�卽�卽�譽�錟�땝Q�뭷�똽�땯%�럦-�뱪4�[V�36��4f�3d�1`�=c�5v�%Gl�#Be�Bl� A}�,�)�)�+�(�)�)�(�(�)�(�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�(�(�*�(�)�)�)�)�)�)�)�)�(�(�*�(�)�)�(�8q�Y볂�I뗉�Y싖�:|�<d�1`�3d�4f��35�XS�럨2�뙂.�뀅&��}��}�몖�搏�鏃�鏃�鏃�鏃�鏃�鋼P�僅`�鏃�鏃�鏃�鏃�鏃�鏃�鏃�翔�벨�裳�鏃�鏃�鏃�鏃�鏃�鏃�鏃�瑙f�跏J�鏃�鏃�鏃�鏃�鏃�輩�뵂�땭�뎱%�뜄.�럨2�XS�24��4f�3d�1`�@g�.t�/m�V먒�]붢�7q�)�)�)�+�(�)�)�(�(�)�(�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�(�(�*�(�)�)�(�(�(�(�(�(�(�(�*�(�)�)�(�2f�R듔�@궹�J뚏�7y�Eq�2_�3d�4f� �.0�RN �뀇,�뙃/��&��|��|�릢�쉼Q�足�足�足�足�足�續�섐�掌�足�足�足�足�足�足�潔V�벨�适^�足�足�足�足�足�足�掌�섐�鮮�足�足�足�足�足�엿W�뵃�땭�늺%�뜆.�뀇,�RN �.0� �4f�3d�2_�"Is�$j�'d�T뫱�R듔�2e�.�)�)�+�(�)�)�(�(�)�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �*�*�*�*�(�)�)�(�(�*�*�*�*�*�*�*�(�)�)�(� 0[�H�A꺢�F됎�4v�"N��3]�4d�5f�%-�*+�KJ �{x&�뙄0��&�z�굖�뛻�쨼�芋�珽�珽�珽�珽�珽�졈3�率z�珽�珽�珽�珽�珽�庵�츩�쮻�캗�縯�珽�珽�珽�珽�珽�妄��졀.�珽�珽�珽�珽�珽�兪�쬀�몘�늸�뇗%�뜇/�{x&�KJ �*+�%-�5f�4d�3]�)V�d�,j�W붣�J�� /Z�,�)�)�+�(�)�)�(�(�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �(�(�(�(�(�)�)�(�(�(�(�(�(�(�(�*�(�)�)�(�+L�<r�C곀�H됌�8x�&V�4[�4d�Line truncated
�mi�럪2��(�|x!�~v��~�쐩�狼y�迹�迹�迹�迹�迹�妄�열*�杖�迹�迹�暲�琅�쭥�븞�몗�뿁�첍$�撈�杖�迹�迹�杖�유.�勞~�迹�迹�迹�迹�迹�率|�윉�똽�� �뀆(�럪1�mi�CD
�"#�*B�5e�4d�4[�*^�a�2o�Y붣�=r�*I�,�)�)�+�(�)�)�(�(�)�(�(�(�(�(�)�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �(�(�)�)�)�)�)�(�(�(�(�(�(�(�(�*�(�)�)�(�&8�(^�<y�3w�1t�X�5Y�3d�5f�3_��<>�_Z�룑4�꼥*�{w!�zt�굕�뱠�랫/�迪�迪�迪�迪�迪�音�빻�僕�迪�拍�츫,�삂���걐�}r�걐���쉳�뇹7�不�迪�僕�빻�音�迪�迪�迪�迪�迪�릇2�뿁��~��} �냳)�룑3�_Z�<>��3]�5f�3d�5Y�,i�X�=z�[뽘�*`�$5�,�)�)�+�(�)�)�(�(�)�(�(�(�(�(�)�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�(�(�*�(�)�)�(�%"�G�=x�/r�6{�"b�7W�4c�6f�7g��35�QN ��*�뇚.�}y$�wr�{t��~�옄�翔�暲�暲�暲�暲�暲�勤f�랬7�꺄@�뱠��}�t�um�ng�le�of�wn�걐��~�뵂�랴I�묵;�苟e�暲�暲�暲�暲�暲�鐥�젔�똼�굗��|#�늼-��+�RN �35��7g�6f�4c�7W�+p�`�M뗆�^샰�H�#�,�)�)�+�(�' �)�(�(�)�(�)�)�(�(�)�(�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�-�/�,�)�)�)�-�-�)�)�(�(�*�(�)�)�(�+�6o�C|�0q�7{�&f� Ix�5`�5f�6h�&;�$%�EE
�mg�뙅2�}'�wt�vq��w�릢�윱U�鱗�鱗�鱗�鱗�語�릎L�몗��{�|r�sk�le�f` �b\ �`[ �b\ �fa �mf �tk�|s��{�뭶�벽M�語�鱗�鱗�鱗�鱗�졔V�뱡��}�z��&�뙅1�mg�EF
�$&�&:�6h�5f�5`�)Q~�c�(e�P뚈�V롃�5n�)�,�)�)�+�(�/�' �'�(�)�(�)�)�(�(�)�(�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �,�)�,�/�/�,�(�(�(�/�/�-�(�(�(�*�(�)�)�(�+�.S�:p�7w�E끤�>z�(^�:_�6f�7h�5a��;>�]V�늽/�긾-�xv#�to�yq�긻�뿂�裔�靷�裔�탁b�썚��|�zq�qi�ha�b\ �_Y �[W �YT �XT �YU �\W �_Y �c] �ic�qi�{q��|�쐩�틥d�裔�靷�裔�솈��~�x�~z"�꼦,�늽/�]V�;>��5a�7h�6f�:_�;q�(g�>|�Y붢�Cx�+P�)�,�)�)�+�(�-�/�' �'�)�(�)�)�(�(�)�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�-�/�/�,�*�*�-�-�/�-�,�*�*�*�*�(�)�)�(�+�!%3�!W�E�"<Z�%:�#Fl�'Dg�6f�7h�8i� ,�*,�KJ �ni�뎶1�}{'�ur�sn�wp�굓�옅q�융n�뻿��~�u�rj�h` �`[ �[V �WR �VQ �UQ �TP �TP �TP �UQ �VQ �XS �[V �`[ �ha �rj��u��~�뿃�윷n�윷r��{�}v�{x��}&�뎶0�nj�KJ �*,� ,�8i�7h�6f�(Ei�'Jo�'=�+Jl�`쇪�#X� .�)�,�)�)�+�(�-�-�.�' �(�(�)�)�(�(�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �,�/�/�/�/�(�/�0�/�-�,�(�(�(�(�*�(�)�/�/�+�-�>��Cq�1L�7U�/H�+[�8a�7h�8j�1Y��=?�UP�긽-�긾,�xu#�qo�pk�rl�vo�yo�vn�qi �ic �a[ �ZV �VR
�SO
�QN
�QN �QN �QN �QN �QN �QN �QN �QN
�SO
�VR
�ZV �a[ �jd �rj�xp�{r�zs�xq�xs�|y"�뀇+�긽,�UP�=?��1Y�8j�7h�9b�6a�/H�6S�/I�P겢�?��'�)�,�)�)�+�(�,�,�,�.�' �'�)�)�(�(�)�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�,�/�-�-�,�2!�2!�/�-�(�(�(�(�(�*�(�/�2!�0�+�.�,L�@r�$Bd�#Bd�+Mr�-k�;]�7g�8j�9l�!2�$%�IH �d^�뒌2�})�us �mj�if�ib�fa�c^ �^Z �[V
�UQ
�QM �PL �NK �NK
�NK
�NK
�NK
�NK
�NK
�NK
�NK
�NK
�NK �PL �QM �UR
�[V �`[ �f`�jd�nh�qn�wu ��(�땳1�d^�IH �$%�!2�9l�8j�7g�;]�8t�#Ac�#Bd�%Bd�Cu�/N�(�)�,�)�)�+�(�+�+�+�,�.�' �(�)�(�(�)�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�-�/�/�/�0�2!�2!�/�-�)�)�)�(�(�*�,�2!�/�,�+�.�"�J�L꽶�K겤�X붠�=|�M�9b�9j�:k�8g� �78�PM�oj�뇞0�|z(�po �if�b_�]Z�WU�TQ �PM
�NK
�KI �KI
�KI
�KI
�KI
�KI
�KI
�KI
�KI
�KI
�KI
�KI
�KI
�KI
�LJ
�OL
�RO �VT �\Y�c_�ig�rp�|z'�늾0�oj�PM�78� �8f�:k�9j�9b�$S�V�@x�J{�]믃�L�&#%�(�)�,�)�)�+�(�*�*�*�+�+�.�' �(�(�(�)�(�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �/�/�0�2!�2!�0�2!�2!�/�,�)�)�)�,�/�2!�/�/�,�(�+�.�(�-Q�<p�J녓�T뫯�N뗅�*h�<_�8i�9l�:m�'B��>A �QO�wu&�뀉/�xx'�nm�db�\Z�WU�QO �NL �LJ �KI
�JH
�IG �IG �IG �IG �IG �IG �IG �IG �IG �IG �JH
�KI
�LJ �NL �QO �VT�\[�db�nl�yx&�뀉/�wu%�QO�>A ��'B�:m�9l�8i�<_�V�(e�O뗅�]뽔�Dx�/T�+�(�)�,�)�)�+�(�*�0�*�*�+�+�.�' �'�(�)�(�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �/�/�2!�2!�2!�0�/�/�,�(�(�(�-�0�2!�2!�2!�/�)�(�+�.�-�$",�G�G궮�M뚈�M띖�J댥�%U�<d�9l� :m� ;n� 3��DF
�]V�zw'�꼧0�ww(�ll!�cc�[[�VU�QP�NM �KJ �JI
�IH
�HG �GF �GF�FE�FE�GF�GF �HG �IH
�JI
�KJ �NM �QP�VU�[[�cc�ll!�ww(�꼧0�{x'�]V�DF
�� 3� ;n� :m�9l�<d�K� \�H녏�U뤝�\빺�H�"#+�+�(�)�,�)�)�+�(�' �*�*�*�*�*�*�-�' �'�)�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �0�2!�0�2!�2!�0�0�/�,�/�/�/�0�0�0�0�/�,�)�(�-�-�/�-�,P�5j�>|�N롇�R먎�G눯�$R�"?l� <n�!<n�!<o�/� !�GH �]V�vt%�뀉1�yy)�nn#�ee�^^�XX�TS�PO�NM�KK �JI �IH �IH �HG �HG �IH �IH �JI �KK �NM�PO�TS�XX�^^�ee�nn#�yy)�뀉1�vt%�]V�HH � !�/�!<o�!<n�!<n�#@l�%R�V�>{�O뗃�Y믇�;o�-Q�*�+�(�)�,�)�)�+�(�)�/�)�/�)�)�*�*�-�' �(�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �2!�2!�0�0�0�0�/�/�/�/�0�2!�2!�0�0�/�(�)�-�0�0�/�-�,�&$�>�D{�@}�T볁�U롁�5_�.Ms�'Ct�!<o�"<o�=c�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=c�"<o�"<o�)Eu�-Lq�&O~�>{�F꺠�M댥�U뚂�>�$"$�*�+�(�)�,�)�)�+�(�' �)�' �)�)�)�)�)�*�-�' �'�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �0�0�0�0�0�0�/�0�2!�0�2!�2!�2!�/�0�0�-�-�0�0�/�/�,�/�,�(D�$Y�B~�D궴�":V�*A�+A�-Ou�$?o�<f�Ei�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Ei�<f�&Ar�-Lp�)?�*@�+Jl�P뗃�U뤝�#X�+H�*�*�+�(�)�,�)�)�+�(�)�/�)�/�)�/�)�)�)�)�-�' �'�(�(�(�)� �뇦� �MNN�MNN� �뇦� �0�0�0�/�/�/�2!�2!�2!�0�0�2!�0�/�0�0�0�0�0�/�/�/�/�/�,�)�-U�Au�C|�4O�;[�5R�3\�#Em�>\�U�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�U�>\�%Hq�&Iq�7T�:Z� ;X�M됇�<p�/U�*�*�*�+�(�)�,�)�)�+�(�& �(�& �(�& �(�(�(�(�)�)�-�' �(�(�(�)� �뇦� �MNN�MNN� �뇦� �0�0�2!�2!�2!�0�0�2!�2!�2!�2!�2!�2!�2!�2!�2!�0�0�0�!�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�.E�Kr�Y�Y�Mz� 0N� 0O� 0O� 0O� 0O� 0O� 0O� 0N� /N� /N� /N� 0O� 0O� 0O� 0O� 0O� 1P� 0O� 0O� 0O� 0O� 0O� 1O� 0O� 0O� 0N� 0N� 0O� 0O� 0O� 0O� 0O�N{�Y�Y�Kr�.E�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)��)�+�(�(�(�(�/�(�/�(�/�(�(�(�(�,�' �'�(�(� �뇦� �MNN�MNN� �뇦� �0�0�2!�2!�2!�0�0�0�2!�0�2!�2!�2!�2!�2!�0�0�/� �7P�#V}�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�Mp�Aa�X�Y�T� /L�5T�8Y�9Z�9[�:[�:\�;]�:\�:]�:]�9[�9[�;]�;^�<^�;^�;^�<^�<`�=a�=a�=`�<`�<`�=`�<`�<`�<_�;^�<_�=a�:\�3T�T�Y�X�Aa�Mp�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�!Sy�#V}�7P��*�(�(�(�& �(�& �(�& �(�(�(�(�(�(�,�' �(�(� �뇦� �MNN�MNN� �뇦� �2!�2!�2!�2!�0�/�0�0�/�/�2!�2!�0�0�0�/�-� �/F�!Pv�Hk�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�@_�Q|�Y�X�7Y� 5V�;]�;]�:]�;^�Fi�j뜲�;]�<^�<_�<`�<`�<_�<^�<_�<`�<_�;^�<^�<_�<_�<_�;_��돪�;^��<^�;^�;^�;^�;]�;]�<^�;]� 5V�9[�X�Y�Q|�@_�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Fi�Hk�!Pv�/F��(�(�(�(�(�)�(�(�(�(�(�(�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �0�0�0�0�0�/�/�-�-�-�0�0�0�0�0�0��)=�!Pu�Gk�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Ab�Ei�Y�Y�Ck�6V�=a�<`�;^�;^�;]�i떘�▤�9[�i떘�▤�▤�▤�▤�▤�▤�▤�Eh�:\�눏�눏�눏�눏�렡�▤�렡�눏�눏�눏�눏�;]�<^�;^�:[�9[� 4S�Ai�Y�Y�Ei�Ab�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Eh�Gk�!Pu�)=��)�)�)�)�*�)�)�)�)�)�)�)�)�)�)�)�*� �뇦� �MNN�MNN� �뇦� �0�0�0�,�/�/�-�-�/�0�2!�2!�0�2!�0��$6�!Nr�Gk�Dg�Dg�8T�/F�/F�/F�/F�/F�/F�/F�/F�0H�>\�U�Y�My� 3R�9[�9[�:\�;]�;^�9\}�v샍�읚�Ce�g닼�떙�Cb�Cc�Cc�Cc�Cc�읚�Ce� 7V�g닼�g닶�g닼�g닶�v샋�읚�v샋�g닶�g닶�g닶�g닼�7X�9Z�9[�9[�9[�9[� 2Q�Lx�Y�U�>\�0H�/F�/F�/F�/F�/F�/F�/F�/F�/F�8T�Dg�Dg�Gk�!Nr�$6�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)� �뇦� �MNN�MNN� �뇦� �/�/�,�/�-�/�0�0�0�2!�2!�2!�0�0�0� 1�!Ko�Fj�Be�Be�7S�Hh�/r�.o�.o�.o�-m�.p�.p�-l� Q}�Kr�Y�U�2R�9[�<^�;^�;]�;]�;^�겈�쎙�쎙�쎙�Ac�Tu�s빓�s빖�+Nm�렢�]}�d뀰�,Po�Bd�d넦�d넢�d넦�d넦�s빓�쎙�s빓�d넦�d넦�d넢�d넧�,Rt�=a�=a�=a�=a�>a�;\�4T�T�Y�Ks�"Z~�4겑�5~�5~�5z�2w�1y�3~�3}�2z�Mh�7S�Be�Be�Fj�!Ko�)�)�(�(�(�(�)�)�)�)�)�)�)�)�(�(�*� �뇦� �MNN�MNN� �뇦� �-�-�,�-�/�0�0�0�0�0�2!�2!�0�/�-� Hj�Fi�Ad�Ac�9V�8S�-m�.r�-o�-l�-l�-m�.p�.o�&_�Aa�X�X�9\� 6W�;^�<_�;^�;]�:\�9Z�6Xw�p뫑�뿻�?_}�*Kj�~졗�뫒�Rs�@a��Z{�뿻�w쇊�Bc�b깶�뿻�~졗�~졗�꽘�뿻�뿻�~졗�~졗�~졗�꽘�뿻�@a�9Y�9Z�9[�:\�9[�9[� 5U�8Y�X�X�Aa�"T�(_�)a�)a�)c�*g�+g�+h�+i�)b�7T�9V�Ac�Ad�Fi�)�)�(�(�*�(�(�(�(�(�(�(�(�(�(�(�*� �뇦� �MNN�MNN� �뇦� �0�,�-�-�0�0�2!�2!�2!�0�0�0�/�0�/� Fi�@b�?a�;[�.G�#S}�&Z�%U�$U�%V�%V�%X�%X�$U��De�Q|�Y�@f� 1O�8Y�8Y�8X�8Y�8Y�7X�6V� 3Q�_~�벜� 1P�?^�{쑠�4So�_�벜� 1O�>]z�f눉� 4R�_�벜�)Gd�_~�뜳�m렜�)Gc�4So�{쑠�Xw�>]z�벜�>^}�6W�7W�7W�7Y�7X�8Y�7X� 1O�?f�Y�Q|�Cf�#S��#S�$U�&Y�&Z�%V�&X�&X�%V�!Nz�.G�;[�?a�@b�)�)�(�(�*�*�*�*�*�*�*�*�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �0�,�-�/�/�0�0�2!�2!�2!�0�0�0�0�0�?a�>`�>`�3N�'`�1w�1w�0r�0r�1t�/r�.p�0u�1w�Nr�Q|�Y�Cl�7W�=a�=a�=a�<`�<_�<_�<`�4Wy�x쎗�맭�꽙�d넧�x쎗�x쎗�꽙�맭�x쎗�x쎗�x쎗�Np�7X� 5U�x쎗�맭�맭�맭�맭�맭�r뵸�3Vv� 7W� 7V�9[�=`�>b�>b�=a�=a�>a�=a�7W�Dm�Y�Q|�Pr�3z�3y�3z�4}�4}�3y�3y�3z�5겑�2y�'`�3N�>`�>`�)�)�(�(�*�(�(�(�(�(�(�(�(�(�(�(�*� �뇦� �MNN�MNN� �뇦� �,�/�,�,�-�/�0�2!�0�2!�2!�/�/�/�/�?a�=_�=_�4P�#Su�3z�1w�1u�1u�/t�0u�0u�1w�0t�'`�Aa�X�X�9[�7Y�<`�<_�<_�<_�<^�:]�v샍�뜳�뜳�b깵�Mn�[|�b깶�뜳�뜳�|왒�[|�[|�<_~�9Z�8Y�A`�Ml�|왏�궏�[|�?\�[|�눏�3Ww�:[�;]�<_�<_�<^�<^�<_�<_�7Y�8]�X�X�Aa�)e�1w�1w�/r�/t�1u�/r�.o�/r�/t�/r�"Ru�4P�=_�=_�)�)�(�(�*�(�(�(�(�(�(�(�(�(�(�(�*� �뇦� �MNN�MNN� �뇦� �,�0�/�0�/�/�-�/�0�0�0�0�-�,�,�!Fk�=`�;^�9[�1N�Gu�*b�+d�,h�-k�+g�)_�)]�)c�)b�Ms�Ks�Y�S� 1O�7W�8Z�8Z�8Y�8Y�7X�2Rp�a��떘� 1M� 3R�=[�z쎖�떘�떘�떘�1Qn� 2O� 4R�7V�St�떘�떘�떘�t빒�t빒�t빓�z쎗�떘�떘�Ll�8Y�8Y�8Y�8Y�8X�7W�6V� 0N�S�Y�Ks�Ls�)]�)_�)_�)`�)`�)`�(_�(]�)_�)a�Hu�1N�9[�;^�=`�)�)�(�(�*�(�)�)�)�)�)�)�)�(�(�(�*� �뇦� �MNN�MNN� �뇦� �/�0�0�0�0�/�-�-�/�/�/�/�/�/�-�:U�"Ej�<_�:]�9Z�1M�"Pt�/p�.m�-l�.m�.o�/p�/p�,i�(`�>\�U�Y�N{�3S�;^�;^�;]�;^Line truncated
*E�
)D�
)D� +H� ,I� ,J� ,I� +G� *E� +G� ,H� ,H� +H� +G�
*F� )E� )E�
(C�
(B� )D� *F� ,H� +I� *F�
*E� +G� ,J� ,I� ,I�Ju�Y�Y�Ks�Ac�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�%Gl�'Jq�9T� �'�(�)�)�(�(�*�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�,�,�,�,�,�,�-�-�-�,�,�,�(��
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)� -�>\�U�Y�Y�Y�Y�Y�X�X�X�Y�Y�Y�Y�X�X�Y�Y�Y�Y�Y�Y�Y�Y�X�W�X�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�U�>\� .�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)�
)��%�+�(�)�)�(�(�*�(�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�,�+�.�(�)�,�(�)�+�)�(�*�*�(�(� ,5�Ei�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Y�Ei�+5�(�(�+�*�(�*�*�*�(�(�+�+�(�(�,�)�)�.�(�)�)�(�(�*�(�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �,�,�,�,�,�,�,�,�,�,�,�,�,�,�(�*�(�)�)�(�+�.�(�)�,�(�)�)�,�)�(�*�(�(�*�/>�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�=[�.=�(�(�*�+�(�(�*�*�*�(�)�)�)�(�)�,�)�)�.�(�)�)�(�(�*�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�,�*�*�*�,�,�,�,�,�,�,�,�,�,�,�(�)�)�(�+�.�(�)�,�(�)�)�,�)�(�(�*�(�(�*�+�+�*�*�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�*�+�+�+�*�*�(�*�*�*�+�(�)�)�)�(�)�,�)�)�.�(�)�)�(�(�(�*�*�*�*�*�*�*�*�*�*�*�*� �뇦� �MNN�MNN� �뇦� �-�*�,�,�,�,�,�,�,�(�(�(�(�,�,�,�,�)�)�(�+�.�(�)�,�(�(�)�+�)�(�+�(�(�(�(�*�*�*�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�*�*�*�*�(�(�*�*�*�+�(�)�)�(�(�)�,�)�)�.�(�)�)�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �(�*�*�,�,�,�,�,�,�,�(�,�,�,�(�(�)�)�)�(�+�.�(�)�,�(�(�)�)�)�(�*�+�*�(�(�(�(�(�(�(�(�(�(�(�*�*�*�*�*�*�(�(�(�(�(�(�(�(�*�*�*�*�*�*�(�(�(�(�(�(�(�(�(�(�(�(�*�*�*�*�*�(�)�)�(�(�)�,�)�)�+�(�)�)�)�(�(�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �(�(�*�*�,�,�,�,�,�,�,�,�-�-�-�)�)�)�(�(�+�.�(�)�,�(�)�(�)�+�)�(�(�(�*�(�(�(�(�(�(�(�(�*�*�*�*�*�*�*�*�*�(�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�(�(�(�(�(�(�*�*�*�(�(�(�(�)�(�(�)�(�)�,�)�)�+�(�(�)�)�)�)�)�(�(�)�)�(�(�(�(�(�)�)� �뇦� �MNN�MNN� �뇦� �(�(�(�(�(�*�,�,�,�,�,�,�,�-�-�,�)�(�(�+�.�.�(�)�,�(�(�)�+�)�+�)�)�)�)�(�*�*�*�*�*�(�*�*�*�*�*�(�*�(�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�(�(�)�)�(�)�(�)�+�(�(�)�,�)�)�+�.�(�(�)�)�)�)�)�+�)�)�)�(�(�)�)�(�(� �뇦� �MNN�MNN� �뇦� �(�(�(�(�(�(�(�*�,�,�,�,�,�,�,�(�(�(�+�.�)�+�(�)�,�(�)�(�(�+�(�)�+�+�,�)�(�*�*�*�*�(�*�*�*�*�+�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�(�*�*�*�*�*�*�*�*�(�(�+�+�+�)�(�)�(�(�+�(�)�,�)�(�)�+�.�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(� �뇦� �MNN�MNN� �뇦� �,�*�*�*�*�*�*�*�*�*�*�*�,�+�+�+�*�+�+�)�+�.�(�(�,�(�)�(�(�)�(�)�)�)�)�,�)�(�*�*�(�(�(�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�.!�+�*�*�*�*�(�(�*�(�(�)�)�)�)�)�(�)�(�(�)�(�)�,�)�)�+�)�+�+�+�+�+�.�.�.�+�+�+�+�+�+�+�+�+� �뇦� �MNN�MNN� �뇦� �(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)�)�(�)�+�(�(�,�(�(�)�)�(�)�(�(�)�)�+�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)�)�)�(�(�)�(�)�)�(�(�)�)�)�(�)�(�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�(�(�(�(�(�(�(�(�(�(�(�(�(�(�)�)�)�)�)�(�(�)�(�(�(�(�(�(�(�(�(�(�(�(�)�)�)Line truncated
�x릝�v�J�b�9�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�^�6�b�9�v�J�q늮�
�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��놃X�`�4�\�1�\�1�\�1�\�1�\�1�\�1�A{"�.W�A{"�\�1�\�1�A{"�.W�A{"�.W�A{"�\�1�\�1�\�1�\�1�A{"�.W�A{"�\�1�\�1�\�1�\�1�\�1�\�1�\�1�A{"�.W�A{"�A{"�.W�.W�.W�.W�.W�A{"�\�1�\�1�\�1�\�1�A{"�.W�.W�A{"�A{"�.W�A{"�\�1�\�1�\�1�\�1�\�1�`�4�놃X��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)����F�[�+�[�+�[�+�[�+�[�+�[�+�[�+�-S�汎��-S�[�+�[�+�-S�汎��-S�汎��-S�@v�[�+�[�+�[�+�-S�汎��-S�[�+�[�+�[�+�[�+�[�+�[�+�[�+�-S�汎��-S�-S�汎��汎��汎��汎��汎��-S�[�+�@v�-S�-S�-S�汎��汎��-S�-S�汎��-S�[�+�[�+�[�+�[�+�[�+�[�+���F��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��{�=�Y�&�Y�&�Y�&�Y�&�Y�&�Y�&�Y�&�,O�藜�,O�,O�?p�,O�藜�,O�,O�藜�,O�Y�&�Y�&�Y�&�,O�藜�,O�Y�&�Y�&�Y�&�Y�&�Y�&�Y�&�Y�&�,O�藜�,O�,O�藜�,O�,O�,O�藜�,O�Y�&�,O�藜�藜�藜�,O�,O�?p�,O�藜�,O�Y�&�Y�&�Y�&�Y�&�Y�&�Y�&�{�=��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��y�3�W� �W� �W� �W� �W� �W� �W� �+K�橋�橋�橋�+K�+K�橋�+K�+K�+K�=j�+K�+K�+K�+K�橋�+K�+K�+K�+K�+K�=j�=j�+K�+K�橋�+K�+K�橋�橋�橋�橋�橋�+K�W� �=j�+K�+K�橋�+K�W� �W� �+K�橋�+K�W� �W� �W� �W� �W� �W� �y�3��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��v�+�U��U��U��U��U��U��U��*G �톈�*G �*G �*G �*G �톈�톈�톈�톈�*G �톈�톈�톈�톈�톈�톈�톈�톈�톈�톈�*G �*G �톈�톈�톈�톈�톈�톈�*G �*G �*G �톈�*G �U��U��U��*G �톈�*G �U��U��*G �톈�*G �U��U��U��U��U��U��v�+��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��s�#�S��S��S��S��S��:_�)C �)C �쁨�)C �)C �쁨�쁨�쁨�)C �)C �)C �:_�)C �)C �)C �)C �쁨�)C �)C �)C �)C �쁨�)C �:_�)C �)C �쁨�)C �)C �쁨�쁨�쁨�쁨�쁨�)C �:_�)C �)C �)C �쁨�)C �)C �)C �)C �쁨�)C �)C �:_�S��S��S��S��s�#��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��g��Jr �Jr �Jr �Jr �Jr �%9�겔�겔�겔�겔�겔�%9�%9�겔�%9�겔�%9�Jr �Jr �Jr �Jr �%9�겔�%9�Jr �Jr �%9�겔�%9�Jr �Jr �%9�겔�%9�4P �%9�%9�%9�%9�%9�%9�%9�겔�겔�겔�겔�겔�겔�겔�겔�겔�겔�겔�%9�Jr �Jr �Jr �Jr �g���)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��=n�,E�,E�,E�,E�,E�"�Ξ�"�"�"�Ξ�"�"�Ξ�"�Ξ�"�,E�,E�,E�0�"�Ξ�"�,E�,E�"�Ξ�"�0�"�"�Ξ�Ξ�"�Ξ�Ξ�Ξ�Ξ�Ξ�Ξ�"�"�"�"�Ξ�"�"�"�"�Ξ�"�"�0�,E�,E�,E�,E�=n��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��>s�-H�-H�-H�-H�-H�$�싄�$�-H�$�싄�$�$�싄�$�싄�$�-H�-H�-H�$�싄�$�3�-H�-H�$�싄�$�$�싄�싄�싄�$�3�$�$�싄�$�$�$�3�-H�-H�$�싄�$�-H�-H�$�싄�$�-H�-H�-H�-H�-H�-H�>s��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��A{
�/M�/M�/M�/M�/M�&�뤐�&�&�&�뤐�&�!6�&�뤐�&�&�!6�/M�!6�&�뤐�&�/M�/M�/M�&�뤐�&�!6�&�&�뤐�&�&�뤐�&�뤐�뤐�뤐�&�/M�/M�!6�&�뤐�&�/M�/M�&�뤐�&�/M�/M�/M�/M�/M�/M�A{
��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��G��3S �3S �3S �3S �3S �)�끆�끆�끆�끆�끆�)�)�끆�끆�)�끆�)�$:�)�끆�)�$:�3S �3S �3S �)�끆�)�3S �3S �)�끆�)�)�끆�)�끆�)�)�$:�3S �3S �)�끆�)�$:�3S �3S �)�끆�)�3S �3S �3S �3S �3S �3S �G���)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��L��7Z �7Z �7Z �7Z �7Z �-�|닯�-�-�-�-�-�|닯�-�-�|닯�|닯�-�-�|닯�-�'@ �7Z �'@ �-�-�-�|닯�-�7Z �'@ �-�|닯�-�-�|닯�-�|닯�-�-�-�'@ �'@ �-�|닯�-�7Z �7Z �7Z �-�|닯�-�7Z �7Z �7Z �7Z �7Z �7Z �L���)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��S��;b�;b�;b�;b�;b�)E �1�)E �;b�;b�1�t��1�)E �)E �1�t��1�t��1�)E �;b�;b�1�t��t��t��1�)E �;b�1�t��t��1�t��1�t��t��t��t��t��1�1�t��1�)E �;b�;b�;b�1�t��1�;b�;b�;b�;b�;b�;b�S���)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)��Z�&�Ao�>i�>i�>i�>i�>i�>i�>i�>i�>i�,J�4
�,J�>i�>i�,J�4
�,J�4
�,J�>i�>i�>i�,J�4
�4
�4
�,J�>i�>i�,J�4
�4
�,J�4
�,J�4
�4
�4
�4
�4
�,J�,J�4
�,J�>i�>i�>i�>i�,J�4
�,J�>i�>i�>i�>i�>i�Ao�Z�&��)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �S�6�Q�!�Dt�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�An�Dt�Q�!�O�4�
�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�" ��V�;�a�/�^�*�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�]�)�^�*�a�/�V�;��$ �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�" � ��������������������������������������������������������� �" �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�( �( �( �)�)�( �( �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�( �)�)�( �( �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦� �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�( �)�)�( �( �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)� �뇦� �MNN�MNN� �뇦�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�( �)�)�( �( �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�뇦� �MNN�MNN� �뇦�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�( �)�)�( �( �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�뇦� �MNN�MNN� �뇦�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�( �)�)�( �( �)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�뇦� �MNN�MNN� �뇦� ��������������������������������������������������������������������������������������������������������������� �뇦� �MNN�MNN� �뇦���{;�즬B�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�쓚C�즬B��{;��뇦� �MNN�MNN� �뇦��웭@�뛧@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@�긟@Line truncated
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�kI*�( �뇦� �MNN�MNN� �뇦�)�jH)�@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �jH)�)�뇦� �MNN�MNN� �뇦�)�iG(�?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �iG(�)�뇦� �MNN�MNN� �뇦�)�hF(�=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �hF(�)�뇦� �MNN�MNN� �뇦�)�gE'�<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �gE'�)�뇦� �MNN�MNN� �뇦�)�fD'�;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �fD'�)�뇦� �MNN�MNN� �뇦�)�eC&�9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �eC&�)�뇦� �MNN�MNN� �뇦�)�dB&�8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �dB&�)�뇦� �MNN�MNN� �뇦�)�cA%�7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �cA%�)�뇦� �MNN�MNN� �뇦�)�b@$�6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �b@$�)�뇦� �MNN�MNN� �뇦�)�a?$�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�a?$�)�뇦� �MNN�MNN� �뇦�)�`>#�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3Line truncated
���������������������������������������������������������������������������������������������������������������
�뇦� �MNN�MNN� �뇦��쯻9�웭@�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁱A�쁰@�뾪8�엙5�쯹6��뇦� �MNN�MNN� �뇦��쉜=�냙>�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�|^=�{];��]1�쁫2��뇦� �MNN�MNN� �뇦��똦;�sW8�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�븝�fO7�fO7�븝�fO7�fO7�fO7�븝�fO7�fO7�fO7�fO7�fO7�fO7�fO7�븝�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�븝�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�븝�fO7�븝�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�fO7�lM+��`.��뇦� �MNN�MNN� �뇦��꼊7�iQ5�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�븝�[H3�븝�[H3�[H3�[H3�븝�[H3�[H3�[H3�[H3�[H3�[H3�븝�븝�븝�븝�븝�븝�븝�[H3�[H3�[H3�[H3�[H3�븝�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�븝�[H3�[H3�븝�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�[H3�aF'�Z*��뇦� �MNN�MNN� �뇦��}]4�`J/�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�븝�븝�븝�븝�븝�븝�븝�븝�R@.�R@.�븝�R@.�븝�R@.�R@.�R@.�븝�R@.�R@.�R@.�R@.�R@.�R@.�븝�R@.�R@.�R@.�R@.�븝�븝�븝�븝�R@.�R@.�븝�븝�븝�븝�븝�븝�븝�븝�븝�븝�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�R@.�W?!�wT&��뇦� �MNN�MNN� �뇦��uY1�XD-�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�븝�븝�I:*�I:*�I:*�븝�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�븝�븝�븝�I:*�I:*�I:*�I:*�I:*�븝�븝�븝�븝�븝�븝�I:*�븝�I:*�I:*�븝�I:*�I:*�븝�I:*�I:*�I:*�I:*�븝�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�I:*�N8�oO#��뇦� �MNN�MNN� �뇦��pU.�Q@)�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�븝�@5&�븝�@5&�@5&�@5&�븝�@5&�@5&�@5&�@5&�@5&�@5&�븝�븝�@5&�@5&�@5&�븝�븝�@5&�@5&�@5&�@5&�@5&�븝�@5&�@5&�븝�@5&�븝�@5&�@5&�븝�@5&�@5&�븝�@5&�@5&�@5&�@5&�븝�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�@5&�F3�iK ��뇦� �MNN�MNN� �뇦��mR.�M='�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�븝�=2%�=2%�븝�=2%�븝�븝�븝�븝�븝�=2%�=2%�븝�븝�=2%�=2%�=2%�=2%�=2%�=2%�=2%�븝�븝�=2%�=2%�=2%�븝�=2%�=2%�븝�=2%�븝�=2%�=2%�븝�=2%�=2%�븝�븝�븝�븝�=2%�븝�=2%�=2%�븝�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�B0�fH��뇦� �MNN�MNN� �뇦��mR.�M='�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�븝�=2%�>2%�>2%�>2%�>2%�>2%�=2%�=2%�븝�븝�븝�븝�븝�븝�븝�>2%�>2%�>2%�>2%�>2%�븝�>2%�>2%�븝�>2%�븝�>2%�>2%�븝�>2%�=2%�븝�=2%�=2%�븝�=2%�븝�=2%�=2%�븝�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�=2%�B0�fH��뇦� �MNN�MNN� �뇦��fH�B0�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�1$�1$�1$�1$�1$�1$�1$�1$�1$�븝�븝�븝�븝�븝�븝�븝�븝�븝�븝�븝�1%�1%�2%�븝�2%�2%�븝�2%�2%�븝�2%�2%�2%�2%�2%�븝�2%�2%�븝�2%�븝�1%�1%�븝�1%�1%�븝�1%�1%�븝�1%�븝�1$�븝�1%�1$�1$�1$�1$�1$�1$�1$�1$�1$�1$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�0$�B0�fH��뇦� �MNN�MNN� �뇦��fH�B0�0$�0$�1$�1$�1$�1$�1$�1$�1$�1$�1$�1$�1$�1$�1$�1%�1%�1%�1%�2%�2%�2%�2%�2%�2%�3&�3&�3&�4&�4&�4&�4&�4'�븝�5'�븝�5'�6'�5'�6'�6'�6'�6'�븝�븝�븝�븝�븝�븝�븝�7(�6(�6(�7(�7(�븝�6(�6(�븝�6(Line truncated
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�B'
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�wV0�0�뇦� �MNN�MNN� �뇦�.�wV0�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�A&
�A&
�@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �B'
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�D)
�wV0�1�뇦� �MNN�MNN� �뇦�0�vU0�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �A' �D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�D(
�vU0�1�뇦� �MNN�MNN� �뇦�1�vU0�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �A' �C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�vU0�1�뇦� �MNN�MNN� �뇦�0�uU0�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �@& �A' �C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�uU0�.�뇦� �MNN�MNN� �뇦�1�uT/�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �@& �C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�C(
�uT/�.�뇦� �MNN�MNN� �뇦�1�uT/�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �@& �B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�uT/�0�뇦� �MNN�MNN� �뇦�1�tS/�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�?% �?% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �>% �@& �B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�B'
�tS/�-�뇦� �MNN�MNN� �뇦�0�tS/�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�>% �>% �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �?& �A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�tS/�.�뇦� �MNN�MNN� �뇦�-�sS/�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �>% �A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�A'
�sS/�.�뇦� �MNN�MNN� �뇦�-�sR.�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �>% �A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�A&
�sR.�.�뇦� �MNN�MNN� �뇦�.�sR.�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �>% �@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�sR.�1�뇦� �MNN�MNN� �뇦�0�rQ.�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �=$ �@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�@&
�rQ.�1�뇦� �MNN�MNN� �뇦�.�rQ.�@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �=$ �@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �@% �rQ.�0�뇦� �MNN�MNN� �뇦�.�qP.�?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �=$ �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �qP.�.�뇦� �MNN�MNN� �뇦�-�qP-�?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �=$ �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �?% �qP-�.�뇦� �MNN�MNN� �뇦�+�qP-�>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �<# �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �qP-�+�뇦� �MNN�MNN� �뇦�.�pO-�>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �<# �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �>$ �pO-�.�뇦� �MNN�MNN� �뇦�,�pO-�=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �<# �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �pO-�&
�뇦� �MNN�MNN� �뇦�,�oN,�=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �:! �:! �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �:" �;" �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �=$ �oN,�+�뇦� �MNN�MNN� �뇦�,�oN,�=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �9! �9! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:" �=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �=# �oN,�+�뇦� �MNN�MNN� �뇦�,�nM,�<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �:" �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �nM,�+�뇦� �MNN�MNN� �뇦�,�nM,�<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �:" �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �<# �nM,�+�뇦� �MNN�MNN� �뇦�)�nL,�;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �9! �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �nL,�+�뇦� �MNN�MNN� �뇦�(�mL+�;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �9! �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �mL+�+�뇦� �MNN�MNN� �뇦�*�mK+�;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �9! �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �;" �mK+�+�뇦� �MNN�MNN� �뇦�.�lK+�:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �7 �7 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �lK+�&
�뇦� �MNN�MNN� �뇦�0�lJ+�:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �8 �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �:! �lJ+�&
�뇦� �MNN�MNN� �뇦�0�kJ*�9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �8 �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �9! �kJ*�&
�뇦� �MNN�MNN� �뇦�0�kI*�9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �8 �9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �9 �kI*�+�뇦� �MNN�MNN� �뇦�0�jI*�8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �6 �6 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �jI*�&
�뇦� �MNN�MNN� �뇦�0�jI*�8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �7 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �8 �jI*�*�뇦� �MNN�MNN� �뇦�0�iH)�7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �7 �iH)�*�뇦� �MNN�MNN� �뇦�-�iH)�7 �7 �7 �7 �7 �7 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �6 �7 �7 �7 �7 �7 �7 �iH)�+�뇦� �MNN�MNN� �뇦�0�iG)�7 �7 �7 �7 �7 �7 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �5 �7 �7 �7 �7 �7 �7 �iG)�+�뇦� �MNN�MNN� �뇦�0�hG)�6 �6 �6 �6 �6 �6 �5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�5�6 �6 �6 �6 �6 �6 �hG)�+�뇦� �MNN�MNN� �뇦�1�hF)�6 �6 �6 �6 �6 �6 �4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�6 �6 �6 �6 �6 �6 �hF)�+�뇦� �MNN�MNN� �뇦�1�gF(�5 �5 �5 �5 �5 �5 �4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�4�5 �5 �5 �5 �5 �5 �gF(�+�뇦� �MNN�MNN� �뇦�1�gE(�5 �5 �5 �5 �5 �5 �3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�4�4�4�5 �5 �5 �gE(�+�뇦� �MNN�MNN� �뇦�1�fE(�4�4�4�)�)�)�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�����4�4�fE(�+�뇦� �MNN�MNN� �뇦�0�fD(�4�4�,�41(�41(�41(�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�궪�궪�궪�2EF�%�4�fD(�+�뇦� �MNN�MNN� �뇦�.�eD'�3�3�+�31(�31(�31(�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3�3Line truncated
��4�*�*�*�*�*�*�[9"�,!�뇦� �MNN�MNN� �뇦�,�[9"�)�)�)�)�)�)�)��8:2�Xnl�Sor�Edj�" �3�3�3�3�3�3�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�+�3�3�3�3�3�3�+� !,�@샓�Y��g��5QX��)�)�)�)�)�)�[9"�(�뇦� �MNN�MNN� �뇦�+�Z8"�)�)�)�)�)�)�41(�bqj�j�}�_xw�Sor�,1.�%�2�2�3�3�3�3�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�3�3�3�3�3�3�3� �>O�Y��r쬠�됓�궪�)�)�)�)�)�)�Z8"�,!�뇦� �MNN�MNN� �뇦�,�Z8"�)�)�)�)�)�)�31(�brk�i�}�^xw�Ror�+1-�$�2�2�3�3�3�3�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�3�3�3�3�3�3�2� �>O�Y��r쬠�됓�꽲�)�)�)�)�)�)�Z8"�(�뇦� �MNN�MNN� �뇦�+�Z8!�(�(�(�(�(�(�(��7:2�Wnl�Ror�Ddj�! �2�2�3�3�3�3�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�3�3�3�3�3�3�+� !,�@샓�Y��g��5QX��(�(�(�(�(�(�Z8!�(�뇦� �MNN�MNN� �뇦�+�Y7!�(�(�(�(�(�(�3�2�&� �4=9�Cbg�7V_�1�1�2�2�2�2�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�*�2�2�2�2�2�2�# �)��?빞�,Vf�
��2�(�(�(�(�(�(�Y7!�(�뇦� �MNN�MNN� �뇦�+�Y7!�(�(�(�(�(�(�2�2�2�0�$��-=@�0�0�2�2�2�2�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�2�2�2�2�2�2�(//�Vr�� �.�2�2�(�(�(�(�(�(�Y7!�(�뇦� �MNN�MNN� �뇦�+�Y7!�(�(�(�(�(�(�2�2�2�2�2�-�"�0�0�1�1�1�1�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�1�1�1�1�1�1�#�
�(�2�2�2�2�(�(�(�(�(�(�Y7!�(�뇦� �MNN�MNN� �뇦�+�Y6!�'�'�'�'�'�'�1�1�1�1�1�1�1�0�0�1�1�1�1�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�1�1�1�1�1�1�.�1�1�1�1�1�1�'�'�'�'�'�'�Y6!�(�뇦� �MNN�MNN� �뇦�+�X6!�'�'�'�'�'�'�1�1�1�1�1�1�1�/�/�0�0�0�0�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�)�0�0�0�0�0�0�0�1�1�1�1�1�1�'�'�'�'�'�'�X6!�(�뇦� �MNN�MNN� �뇦�+�X6 �'�'�'�'�'�'�0�0�0�0�0�0�0�/�/�0�0�0�0�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�0�0�0�0�0�0�/�0�0�0�0�0�0�'�'�'�'�'�'�X6 �(�뇦� �MNN�MNN� �뇦�+�X5 �&�&�&�&�&�&�&�&�&�&�&�&�&�'�'�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�'�&�&�&�&�&�&�&�&�&�&�&�&�X5 �(�뇦� �MNN�MNN� �뇦�+�X5 �&�&�&�&�&�&�&�&�&�&�&�&�&�'�'�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�(�'�&�&�&�&�&�&�&�&�&�&�&�&�X5 �(�뇦� �MNN�MNN� �뇦�+�W5 �&�&�&�&�&�&�&�&�&�&�&�&�&�&�&�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�&�&�&�&�&�&�&�&�&�&�&�&�&�W5 �(�뇦� �MNN�MNN� �뇦�+�W5 �&�&�&�&�&�&�&�&�&�&�&�&�&�&�&�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�&�&�&�&�&�&�&�&�&�&�&�&�&�W5 �(�뇦� �MNN�MNN� �뇦�*�W4 �&�&�&�&�&�&�&�&�&�&�&�&�&�&�&�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�&�&�&�&�&�&�&�&�&�&�&�&�&�W4 �(�뇦� �MNN�MNN� �뇦�+�W4 �%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�'�%�%�%�%�%�%�%�%�%�%�%�%�%�W4 �(�뇦� �MNN�MNN� �뇦�,�V4 �%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�%�V4 �(�뇦� �MNN�MNN� �뇦�.�`>$�.�.�.�.�.�.�.�.�.�.�.�.�.�-�-�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.�.Line truncated
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.
@@ -0,0 +1,69 @@
┤D ╟⌡Nи⌡Nи Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи Nи⌡Nи⌡Nи Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи Nи⌡Nи⌡Nи⌡Nи Nи Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи⌡Nи┤D ╟╗H июSФюSФюSФюRФюRФюS ФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюR ФюS ФюSФюSФюRФюSФюSФюRФюSФюRФюSФюSФюSФюSФюSФюSФюSФюSФюSФюRФюS ФюS ФюSФюRФюSФюSФюRФюS ФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюR ФюS ФюSФюSФюRФюSФюSФюSФюSФюSФюRФюRФюS ФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюSФюRФюSФюSФюSФюSФюSФюRФюSФюS ФюSФюSФюSФ╗H и╧K исV
ФсV
ФсU ФсU ФсU ФсV ФсV
ФсV
ФсV
ФсV
ФсV ФсV ФсV
ФсV
ФсV
ФсV ФсV ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсV ФсV
ФсU
ФсV
ФсV
ФсV ФсV ФсV
ФсV
ФсV
ФсV ФсV ФсV ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсU ФсV ФсV
ФсV
ФсV
ФсV
ФсV ФсV ФсV
ФсV
ФсV
ФсV ФсV ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсU ФсU ФсU ФсV ФсV
ФсV
ФсV
ФсV
ФсV ФсV
ФсV
ФсV
ФсV
ФсV
ФсU
ФсV
ФсV ФсV
ФсV ФсV ФсV
ФсV
ФсV ФсV
ФсV
ФсV
ФсV
ФсV
ФсV
ФсV ФсV
ФсV
ФсV
Ф╧K ицTиъaФъaФъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`ФъaФъaФъaФъaФъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`ФъaФъaФъaФъaФъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`ФъaФъaФъaФъaФъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`ФъaФъaФъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`ФъaФъ`Фъ`Фъ`Фъ`ФъaФъaФъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`Фъ`ФцTих`иЕoФЕoФЕoФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕoФЕoФЕoФЕoФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕoФЕoФЕoФЕoФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕoФЕoФЕoФЕoФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕoФЕoФЕoФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕoФЕoФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФЕnФх`ииoиФФФ~ФФФФФФ~ФФ~ФФ~ФФ~ФФФФ~ФФ~ФФ~ФФ~ФФ~ФФ~ФФФФФФФФФФФФ~ФФФФФФ~ФФ~ФФ~ФФ~ФФФФФФ~ФФ~ФФ~ФФ~ФФ~ФФ~ФФФФ~ФФФФФФФФФФФФФФ~ФФ~ФФ~ФФ~ФФФФ~ФФ~ФФ~ФФ~ФФ~ФФ~ФФФФФФФФФФФФ~ФФФФФФФФФФ~ФФФФФФ~ФФ~ФФ~ФФ~ФФФФФФФФФФФФ~ФФ~ФФ~ФФ~ФФФФ~ФФФФФФФФФФФФ~ФФФФФФ~ФФФФФФФФ~ФФ~ФФФФФиnии{иФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ▄ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ▄ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ▄ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█ФФ█Фи{ии┘иФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤ФФ≤Фи┘ии█иФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║ФФ║Фи█ии⌠иФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╖ФФ╖ФФ╗ФФ╗ФФ╖ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╖ФФ╖ФФ╗ФФ╖ФФ╖ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╖ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╖ФФ╖ФФ╗ФФ╗ФФ╖ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╖ФФ╖ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╗ФФ╖ФФ╗ФФ╗ФФ╗Фи⌠и╟┌╟и■ии⌠ии⌠ии■ии■ии⌠ии■ии■ии⌠ии⌠ии■ии■ии⌠ии⌠ии■ии■ии■ии■ии⌠ии■ии■ии■ии■ии■ии⌠ии■ии■ии■ии⌠ии⌠ии■ии■ии⌠ии■ии■ии■ии■ии⌠ии■ии■ии■ии■ии■ии■ии⌠ии■ии■ии⌠ии⌠ии■ии■ии⌠ии⌠ии■ии■ии■ии■ии⌠ии■ии■ии■ии■ии■ии■ии⌠ии⌠ии■ии■ии⌠ии■ии■ии⌠ии■ии⌠ии■ии⌠ии⌠ии⌠ии⌠ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■ии■и╟┌╟
Binary file not shown.
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
#include "client_assets.h"
IDR_COMBAT_POWER_PANEL_SKIN_V2 RCDATA "assets\\combat_power_panel_skin_v2.bgra"
IDR_COMBAT_RANK_EXPLORE_V3 RCDATA "assets\\combat_rank_explore_v3.pbgra"
IDR_COMBAT_RANK_PIONEER_V3 RCDATA "assets\\combat_rank_pioneer_v3.pbgra"
IDR_COMBAT_RANK_FEARLESS_V3 RCDATA "assets\\combat_rank_fearless_v3.pbgra"
IDR_COMBAT_RANK_CONQUER_V3 RCDATA "assets\\combat_rank_conquer_v3.pbgra"
IDR_COMBAT_RANK_BATTLE_PINNACLE_V3 RCDATA "assets\\combat_rank_battle_pinnacle_v3.pbgra"
IDR_COMBAT_RANK_HEROIC_V3 RCDATA "assets\\combat_rank_heroic_v3.pbgra"
IDR_COMBAT_RANK_MARTIAL_MASTERY_V3 RCDATA "assets\\combat_rank_martial_mastery_v3.pbgra"
IDR_COMBAT_POWER_RANK_TOOLTIP_V2 RCDATA "assets\\combat_power_rank_tooltip_v2.bgra"
+18 -93
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>90轮回</title>
<title>DNF90 灵狐小竹整合单机版</title>
<link rel="stylesheet" href="/style.css">
<link rel="stylesheet" href="/shell.css">
</head>
@@ -22,18 +22,15 @@
<header class="shell-topnav">
<div class="shell-brand">
<div class="shell-logo">90</div>
<div><b>90轮回</b><span>启动器 <em>v2.1.0</em></span></div>
<div><b>灵狐小竹</b><span>启动器 <em>v2.1.0</em></span></div>
</div>
<nav class="shell-nav">
<button class="shell-nav-item active" data-shell-page="start"><span class="nav-ico">▶</span>开始</button>
<button class="shell-nav-item" data-shell-page="changelog"><span class="nav-ico">📜</span>游戏更新</button>
<button class="shell-nav-item" data-shell-page="gm"><span class="nav-ico">⚙</span>GM工具</button>
<button class="shell-nav-item" data-shell-page="loadout"><span class="nav-ico">🔄</span>一键换装</button>
<button class="shell-nav-item" data-shell-page="servertool"><span class="nav-ico">🧰</span>服务端工具</button>
<button class="shell-nav-item" data-shell-page="companion"><span class="nav-ico">👥</span>支援兵</button>
<button class="shell-nav-item" data-shell-page="plugins"><span class="nav-ico">🧩</span>插件</button>
<button class="shell-nav-item" data-shell-page="gamesettings"><span class="nav-ico">🎮</span>游戏设置</button>
<div class="shell-nav-gap"></div>
<button class="shell-nav-item" data-shell-page="settings"><span class="nav-ico">⚙</span>设置</button>
<button class="shell-nav-item" data-shell-page="changelog"><span class="nav-ico">📜</span>更新日志</button>
</nav>
<div class="shell-win-controls">
<button class="tb-btn" id="shellWinMin" title="最小化">—</button>
@@ -51,7 +48,7 @@
<div class="hero-bg"></div>
<div class="hero-content">
<div class="hero-kicker">DNF · 90级 · 本地服务端</div>
<h1 id="shellServerName">Server90轮回</h1>
<h1 id="shellServerName">DNF90 灵狐小竹整合单机版</h1>
<div class="hero-status">
<span class="live-dot" id="shellStatusDot"></span>
<span id="shellStatusText">检测中…</span>
@@ -111,98 +108,26 @@
</div>
</section>
<section class="shell-page" id="page-servertool">
<section class="shell-page" id="page-gamesettings">
<div class="page-pad">
<div class="page-heading"><div><h2>服务端工具</h2><p class="muted">按原 ServerTool 页签组织,配置写入 server-features.json / activity.json。</p></div><span class="tag" id="stSource">文件配置</span></div>
<div class="st-tabs" id="stTabs">
<button class="st-tab active" data-st="global">全局游戏参数</button>
<button class="st-tab" data-st="activity">在线活动</button>
<button class="st-tab" data-st="gameplay">游戏参数</button>
<button class="st-tab" data-st="growth">疲劳/成长</button>
<button class="st-tab" data-st="raid">团本配置</button>
<button class="st-tab" data-st="launch">启动控制</button>
<div class="page-heading"><div><h2>游戏设置</h2><p class="muted">调整服务端频道与活动;改动实时生效,无需重启。</p></div><button class="shell-btn" id="gsRefresh">刷新</button></div>
<div class="st-tabs" id="gsTabs">
<button class="st-tab active" data-gs="channels">频道开关</button>
<button class="st-tab" data-gs="activities">活动开关</button>
</div>
<div class="st-subtabs" id="stSubTabs"></div>
<div class="st-toolbar"><button class="shell-btn primary" id="stSave">保存当前页</button><button class="shell-btn" id="stReload">重新读取</button><span id="stStatus" class="muted"></span></div>
<div class="st-body" id="stBody"></div>
</div>
</section>
<section class="shell-page" id="page-companion">
<div class="page-pad">
<div class="page-heading"><div><h2>支援兵</h2><p class="muted">按账号配置支援兵;配置只影响下一次进入副本。</p></div><button class="shell-btn primary" id="cpSave">保存配置</button></div>
<div class="cp-toolbar">
<label>账号<select id="cpAccount" class="shell-input"></select></label>
<label>出战角色<select id="cpOwner" class="shell-input"></select></label>
<button class="shell-btn" id="cpReload">刷新</button>
<span id="cpStatus" class="muted"></span>
<div id="gsChannelsPane">
<div class="gs-summary muted" id="gsChannelSummary">正在加载频道…</div>
<div class="gs-grid" id="gsChannelGrid"><div class="empty-box">加载中…</div></div>
</div>
<div class="cp-flags">
<label class="st-check"><input type="checkbox" id="cpEnabled">启用支援兵</label>
<label class="st-check"><input type="checkbox" id="cpBalance">读取佩戴装备</label>
<label class="st-check"><input type="checkbox" id="cpProgression">启用成长推进</label>
<label class="st-check"><input type="checkbox" id="cpRandom">随机武器装备</label>
<label class="st-check"><input type="checkbox" id="cpBasicAttack">允许支援兵普攻</label>
</div>
<h3 class="cp-title">选择支援兵角色</h3>
<div class="cp-grid" id="cpGrid"><div class="empty-box">选择出战角色后加载支援兵列表</div></div>
<div class="cp-ai">
<div class="cp-ai-head"><h3>AI 策略</h3><select id="cpAISource" class="shell-input"></select></div>
<div class="st-form">
<label>行为模式<select id="cpAIBehavior"><option value="assist">辅助</option><option value="aggressive">好战</option></select></label>
<label>BUFF 优先级<select id="cpAIBuff"><option value="leader_first">优先队长</option><option value="assist_only">仅辅助</option><option value="off">关闭</option></select></label>
<label>职业连招<select id="cpAIComboPreset"><option value="auto">按职业自动</option><option value="sword_guard">鬼剑士:控制连招</option><option value="fighter_chain">格斗家:近身连招</option><option value="gunner_burst">神枪手:远程爆发</option><option value="mage_control">魔法师:范围控制</option><option value="priest_support">圣职者:辅助连招</option></select></label>
<label>范围技能最少目标<input id="cpAIAoe" type="number" min="2" max="6" step="1"></label>
<label class="st-check"><input type="checkbox" id="cpAIComboEnabled">启用连招</label>
<label class="st-check"><input type="checkbox" id="cpAIProtect">保护队长</label>
</div>
<details class="st-json">
<summary>高级调参:距离、追击、视野、重试和治疗阈值</summary>
<div class="st-form" id="cpAITuning">
<label>思考间隔(ms)<input data-cp-tuning="think_interval_ms" type="number" min="50" max="2000"></label>
<label>辅助攻击间隔(ms)<input data-cp-tuning="assist_attack_interval_ms" type="number" min="200" max="10000"></label>
<label>辅助横向范围<input data-cp-tuning="assist_leash_x" type="number" min="30" max="500"></label>
<label>辅助纵向范围<input data-cp-tuning="assist_leash_y" type="number" min="20" max="300"></label>
<label>追击范围<input data-cp-tuning="chase_range" type="number" min="20" max="1000"></label>
<label>视野范围<input data-cp-tuning="vision_range" type="number" min="100" max="20000"></label>
<label>目标黏性(ms)<input data-cp-tuning="target_stickiness_ms" type="number" min="0" max="10000"></label>
<label>队长治疗阈值(%)<input data-cp-tuning="heal_leader_percent" type="number" min="0" max="100"></label>
<label>自身治疗阈值(%)<input data-cp-tuning="heal_self_percent" type="number" min="0" max="100"></label>
<label>重试间隔(ms)<input data-cp-tuning="retry_ms" type="number" min="50" max="5000"></label>
<label>辅助重试(ms)<input data-cp-tuning="support_retry_ms" type="number" min="100" max="10000"></label>
<label>忙碌移动(ms)<input data-cp-tuning="busy_move_ms" type="number" min="100" max="10000"></label>
<label>卡住判定(ms)<input data-cp-tuning="stuck_ms" type="number" min="100" max="5000"></label>
<label>绕行等待(ms)<input data-cp-tuning="detour_ms" type="number" min="100" max="5000"></label>
<label>放弃目标(ms)<input data-cp-tuning="give_up_ms" type="number" min="500" max="20000"></label>
<label>跟随距离<input data-cp-tuning="follow_distance" type="number" min="20" max="300"></label>
</div>
</details>
<div class="st-note">AI 策略按角色保存;保存后下一次进入副本生效。</div>
</div>
<div class="st-note" id="cpHint">支援兵按账号隔离,只能选择同一账号下的其他角色。</div>
</div>
</section>
<section class="shell-page" id="page-plugins">
<div class="page-pad"><h2>插件</h2><div class="empty-box">插件目录:启动器扩展位,后续接入 LoadoutTool / ServerTool 之外的模块。</div></div>
</section>
<section class="shell-page" id="page-settings">
<div class="page-pad">
<div class="page-heading"><div><h2>设置</h2><p class="muted">当前启动器实际绑定的服务端、数据库和游戏端。</p></div><button class="shell-btn" id="setRefresh">刷新</button></div>
<div class="settings-grid">
<div class="settings-card"><div class="settings-label">服务端根目录</div><div class="settings-value" id="setServerRoot">—</div></div>
<div class="settings-card"><div class="settings-label">服务端控制</div><div class="settings-value" id="setControl">—</div></div>
<div class="settings-card"><div class="settings-label">数据库</div><div class="settings-value" id="setDb">—</div></div>
<div class="settings-card"><div class="settings-label">游戏客户端</div><div class="settings-value" id="setClient">—</div></div>
<div class="settings-card"><div class="settings-label">服务端状态</div><div class="settings-value" id="setRun">—</div></div>
<div class="settings-card"><div class="settings-label">Native API</div><div class="settings-value" id="setNative">—</div></div>
<div id="gsActivitiesPane" style="display:none">
<div class="gs-summary muted" id="gsActivitySummary">正在加载活动…</div>
<div class="gs-list" id="gsActivityList"><div class="empty-box">加载中…</div></div>
</div>
</div>
</section>
<section class="shell-page" id="page-changelog">
<div class="page-pad"><h2>更新日志</h2><p class="muted">从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。</p><div id="changelogBody"></div></div>
<div class="page-pad"><h2>游戏更新</h2><p class="muted">从自托管仓库(Lingbao/90CNServer)的发布记录自动获取。</p><div id="changelogBody"></div></div>
</section>
<section class="shell-page" id="page-gm">
@@ -239,7 +164,7 @@
<div class="win-resize nw" data-edge="topleft"></div><div class="win-resize ne" data-edge="topright"></div>
<div class="win-resize sw" data-edge="bottomleft"></div><div class="win-resize se" data-edge="bottomright"></div>
<div class="modal-mask hidden" id="sponsorModal"><div class="modal"><div class="modal-head"><span>赞助我们</span><button class="modal-close" id="btnCloseSponsor">×</button></div><div class="modal-body"><p class="sponsor-tip">感谢支持 DNF90 单机模拟器!</p><div class="qrcode-box" id="qrcodeBox"></div></div></div></div>
<div class="modal-mask hidden" id="sponsorModal"><div class="modal"><div class="modal-head"><span>赞助我们</span><button class="modal-close" id="btnCloseSponsor">×</button></div><div class="modal-body"><p class="sponsor-tip">感谢支持 DNF90 灵狐小竹整合单机版!</p><div class="qrcode-box" id="qrcodeBox"></div></div></div></div>
<div class="modal-mask hidden" id="accountsModal"><div class="modal" style="width:520px"><div class="modal-head"><span>账号管理</span><button class="modal-close" id="btnCloseAccounts" onclick="document.getElementById('accountsModal').classList.add('hidden')">×</button></div><div class="modal-body" style="text-align:left"><div id="accountsList" style="margin-bottom:16px"></div><div class="accounts-add"><div class="accounts-add-title">添加新账号</div><div class="gm-form-row"><div class="gm-field"><label>用户名</label><input class="gm-input" id="newAccUser"></div><div class="gm-field"><label>密码</label><input class="gm-input" id="newAccPass" type="password"></div></div><button class="gm-btn gm-btn-primary" id="btnAddAccount">+ 添加账号</button></div></div></div></div>
<script src="/app.js"></script>
+15 -9
View File
@@ -623,15 +623,21 @@ class NativeClient(object):
return self._unwrap(self._post("/local/dnf-gm/loadout/apply", {
"character_id": str(character_id), "slots": slots}))
def get_server_features(self):
return self._unwrap(self._get("/dnf/gm/features"))
# ---- 游戏设置:活动开关 / 频道开关(Native,服务端在线)--------
def get_activity_switches(self):
"""GET /dnf/gm/activity-switches -> GMActivitySwitches"""
return self._unwrap(self._get("/dnf/gm/activity-switches"))
def save_server_features(self, features):
return self._unwrap(self._post("/dnf/gm/features", features))
def set_activity_switch(self, key, enabled):
"""POST /dnf/gm/activity-switches {key:str, enabled:bool}"""
return self._unwrap(self._post("/dnf/gm/activity-switches", {
"key": str(key), "enabled": bool(enabled)}))
def companion_state(self, character_id):
qs = urlencode({"characterId": str(character_id)})
return self._unwrap(self._get("/local/companion?" + qs))
def get_channel_switches(self):
"""GET /dnf/gm/channel-switches -> GMChannelSwitches"""
return self._unwrap(self._get("/dnf/gm/channel-switches"))
def companion_save(self, payload):
return self._unwrap(self._post("/local/companion", payload))
def set_channel_switch(self, channel_id, enabled):
"""POST /dnf/gm/channel-switch {channel_id:int, enabled:bool}"""
return self._unwrap(self._post("/dnf/gm/channel-switch", {
"channel_id": int(channel_id), "enabled": bool(enabled)}))
+68 -205
View File
@@ -107,14 +107,12 @@ PVF_PATH = os.path.join(CLIENT_DIR, "Script.pvf")
# 一键换装工具(独立工具,放在服务端根目录的 LoadoutTool 文件夹下)
LOADOUT_DIR = os.path.join(SERVER_ROOT, "LoadoutTool")
LOADOUT_EXE = os.path.join(LOADOUT_DIR, "DNFLoadout.exe")
LOADOUT_EXE = os.path.join(LOADOUT_DIR, "换装工具.exe")
if not os.path.exists(LOADOUT_EXE):
LOADOUT_EXE = os.path.join(LOADOUT_DIR, "DNFLoadout.exe")
# 一键换装 / 服务端工具的持久化配置路径
# 一键换装的持久化配置路径
LOADOUT_CONFIG = os.path.join(LOADOUT_DIR, "config.json")
SERVER_CONFIG_DIR = os.path.join(SERVER_ROOT, "Server", "runtime", "config")
SERVER_FEATURES_PATH = os.path.join(SERVER_CONFIG_DIR, "server-features.json")
ACTIVITY_CONFIG_PATH = os.path.join(SERVER_CONFIG_DIR, "activity.json")
RAW_CONFIG_NAMES = ("instance.json", "dnfbridge.toml", "logic.toml", "plan.json")
# 赞助二维码图片路径(放启动器目录下 sponsor.png)
SPONSOR_IMG = os.path.join(BASE_DIR, "sponsor.png")
@@ -545,23 +543,6 @@ class Handler(BaseHTTPRequestHandler):
self._api_loadout_characters()
elif path == "/api/loadout/equipment":
self._api_loadout_equipment(qs)
elif path == "/api/servertool/features":
self._api_servertool_features()
elif path == "/api/servertool/activity":
self._api_servertool_activity()
elif path == "/api/servertool/info":
self._api_servertool_info()
elif path == "/api/servertool/raw":
self._api_servertool_raw(qs)
elif path == "/api/servertool/gameplay":
self._api_servertool_gameplay()
elif path == "/api/servertool/batch-pickup":
self._api_servertool_batch_pickup()
elif path == "/api/companion":
self._api_companion(qs)
elif path == "/api/basic-attack":
self._json({"ok": True, "enabled": not os.path.exists(
os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled"))})
elif path == "/api/logs":
self._api_logs()
elif path == "/api/gm/health":
@@ -597,6 +578,10 @@ class Handler(BaseHTTPRequestHandler):
self._api_character_black_diamond(qs)
elif path == "/api/gameplay":
self._api_gameplay(qs)
elif path == "/api/game-settings/activity-switches":
self._api_activity_switches()
elif path == "/api/game-settings/channel-switches":
self._api_channel_switches()
elif path == "/api/quests":
self._api_quests(qs)
elif path == "/api/quests/search":
@@ -627,32 +612,6 @@ class Handler(BaseHTTPRequestHandler):
self._api_save_loadout_config(data)
elif path == "/api/loadout/apply":
self._api_loadout_apply(data)
elif path == "/api/servertool/features":
self._api_save_servertool_features(data)
elif path == "/api/servertool/activity":
self._api_save_servertool_activity(data)
elif path == "/api/servertool/raw":
self._api_save_servertool_raw(data)
elif path == "/api/servertool/gameplay":
self._api_save_servertool_gameplay(data)
elif path == "/api/servertool/batch-pickup":
self._api_save_servertool_batch_pickup(data)
elif path == "/api/companion":
self._api_save_companion(data)
elif path == "/api/basic-attack":
enabled = bool(data.get("enabled", True))
flag = os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled")
try:
if enabled:
if os.path.exists(flag):
os.remove(flag)
else:
os.makedirs(CLIENT_DIR, exist_ok=True)
with open(flag, "w", encoding="utf-8") as f:
f.write("disabled\n")
self._json({"ok": True, "enabled": enabled})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 500)
elif path == "/api/accounts-config":
self._api_accounts_config_post(data)
elif path == "/api/source":
@@ -683,6 +642,10 @@ class Handler(BaseHTTPRequestHandler):
self._api_gameplay_fatigue_mode(data)
elif path == "/api/gameplay/burning":
self._api_gameplay_burning(data)
elif path == "/api/game-settings/activity-switch":
self._api_activity_switch(data)
elif path == "/api/game-settings/channel-switch":
self._api_channel_switch(data)
elif path == "/api/character/fatigue/refill":
self._api_fatigue_refill(data)
elif path == "/api/quests/action":
@@ -866,161 +829,6 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
# ==================== 服务端工具 ====================
def _api_servertool_features(self):
if NATIVE is not None and server_running():
try:
self._json({"ok": True, "source": "native",
"data": NATIVE.get_server_features()})
return
except Exception:
pass
cfg = _read_json_file(SERVER_FEATURES_PATH, {})
self._json({"ok": True, "source": "file", "data": cfg})
def _api_save_servertool_features(self, data):
if NATIVE is not None and server_running():
try:
self._json({"ok": True, "source": "native",
"data": NATIVE.save_server_features(data)})
return
except Exception as e:
fallback_error = str(e)
else:
fallback_error = "Native API 不可用"
_write_json_file(SERVER_FEATURES_PATH, data)
self._json({"ok": True, "source": "file",
"warning": fallback_error, "data": data})
def _api_servertool_activity(self):
self._json({"ok": True,
"data": _read_json_file(ACTIVITY_CONFIG_PATH, {})})
def _api_save_servertool_activity(self, data):
_write_json_file(ACTIVITY_CONFIG_PATH, data)
self._json({"ok": True, "data": data})
def _api_servertool_info(self):
self._json({
"ok": True,
"features_path": SERVER_FEATURES_PATH,
"activity_path": ACTIVITY_CONFIG_PATH,
"config_dir": SERVER_CONFIG_DIR,
"raw_names": list(RAW_CONFIG_NAMES),
"server_running": server_running(),
})
def _api_servertool_raw(self, qs):
name = (qs.get("name") or [""])[0]
if name not in RAW_CONFIG_NAMES:
self._json({"error": "invalid config name"}, 400)
return
path = os.path.join(SERVER_CONFIG_DIR, name)
try:
text = ""
if os.path.exists(path):
with open(path, "r", encoding="utf-8-sig") as f:
text = f.read()
self._json({"ok": True, "name": name, "path": path, "text": text})
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_save_servertool_raw(self, data):
name = str(data.get("name", ""))
text = str(data.get("text", ""))
if name not in RAW_CONFIG_NAMES:
self._json({"error": "invalid config name"}, 400)
return
path = os.path.join(SERVER_CONFIG_DIR, name)
try:
os.makedirs(SERVER_CONFIG_DIR, exist_ok=True)
if os.path.exists(path):
shutil.copy2(path, path + ".bak_tool")
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
self._json({"ok": True, "name": name, "path": path})
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_servertool_gameplay(self):
if NATIVE is not None and server_running():
try:
self._json({"ok": True, "source": "native",
"data": NATIVE.get_gameplay_settings()})
return
except Exception:
pass
self._json({"ok": True, "source": "file", "data": {}})
def _api_save_servertool_gameplay(self, data):
if NATIVE is None or not server_running():
self._json({"error": "服务端未运行,无法写入玩法参数"}, 400)
return
try:
if data.get("hellEpicDropRatePercent") is not None:
NATIVE.set_hell_drop_rate(int(data["hellEpicDropRatePercent"]))
if data.get("epicPotionMultiplierPercent") is not None:
NATIVE.set_epic_potion_multiplier(int(data["epicPotionMultiplierPercent"]))
self._json({"ok": True, "data": data})
except Exception as e:
self._json({"error": str(e)}, 502)
def _batch_pickup_path(self):
return INSTANCE_PATH if INSTANCE_PATH else os.path.join(
SERVER_CONFIG_DIR, "instance.json")
def _api_servertool_batch_pickup(self):
path = self._batch_pickup_path()
cfg = _read_json_file(path, {})
value = True
try:
value = bool(cfg.get("game", {}).get("dungeonBatchPickupEnabled", True))
except Exception:
value = True
self._json({"ok": True, "data": {"enabled": value}, "path": path})
def _api_save_servertool_batch_pickup(self, data):
path = self._batch_pickup_path()
cfg = _read_json_file(path, None)
if not isinstance(cfg, dict):
self._json({"error": "instance.json 不存在或格式错误"}, 400)
return
game = cfg.get("game")
if not isinstance(game, dict):
game = {}
cfg["game"] = game
game["dungeonBatchPickupEnabled"] = bool(data.get("enabled", True))
try:
if os.path.exists(path):
shutil.copy2(path, path + ".bak_tool")
_write_json_file(path, cfg)
self._json({"ok": True, "data": {"enabled": game["dungeonBatchPickupEnabled"]}})
except Exception as e:
self._json({"error": str(e)}, 500)
# ==================== 支援兵 ====================
def _api_companion(self, qs):
if NATIVE is None or not server_running():
self._json({"ok": False, "error": "服务端未运行,支援兵配置不可用"}, 400)
return
character = (qs.get("character") or qs.get("id") or [""])[0].strip()
if not character:
self._json({"ok": False, "error": "缺少角色 ID"}, 400)
return
try:
self._json({"ok": True, "data": NATIVE.companion_state(character)})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
def _api_save_companion(self, data):
if NATIVE is None or not server_running():
self._json({"ok": False, "error": "服务端未运行,无法保存支援兵配置"}, 400)
return
try:
self._json({"ok": True, "data": NATIVE.companion_save(data)})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
# ==================== 自定义账号配置 ====================
def _api_accounts_config(self):
"""返回账号列表(含默认标记)。"""
@@ -1414,6 +1222,61 @@ class Handler(BaseHTTPRequestHandler):
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_activity_switches(self):
if not self._require_native():
return
try:
res = NATIVE.get_activity_switches()
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_activity_switch(self, data):
if not self._require_native():
return
key = data.get("key", "")
enabled = bool(data.get("enabled", False))
if not key:
self._json({"error": "missing key"}, 400)
return
try:
res = NATIVE.set_activity_switch(key, enabled)
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_channel_switches(self):
if not self._require_native():
return
try:
res = NATIVE.get_channel_switches()
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_channel_switch(self, data):
if not self._require_native():
return
try:
channel_id = int(data.get("channel_id"))
except (TypeError, ValueError):
self._json({"error": "missing or invalid channel_id"}, 400)
return
enabled = bool(data.get("enabled", False))
try:
res = NATIVE.set_channel_switch(channel_id, enabled)
self._json({"ok": True, "data": res})
except NativeError as e:
self._json({"error": str(e)}, 502)
except Exception as e:
self._json({"error": str(e)}, 500)
def _api_dungeon_unlock(self, data):
char_id = str(data.get("id", ""))
kind = data.get("kind", "difficulties")
@@ -2421,7 +2284,7 @@ def show_window(url):
# 主窗口:启动器(无边框,屏幕居中)
window = webview.create_window(
"DNF90 单机模拟器",
"DNF90 灵狐小竹整合单机版",
url,
width=1204,
height=717,
+29 -86
View File
@@ -38,10 +38,6 @@ button,input,select{font:inherit}
padding:0 18px;border-bottom:1px solid #14291f;color:var(--muted);font-size:12px;
cursor:move;user-select:none}
.shell-topbar button,.shell-topbar select,.shell-topbar input{cursor:pointer}
.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(240px,1fr));gap:10px;margin-top:14px}
.settings-card{border:1px solid #1d3a2d;background:#0b1712;padding:12px 14px;border-radius:4px}
.settings-label{font-size:11px;color:#83a393;margin-bottom:5px}
.settings-value{font:11px/1.6 Consolas,"Microsoft YaHei",monospace;color:#d5e8de;word-break:break-all}
.shell-top-actions{display:flex;gap:8px;align-items:center}
.shell-top-actions select{background:#0c1b16;border:1px solid #25483a;color:#bfe4d2;padding:4px 8px;border-radius:2px}
.tb-btn{width:28px;height:24px;border:1px solid #25483a;background:#0c1b16;color:#a8c7b6;cursor:pointer}
@@ -165,32 +161,6 @@ button,input,select{font:inherit}
.lo-slot b{display:block;color:#d5ece0;font-weight:400;font-family:Consolas,monospace}
/* server tool */
.st-tabs{display:flex;flex-wrap:wrap;gap:2px;border-bottom:1px solid #244435;margin-bottom:10px}
.st-tab{background:none;border:1px solid transparent;border-bottom:0;color:#8dac9c;padding:8px 13px;cursor:pointer;font-size:13px}
.st-tab:hover{color:#d8eee3}
.st-tab.active{background:#10251c;border-color:#2a503d;color:#8fe5b8}
.st-subtabs{display:flex;gap:4px;margin:-2px 0 10px;padding:6px 0;border-bottom:1px solid #1d3a2d}
.st-subtab{background:transparent;border:1px solid transparent;color:#8dac9c;padding:3px 9px;cursor:pointer;font-size:11px;border-radius:5px}
.st-subtab.active{background:rgba(124,92,255,.16);border-color:rgba(124,92,255,.35);color:#dcd4ff}
.st-toolbar{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.st-body{border:1px solid #1d3a2d;background:#0b1712;padding:14px;min-height:300px}
.st-section{margin-bottom:16px}
.st-section h3{margin:0 0 8px;color:#9cd8b8;font-size:15px;font-weight:400;border-left:2px solid #4d9f77;padding-left:8px}
.st-form{display:grid;grid-template-columns:repeat(2,minmax(260px,1fr));gap:12px 18px}
.st-form label{display:flex;flex-direction:column;gap:4px;font-size:13px;color:#83a393}
.st-hint{font-size:11.5px;color:#687f73;line-height:1.4;margin-bottom:1px}
.st-form input,.st-form select{background:#08130f;border:1px solid #254636;color:#d2e7db;padding:8px 9px;border-radius:2px;font-size:13px;height:34px}
.st-check{display:flex;align-items:center;gap:6px;font-size:13px;color:#b7d4c5;padding:5px 0}
.st-check-row{display:flex;gap:28px;flex-wrap:wrap;border:1px solid #1d3a2d;background:#0b1712;padding:8px 12px;margin-bottom:10px;border-radius:3px}
.st-check-row .st-check{color:#d5e8de;font-size:13px}
.st-check input{width:15px;height:15px}
.st-note{font-size:12.5px;color:#638373;margin:6px 0}
.st-json{margin-top:14px;border-top:1px solid #1d3a2d;padding-top:10px;color:#8eae9d;font-size:12px}
.st-raw{width:100%;height:300px;background:#07110e;border:1px solid #254636;color:#cfe5d9;
font:11px/1.5 Consolas,monospace;padding:8px;resize:vertical;border-radius:3px;margin:6px 0}
.st-json summary{cursor:pointer;color:#a9d8bf;margin-bottom:8px}
.st-json textarea{width:100%;height:220px;background:#07110e;border:1px solid #254636;color:#cfe5d9;
font:11px/1.5 Consolas,monospace;padding:8px;resize:vertical;border-radius:2px}
/* GM embedded */
#page-gm{padding:0}
@@ -210,7 +180,6 @@ button,input,select{font:inherit}
@media (max-width:1000px){
.stat-grid{grid-template-columns:repeat(2,1fr)}
.loadout-layout{grid-template-columns:1fr}
.st-form{grid-template-columns:repeat(2,1fr)}
}
/* ================= 沿用原启动器配色与图标 ================= */
@@ -277,7 +246,7 @@ button,input,select{font:inherit}
.shell-input:focus{outline:1px solid var(--primary);border-color:var(--primary)}
.tag{border-color:rgba(124,92,255,.45);color:#c9beff;background:rgba(124,92,255,.1)}
.empty-box{border-color:rgba(255,255,255,.12);color:#70748d;background:rgba(20,20,36,.45)}
.loadout-side,.loadout-main,.st-body{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px}
.loadout-side,.loadout-main{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px}
.lo-page{border-color:rgba(255,255,255,.12);color:#aeb2c8;border-radius:6px}
.lo-page.active{background:rgba(124,92,255,.2);color:#eee9ff;border-color:rgba(124,92,255,.5)}
.lo-list{border-top-color:rgba(255,255,255,.08)}
@@ -285,38 +254,9 @@ button,input,select{font:inherit}
.lo-item:hover,.lo-item.active{background:rgba(124,92,255,.16);color:#f2f3f8}
.lo-slot{background:rgba(0,0,0,.18);border-color:rgba(255,255,255,.09);color:#aeb2c8}
.lo-slot b{color:#eef0fb}
.st-tabs{border-bottom-color:rgba(255,255,255,.1)}
.st-tab{color:#9a9eb5}.st-tab:hover{color:#fff}
.st-tab.active{background:rgba(124,92,255,.15);border-color:rgba(124,92,255,.35);color:#dcd4ff;border-radius:8px 8px 0 0}
.st-section h3{color:#c9beff;border-left-color:var(--primary)}
.st-form label,.st-json{color:#9a9eb5}
.st-form input,.st-form select{background:rgba(0,0,0,.22);border-color:rgba(255,255,255,.13);color:#eef0fb;border-radius:6px}
.st-form input:focus,.st-form select:focus{outline:1px solid var(--primary)}
.st-check{color:#c4c8dc}
.st-json summary{color:#c9beff}
.st-json textarea{background:rgba(0,0,0,.25);border-color:rgba(255,255,255,.13);color:#e6e8f5}
.shell-statusbar{background:#08080f;border-top-color:rgba(255,255,255,.08);color:#7d8198}
.shell-statusbar b{color:#b8bcd0}
#page-gm #gmView{background:#0b0b14}
.settings-card{background:rgba(20,20,36,.55);border-color:rgba(255,255,255,.09);border-radius:10px}
.settings-label{color:#8d91a8}.settings-value{color:#e2e4f2}
.cp-toolbar{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:16px}
.cp-toolbar label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:#9a9eb5}
.cp-toolbar select{min-width:220px}
.cp-flags{display:flex;flex-wrap:wrap;gap:28px;border:1px solid rgba(255,255,255,.1);border-radius:10px;
padding:10px 16px;margin-bottom:18px;background:rgba(20,20,36,.45)}
.cp-title{font-size:15px;font-weight:400;color:#c9beff;margin:0 0 10px}
.cp-grid{display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px}
.cp-card{border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:12px;background:rgba(20,20,36,.55);cursor:pointer}
.cp-card.selected{border-color:rgba(124,92,255,.7);background:rgba(124,92,255,.16)}
.cp-card b{font-size:14px;display:block;color:#eef0fb}
.cp-card small{font-size:12px;color:#9a9eb5}
.cp-card input{margin-right:7px}
.cp-ai{margin-top:20px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(20,20,36,.45);padding:14px}
.cp-ai-head{display:flex;align-items:center;gap:14px;margin-bottom:12px}
.cp-ai-head h3{margin:0;font-size:15px;font-weight:400;color:#c9beff}
.cp-ai-head select{width:240px}
.cp-ai .st-form{margin-bottom:10px}
/* ================= 字号整体 +2,间距放宽 ================= */
body{font-size:16px}
@@ -381,34 +321,9 @@ body{font-size:16px}
.lo-cooldown.active{color:#ffcf7a}
.lo-slot{font-size:13px;padding:9px}
.lo-slot b{font-size:13px}
.st-tabs{gap:4px;margin-bottom:14px}
.st-tab{font-size:15px;padding:9px 14px}
.st-subtabs{gap:6px;padding:8px 0;margin-bottom:12px}
.st-subtab{font-size:13px;padding:5px 11px}
.st-toolbar{gap:10px;margin-bottom:14px}
.st-body{padding:18px;min-height:380px}
.st-section{margin-bottom:20px}
.st-section h3{font-size:17px;margin-bottom:12px;padding-left:10px}
.st-form{grid-template-columns:repeat(2,minmax(280px,1fr));gap:16px 22px}
.st-form label{font-size:15px;gap:6px}
.st-form input,.st-form select{font-size:15px;padding:9px 10px;height:38px}
.st-hint{font-size:13px;line-height:1.5}
.st-note{font-size:14px;line-height:1.6}
.st-check{font-size:15px;gap:9px;padding:8px 0}
.st-check input{width:17px;height:17px}
.st-check-row{gap:34px;padding:12px 16px;margin-bottom:14px}
.st-check-row .st-check{font-size:15px}
.st-json{font-size:14px;padding-top:14px;margin-top:18px}
.st-json summary{font-size:14px;margin-bottom:10px}
.st-json textarea,.st-raw{font-size:13px;height:260px}
.shell-statusbar{height:34px;font-size:13px;gap:22px;padding:0 18px}
.settings-grid{gap:14px;margin-top:18px}
.settings-card{padding:16px 18px}
.settings-label{font-size:13px}
.settings-value{font-size:13px;line-height:1.7}
@media (max-width:1100px){
.stat-grid{grid-template-columns:repeat(2,1fr)}
.st-form{grid-template-columns:1fr}
.hero-actions{flex-wrap:wrap;gap:10px}
}
.win-resize{position:fixed;z-index:10000;display:none}
@@ -446,3 +361,31 @@ body.gm-fullscreen .win-resize{display:none}
.shell-win-controls .tb-btn:hover{background:rgba(255,255,255,.1);color:#fff}
.shell-win-controls .tb-close:hover{background:var(--danger);color:#fff}
/* ================= 游戏设置(频道开关 / 活动开关) ================= */
.st-tabs{display:flex;gap:6px;margin:14px 0 16px;border-bottom:1px solid var(--line)}
.st-tab{appearance:none;background:transparent;border:0;border-bottom:2px solid transparent;
color:var(--muted);padding:8px 16px;cursor:pointer;font-size:14px}
.st-tab:hover{color:var(--text)}
.st-tab.active{color:var(--primary);border-bottom-color:var(--primary);font-weight:500}
.gs-summary{font-size:12px;margin-bottom:12px}
.gs-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
.gs-card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
.gs-card.gs-off{opacity:.72}
.gs-card-head{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px}
.gs-card-name{font-weight:600;color:var(--text)}
.gs-card-meta{font-size:12px;margin-bottom:12px;min-height:17px}
.gs-card-actions{display:flex;gap:8px}
.gs-list{display:flex;flex-direction:column;gap:10px}
.gs-row{display:flex;align-items:center;justify-content:space-between;gap:16px;
background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 16px}
.gs-row.gs-off{opacity:.72}
.gs-row-main{flex:1;min-width:0}
.gs-row-name{font-weight:600;color:var(--text);margin-bottom:3px}
.gs-row-desc{font-size:12px}
.gs-switch{position:relative;width:44px;height:24px;border-radius:12px;border:0;
background:rgba(255,255,255,.12);cursor:pointer;flex-shrink:0;transition:background .2s;padding:0}
.gs-switch .gs-knob{position:absolute;top:2px;left:2px;width:20px;height:20px;border-radius:50%;
background:#fff;transition:left .2s;box-shadow:0 1px 3px rgba(0,0,0,.3)}
.gs-switch.on{background:var(--primary)}
.gs-switch.on .gs-knob{left:22px}
+156 -497
View File
@@ -1,19 +1,13 @@
/* 90轮回启动器 · 单窗口页面与工具整合 */
(function () {
const $ = (id) => document.getElementById(id);
const pages = ['start','gm','loadout','companion','servertool','plugins','settings','changelog'];
const pages = ['start','changelog','gm','loadout','gamesettings'];
let shellGmInit = false;
let loConfig = null, loPage = 0, loScheme = -1;
let loCooldownUntil = 0, loCooldownCharacter = '', loCooldownTimer = null;
let stFeatures = {}, stActivity = {}, stTab = 'global';
let stGameplay = {};
let stBatchPickup = true;
let hotkeyCapture = false;
let stRaidTab = 'anton';
let stFeatureTab = 'instance.json';
let stRawText = '';
const crumb = {start:'开始 / 本地服务端',gm:'GM工具',loadout:'工具 / 一键换装',companion:'工具 / 支援兵',servertool:'工具 / 服务端工具',plugins:'插件',settings:'设置',changelog:'更新日志'};
const crumb = {start:'开始 / 本地服务端',changelog:'游戏更新',gm:'GM工具',loadout:'工具 / 一键换装',gamesettings:'工具 / 游戏设置'};
function shellGo(page) {
if (!pages.includes(page)) page = 'start';
@@ -22,9 +16,7 @@
$('shellCrumb').textContent = crumb[page] || page;
if (page === 'start') loadStart();
if (page === 'loadout') loadLoadout();
if (page === 'companion') loadCompanion();
if (page === 'servertool') loadServerTool();
if (page === 'settings') loadSettings();
if (page === 'gamesettings') loadGameSettings();
if (page === 'changelog') loadChangelog();
if (page === 'gm') {
// 进入 GM:GM 覆盖整个窗口(隐藏顶部导航/面包屑/底部状态栏),并铺满工作区
@@ -483,504 +475,171 @@
saveLoadout();
});
// ---------------- 支援兵 ----------------
let cpAccounts = [], cpCharacters = [], cpState = null, cpSelected = new Set();
let cpAIProfiles = {}, cpAISource = '';
let cpLoading = false;
const CP_AI_DEFAULTS = {
behavior:'aggressive', buff_policy:'leader_first', combo_preset:'auto',
combo_enabled:true, aoe_min_targets:3, protect_leader:true,
tuning:{think_interval_ms:120,assist_attack_interval_ms:1800,assist_leash_x:180,assist_leash_y:90,
chase_range:50,vision_range:10000,target_stickiness_ms:1200,heal_leader_percent:85,
heal_self_percent:85,retry_ms:300,support_retry_ms:1500,busy_move_ms:2500,stuck_ms:700,
detour_ms:900,give_up_ms:3500,follow_distance:75}
// ==================== 游戏设置(频道开关 / 活动开关) ====================
let gsCurrentTab = 'channels';
const gsToast = (msg, type = 'info') => {
if (typeof gmToast === 'function') gmToast(msg, type);
else console.log(msg);
};
async function loadCompanion() {
if (cpLoading) return;
cpLoading = true;
$('cpStatus').textContent = '读取账号和角色…';
function gsSwitchTab(tab) {
gsCurrentTab = tab;
document.querySelectorAll('#gsTabs .st-tab').forEach(el =>
el.classList.toggle('active', el.dataset.gs === tab));
$('gsChannelsPane').style.display = tab === 'channels' ? '' : 'none';
$('gsActivitiesPane').style.display = tab === 'activities' ? '' : 'none';
}
function gsChannelTypeText(type) {
const t = Number(type);
if (t === 22) return '特殊区域';
if (t === 11) return '普通城镇';
if (t === 2 || t === 3) return '引导';
return '频道 ' + t;
}
async function gsLoadChannels() {
const grid = $('gsChannelGrid');
const summary = $('gsChannelSummary');
if (!grid) return;
grid.innerHTML = '<div class="empty-box">加载中…</div>';
try {
const a = await api('/api/accounts');
const ba = await api('/api/basic-attack');
$('cpBasicAttack').checked = !ba || ba.enabled !== false;
cpAccounts = (a && a.data) || [];
const sel = $('cpAccount');
sel.innerHTML = cpAccounts.map(x => `<option value="${x.account_id || x.username || ''}">${x.username || x.account_id}</option>`).join('');
if (!cpAccounts.length) { $('cpStatus').textContent = '没有账号'; return; }
await loadCompanionCharacters();
$('cpStatus').textContent = '';
} catch (e) {
$('cpStatus').textContent = '读取失败:' + e.message;
} finally { cpLoading = false; }
}
async function loadCompanionCharacters() {
const account = $('cpAccount').value;
const r = await api('/api/characters?account=' + encodeURIComponent(account));
cpCharacters = (r && r.data) || [];
const owner = $('cpOwner');
owner.innerHTML = cpCharacters.map(c => `<option value="${c.character_id}">${c.name} · Lv${c.level}</option>`).join('');
if (cpCharacters.length) await loadCompanionState();
else { $('cpGrid').innerHTML = '<div class="empty-box">当前账号没有角色</div>'; }
}
async function loadCompanionState() {
const owner = $('cpOwner').value;
if (!owner) return;
$('cpStatus').textContent = '读取支援兵配置…';
try {
const r = await api('/api/companion?character=' + encodeURIComponent(owner));
if (!r.ok) { $('cpStatus').textContent = r.error || '读取失败'; return; }
cpState = r.data || {};
const cfg = (cpState.state && cpState.state.config) || {};
$('cpEnabled').checked = !!cfg.enabled;
$('cpBalance').checked = !!cfg.balance_enabled;
$('cpProgression').checked = !!cfg.progression_enabled;
$('cpRandom').checked = !!cfg.random_equipment_enabled;
cpSelected = new Set((cfg.source_character_ids || []).map(String));
cpAIProfiles = JSON.parse(JSON.stringify(cfg.ai_profiles || {}));
cpAISource = [...cpSelected][0] || '';
renderCompanionGrid();
$('cpStatus').textContent = (cpState.state && cpState.state.preflight && cpState.state.preflight.reason_message) || '已读取';
} catch (e) { $('cpStatus').textContent = '读取失败:' + e.message; }
}
function renderCompanionGrid() {
const owner = String($('cpOwner').value || '');
const list = cpCharacters.filter(c => String(c.character_id) !== owner);
const grid = $('cpGrid');
if (!list.length) { grid.innerHTML = '<div class="empty-box">当前账号没有其他角色可作为支援兵</div>'; renderCpAI(); return; }
grid.innerHTML = list.map(c => {
const id = String(c.character_id), selected = cpSelected.has(id);
return `<label class="cp-card ${selected?'selected':''}">
<input type="checkbox" data-cp-source="${id}" ${selected?'checked':''}>
<b>${c.name || id}</b><small>Lv${c.level ?? '-'} · 职业 ${c.job ?? '-'}</small>
</label>`;
}).join('');
grid.querySelectorAll('input[data-cp-source]').forEach(inp => {
inp.onchange = () => {
const id = inp.dataset.cpSource;
if (inp.checked) cpSelected.add(id); else cpSelected.delete(id);
inp.closest('.cp-card').classList.toggle('selected', inp.checked);
renderCpAI();
};
});
renderCpAI();
}
function cpProfile(id) {
const raw = cpAIProfiles[String(id)] || {};
return {
...CP_AI_DEFAULTS, ...raw,
tuning: {...CP_AI_DEFAULTS.tuning, ...(raw.tuning || {})}
};
}
function renderCpAI() {
const select = $('cpAISource');
const ids = [...cpSelected];
if (!ids.includes(cpAISource)) cpAISource = ids[0] || '';
select.innerHTML = ids.map(id => {
const c = cpCharacters.find(x => String(x.character_id) === id);
return `<option value="${id}">${c ? c.name : id}</option>`;
}).join('');
if (cpAISource) select.value = cpAISource;
if (!cpAISource) {
$('cpAIBehavior').value = 'assist'; $('cpAIBuff').value = 'leader_first';
$('cpAIComboPreset').value = 'auto'; $('cpAIAoe').value = 3;
$('cpAIComboEnabled').checked = true; $('cpAIProtect').checked = true;
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
inp.value = CP_AI_DEFAULTS.tuning[inp.dataset.cpTuning] ?? 0;
});
return;
}
const p = cpProfile(cpAISource);
$('cpAIBehavior').value = ['assist','aggressive'].includes(p.behavior) ? p.behavior : 'aggressive';
$('cpAIBuff').value = p.buff_policy || 'leader_first';
$('cpAIComboPreset').value = p.combo_preset || 'auto';
$('cpAIAoe').value = Number(p.aoe_min_targets) || 3;
$('cpAIComboEnabled').checked = !!p.combo_enabled;
$('cpAIProtect').checked = !!p.protect_leader;
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
inp.value = p.tuning[inp.dataset.cpTuning] ?? 0;
});
}
function readCpAI() {
if (!cpAISource) return;
const p = cpProfile(cpAISource);
p.behavior = $('cpAIBehavior').value;
p.buff_policy = $('cpAIBuff').value;
p.combo_preset = $('cpAIComboPreset').value;
p.combo_enabled = $('cpAIComboEnabled').checked;
p.aoe_min_targets = Math.max(2, Math.min(6, Number($('cpAIAoe').value) || 3));
p.protect_leader = $('cpAIProtect').checked;
p.tuning = {...p.tuning};
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
const n = Number(inp.value);
if (Number.isFinite(n)) p.tuning[inp.dataset.cpTuning] = n;
});
cpAIProfiles[String(cpAISource)] = p;
}
$('cpAccount').addEventListener('change', async () => {
try { await loadCompanionCharacters(); } catch (e) { $('cpStatus').textContent = e.message; }
});
$('cpOwner').addEventListener('change', loadCompanionState);
$('cpReload').addEventListener('click', loadCompanion);
$('cpAISource').addEventListener('change', () => { cpAISource = $('cpAISource').value; renderCpAI(); });
['cpAIBehavior','cpAIBuff','cpAIComboPreset','cpAIAoe','cpAIComboEnabled','cpAIProtect'].forEach(id => {
$(id).addEventListener('change', readCpAI);
});
document.querySelectorAll('[data-cp-tuning]').forEach(inp => inp.addEventListener('change', readCpAI));
$('cpBasicAttack').addEventListener('change', async () => {
try {
const r = await post('/api/basic-attack', {enabled: $('cpBasicAttack').checked});
if (!r.ok) $('cpStatus').textContent = r.error || '普攻开关保存失败';
else $('cpStatus').textContent = $('cpBasicAttack').checked
? '已允许支援兵普攻;重启客户端后生效。'
: '已关闭支援兵普攻,技能释放不受影响;重启客户端后生效。';
} catch (e) { $('cpStatus').textContent = '普攻开关保存失败:' + e.message; }
});
$('cpSave').addEventListener('click', async () => {
const owner = $('cpOwner').value;
if (!owner) { $('cpStatus').textContent = '请先选择出战角色'; return; }
if ($('cpEnabled').checked && cpSelected.size === 0) {
$('cpStatus').textContent = '启用支援兵时至少选择一个支援兵角色'; return;
}
const state = (cpState && cpState.state) || {};
const cfg = state.config || {};
const sources = [...cpSelected];
readCpAI();
const payload = {
characterId: String(owner),
expectedRevision: Number(cfg.revision || 0),
enabled: $('cpEnabled').checked,
balanceEnabled: $('cpBalance').checked,
progressionEnabled: $('cpProgression').checked,
randomEquipmentEnabled: $('cpRandom').checked,
sourceCharacterId: sources[0] || '',
sourceCharacterIds: sources,
ai: cfg.ai || {},
aiProfiles: cpAIProfiles
};
$('cpStatus').textContent = '保存中…';
try {
const r = await post('/api/companion', payload);
if (!r.ok) { $('cpStatus').textContent = r.error || '保存失败'; return; }
cpState = r.data || cpState;
$('cpStatus').textContent = '支援兵配置已保存,下一次进入副本生效。';
} catch (e) { $('cpStatus').textContent = '保存失败:' + e.message; }
});
// ---------------- 服务端工具 ----------------
async function loadServerTool() {
try {
$('stBody').innerHTML = '<div class="empty-box">正在读取服务端配置…</div>';
const [f, a, s, g, b] = await Promise.all([api('/api/servertool/features'), api('/api/servertool/activity'), api('/api/status'), api('/api/servertool/gameplay'), api('/api/servertool/batch-pickup')]);
stFeatures = f.data || {}; stActivity = a.data || {}; stGameplay = g.data || {};
stBatchPickup = !!(b.data || {}).enabled;
window.__stStatusInfo = s || {};
$('stSource').textContent = f.source === 'native' ? 'Native API' : '文件配置';
renderServerTool();
} catch (e) {
$('stBody').innerHTML = '<div class="empty-box">读取失败:' + (e && e.message ? e.message : e) + '</div>';
$('stStatus').textContent = '读取失败';
}
}
document.querySelectorAll('.st-tab').forEach(b => b.addEventListener('click', () => {
stTab = b.dataset.st;
document.querySelectorAll('.st-tab').forEach(x => x.classList.toggle('active', x === b));
renderServerTool();
}));
const LABEL = {
raidReviveSeconds:'副本复活秒数', allDeadReturnSeconds:'全员死亡返回秒数', allowFreeRevive:'允许免费复活',
hellOrbChance:'深渊宝珠概率', hellDropChanceDenominator:'掉落分母', equipmentRollMultiplier:'装备倍率',
epicPieceRollMultiplier:'史诗碎片倍率', lukeEnergyRecoveryPercent:'卢克能量恢复%', fiendWarSquadCount:'超时空队伍数',
roomFatigueCost:'每房间疲劳', dailyFatigueResetHour:'每日疲劳重置点', adventureGroupBonusPercent:'冒险团加成%',
rentalMaxReinforce:'租赁最大强化', rentalMaxForge:'租赁最大锻造', soloTeleportCooldownSeconds:'单人传送冷却秒',
soloTeleportCooldownIndices:'单人传送索引', auctionStandardListingLimit:'普通拍卖上架数',
auctionPremiumListingLimit:'高级拍卖上架数', auctionStandardDurationHours:'普通拍卖时长',
auctionPremiumDurationHours:'高级拍卖时长', auctionStandardFeePercent:'普通手续费%', auctionPremiumFeePercent:'高级手续费%',
antonBlackFogRecoverySeconds:'安徒恩黑雾恢复秒', antonBlackFogActiveSeconds:'安徒恩黑雾持续秒',
antonQuakeRecoverySeconds:'安徒恩震颤恢复秒', antonQuakeActiveSeconds:'安徒恩震颤持续秒',
antonNavalOpenDelaySeconds:'安徒恩舰炮延迟秒', antonNavalRecoverySeconds:'安徒恩舰炮恢复秒',
antonNavalActiveSeconds:'安徒恩舰炮持续秒', lukeEnergyRecovery0Seconds:'卢克1阶段恢复秒',
lukeEnergyRecovery1Seconds:'卢克2阶段恢复秒', lukeEnergyRecovery2Seconds:'卢克3阶段恢复秒',
lukePhaseTimeoutPercent:'卢克阶段时限%', fiendWarAreaLimitPercent:'超时空区域限制%',
fiendWarDieImprisonPercent:'超时空死亡禁锢%', antonMemberMax:'安徒恩人数上限',
antonStartMinimumMembers:'安徒恩最少开局人数', antonSingleStartMinimumMembers:'安徒恩单人开局人数',
antonStartDelaySeconds:'安徒恩开始延迟', antonPhaseBreakSeconds:'安徒恩阶段间隔',
antonEntryCostItem:'安徒恩入场物品', antonEntryCostCount:'安徒恩入场数量',
antonWeeklyEnterCountA:'安徒恩A每周次数', antonWeeklyEnterCountB:'安徒恩B每周次数',
antonPhaseTimeoutSeconds:'安徒恩阶段时限', lukeDailyAccountEnterLimit:'卢克账号每日次数',
lukeSanctumTimerCount:'卢克圣所计时', lukeEntryCostItem:'卢克入场物品', lukeEntryCostCount:'卢克入场数量',
fiendWarDailyEnterLimit:'超时空每日次数', fiendWarMemberMax:'超时空人数上限',
fiendWarStartMinimumMembers:'超时空最少开局人数', fiendWarDisconnectWaitSeconds:'超时空掉线等待',
fiendWarEntryCostItem:'超时空入场物品', fiendWarEntryCostCount:'超时空入场数量', fiendWarRetry:'超时空重试',
reviveByDungeon:'按副本复活配置'
};
const ST_DESC = {
soloTeleportCooldownSeconds:'单人传送冷却时间,单位秒;服务端读取该值限制重复传送。',
soloTeleportCooldownIndices:'允许触发单人传送的技能索引,多个用逗号分隔。',
auctionStandardListingLimit:'普通拍卖行同时可上架数量。',
auctionStandardDurationHours:'普通拍卖行上架时长,单位小时。',
auctionPremiumListingLimit:'高级拍卖行同时可上架数量。',
auctionPremiumDurationHours:'高级拍卖行上架时长,单位小时。',
auctionStandardFeePercent:'普通拍卖成交手续费百分比。',
auctionPremiumFeePercent:'高级拍卖成交手续费百分比。',
raidReviveSeconds:'团本死亡后等待复活的秒数,有效范围 1-60;不是无限复活选项。',
allDeadReturnSeconds:'队伍全员死亡后返回城镇的等待秒数。',
hellOrbChance:'深渊宝珠掉落判定值。',
hellDropChanceDenominator:'深渊基础掉率分母,数值越大掉率越低。',
equipmentRollMultiplier:'史诗装备掉落 roll 次数倍率。',
epicPieceRollMultiplier:'史诗碎片掉落 roll 次数倍率。',
allowFreeRevive:'允许不使用复活币直接复活。',
roomFatigueCost:'进入每个房间消耗的疲劳值。',
dailyFatigueResetHour:'每日疲劳重置小时,0-23。',
adventureGroupBonusPercent:'冒险团经验加成百分比。',
rentalMaxReinforce:'租赁装备允许的最大强化等级。',
rentalMaxForge:'租赁装备允许的最大锻造等级。',
antonBlackFogRecoverySeconds:'安徒恩黑雾之源恢复等待秒数。',
antonBlackFogActiveSeconds:'安徒恩黑雾之源持续时间秒数。',
antonQuakeRecoverySeconds:'安徒恩震颤之地恢复等待秒数。',
antonQuakeActiveSeconds:'安徒恩震颤之地持续时间秒数。',
antonNavalOpenDelaySeconds:'安徒恩舰炮防御战开启延迟秒数。',
antonNavalRecoverySeconds:'安徒恩舰炮防御战恢复等待秒数。',
antonNavalActiveSeconds:'安徒恩舰炮防御战持续时间秒数。',
lukeEnergyRecovery0Seconds:'卢克能量控制一阶段恢复秒数。',
lukeEnergyRecovery1Seconds:'卢克能量控制二阶段恢复秒数。',
lukeEnergyRecovery2Seconds:'卢克能量控制三阶段恢复秒数。',
lukePhaseTimeoutPercent:'卢克各阶段时限百分比。',
lukeEnergyRecoveryPercent:'卢克能量恢复速度百分比。',
fiendWarSquadCount:'超时空之战小队编制数量。',
fiendWarAreaLimitPercent:'超时空区域进入限制百分比。',
fiendWarDieImprisonPercent:'超时空死亡禁锢触发百分比。'
};
const ST_FIELDS = {
global: [
'soloTeleportCooldownSeconds','soloTeleportCooldownIndices',
'auctionStandardListingLimit','auctionStandardDurationHours',
'auctionPremiumListingLimit','auctionPremiumDurationHours',
'auctionStandardFeePercent','auctionPremiumFeePercent'
],
gameplay: [
'allDeadReturnSeconds','hellOrbChance',
'hellDropChanceDenominator','equipmentRollMultiplier',
'epicPieceRollMultiplier','allowFreeRevive'
],
growth: [
'roomFatigueCost','dailyFatigueResetHour','adventureGroupBonusPercent',
'rentalMaxReinforce','rentalMaxForge'
],
raid: {
anton: [
'antonBlackFogRecoverySeconds','antonBlackFogActiveSeconds',
'antonQuakeRecoverySeconds','antonQuakeActiveSeconds',
'antonNavalOpenDelaySeconds','antonNavalRecoverySeconds','antonNavalActiveSeconds'
],
luke: [
'lukeEnergyRecovery0Seconds','lukeEnergyRecovery1Seconds',
'lukeEnergyRecovery2Seconds','lukePhaseTimeoutPercent',
'lukeEnergyRecoveryPercent'
],
fiend: [
'fiendWarSquadCount','fiendWarAreaLimitPercent','fiendWarDieImprisonPercent'
]
}
};
function allAssignedKeys() {
return new Set([...ST_FIELDS.global, ...ST_FIELDS.gameplay, ...ST_FIELDS.growth,
...ST_FIELDS.raid.anton, ...ST_FIELDS.raid.luke, ...ST_FIELDS.raid.fiend]);
}
function renderServerTool() {
const body = $('stBody');
if (stTab === 'activity') { renderActivity(body); return; }
if (stTab === 'launch') {
body.innerHTML = `<div class="st-section"><h3>启动控制</h3><p class="st-note">配置文件:server-features.json / activity.json。服务端运行状态请以开始页为准。</p><div class="lo-toolbar"><button class="shell-btn primary" id="stStart">开始游戏</button><button class="shell-btn" id="stStop">关闭服务端</button></div><div class="st-note" id="stLaunchInfo"></div></div>`;
$('stStart').onclick = () => doAction('start');
$('stStop').onclick = () => doAction('stop');
api('/api/status').then(s => {
$('stLaunchInfo').textContent = `服务端:${s.server_running ? '运行中' : '未运行'} · 目录:${s.server_root || '未绑定'}`;
}).catch(() => {});
return;
}
let sub = '';
let keys;
if (stTab === 'features') {
keys = Object.keys(stFeatures).filter(k => !allAssignedKeys().has(k));
} else if (stTab === 'raid') {
const names = {anton:'安徒恩机制', luke:'卢克', fiend:'超时空'};
$('stSubTabs').innerHTML = Object.keys(names).map(k =>
`<button class="st-subtab ${stRaidTab===k?'active':''}" data-raid="${k}">${names[k]}</button>`).join('');
$('stSubTabs').querySelectorAll('.st-subtab').forEach(b => b.onclick = () => {
stRaidTab = b.dataset.raid; renderServerTool();
});
keys = ST_FIELDS.raid[stRaidTab] || [];
} else {
$('stSubTabs').innerHTML = '';
keys = ST_FIELDS[stTab] || [];
}
let html = `<div class="st-section"><h3>${tabTitle(stTab)}</h3>`;
if (stTab === 'features') {
const info = window.__stStatusInfo || {};
const rawNames = ['instance.json','dnfbridge.toml','logic.toml','plan.json'];
$('stSubTabs').innerHTML = rawNames.map(n =>
`<button class="st-subtab ${stFeatureTab===n?'active':''}" data-raw="${n}">${n}</button>`).join('');
$('stSubTabs').querySelectorAll('.st-subtab').forEach(b => b.onclick = () => {
stFeatureTab = b.dataset.raw; renderServerTool();
});
html += `<div class="st-form">
<label>服务端目录<input value="${(info.server_root || '').replace(/"/g,'&quot;')}" readonly></label>
<label>Admin 端口<input value="18111" readonly></label>
<label>数据库<input value="${(info.db || '').replace(/"/g,'&quot;')}" readonly></label>
<label>客户端目录<input value="${(info.client_dir || '').replace(/"/g,'&quot;')}" readonly></label>
</div>`;
html += `<div class="st-note">当前配置文件:${stFeatureTab}</div>`;
html += `<textarea id="stRaw" class="st-raw" spellcheck="false">${stRawText.replace(/</g,'&lt;')}</textarea>`;
html += `<div class="st-note">服务端参数页保留实例与配置文件;下方列出未归入其他页的字段。</div>`;
}
html += `<div class="st-form">${groupFields(stFeatures, stTab === 'gameplay' ? keys.filter(k => k !== 'allowFreeRevive') : keys)}</div>`;
if (stTab === 'gameplay') {
html += `<div class="st-form">
<label>深渊史诗掉率(%)<small class="st-hint">100 = 原版,250 = 2.5 倍</small><input data-game-key="hellEpicDropRatePercent" type="number" value="${stGameplay.hell_epic_drop_rate_percent ?? 0}"></label>
<label>史诗药剂测试倍率(%)<small class="st-hint">0 = 关闭测试倍率</small><input data-game-key="epicPotionMultiplierPercent" type="number" value="${stGameplay.epic_drop_rate_potion_test_multiplier_percent ?? 0}"></label>
</div>`;
html += `<div class="st-check-row">
<label class="st-check"><input type="checkbox" data-st-key="allowFreeRevive" ${stFeatures.allowFreeRevive === true ? 'checked' : ''}>允许无复活币免费复活</label>
<label class="st-check"><input type="checkbox" data-batch-pickup ${stBatchPickup ? 'checked' : ''}>服务端允许一键拾取</label>
</div>`;
}
html += `</div>`;
html += `<details class="st-json"><summary>高级:完整 JSON 配置</summary><textarea id="stJson" spellcheck="false"></textarea><div class="lo-toolbar"><button class="shell-btn" id="stJsonApply">应用 JSON</button><span class="muted">可直接编辑全部服务端字段</span></div></details>`;
body.innerHTML = html;
if (stTab === 'features') loadRawConfig(stFeatureTab);
const jsonBox = $('stJson');
if (jsonBox) jsonBox.value = JSON.stringify(stFeatures, null, 2);
$('stJsonApply').onclick = () => {
try {
const obj = JSON.parse($('stJson').value);
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) throw new Error('需要 JSON 对象');
stFeatures = obj; renderServerTool();
$('stStatus').textContent = 'JSON 已载入,点击“保存当前页”写入';
} catch (e) { $('stStatus').textContent = 'JSON 错误:' + e.message; }
};
bindFeatureInputs(body);
}
async function loadRawConfig(name) {
try {
const r = await api('/api/servertool/raw?name=' + encodeURIComponent(name));
stRawText = r.text || '';
const box = $('stRaw');
if (box) box.value = stRawText;
} catch (e) { /* ignore */ }
}
function bindFeatureInputs(body) {
body.querySelectorAll('input[data-st-key]').forEach(inp => {
inp.addEventListener('change', () => {
const key = inp.dataset.stKey;
let v = inp.value;
if (inp.type === 'checkbox') v = inp.checked;
else if (inp.dataset.type === 'number') { const n = Number(v); v = Number.isFinite(n) ? n : 0; }
stFeatures[key] = v;
});
});
body.querySelectorAll('input[data-game-key]').forEach(inp => {
inp.addEventListener('change', () => {
stGameplay[inp.dataset.gameKey] = Number(inp.value) || 0;
});
});
body.querySelectorAll('input[data-batch-pickup]').forEach(inp => {
inp.addEventListener('change', () => { stBatchPickup = inp.checked; });
});
}
function groupFields(obj, keys) {
return keys.map(k => {
const val = obj[k];
const label = LABEL[k] || k;
if (typeof val === 'boolean' || /Retry|Revive|Pickup/.test(k)) {
return `<label class="st-check"><input type="checkbox" data-st-key="${k}" ${val===true?'checked':''}>${label}</label>`;
const r = await api('/api/game-settings/channel-switches');
if (!r.ok) {
grid.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(r.error || '未知错误') + '</div>';
summary.textContent = '';
return;
}
const numeric = (typeof val === 'number') || /Seconds|Percent|Count|Hour|Limit|Cost|Max|Min|Delay|Item/.test(k);
const type = numeric ? 'number' : 'text';
const shown = val === undefined || val === null ? '' : String(val);
const hint = ST_DESC[k] ? `<small class="st-hint">${ST_DESC[k]}</small>` : '';
return `<label>${label}${hint}<input data-st-key="${k}" data-type="${type}" type="${type}" value="${shown.replace(/"/g,'&quot;')}"></label>`;
}).join('');
const d = r.data || {};
const list = d.channels || [];
summary.textContent = '共 ' + (d.total_channels ?? list.length) + ' 个频道 · 已启用 ' +
(d.enabled_channels ?? '—') + ' · 已停用 ' + (d.disabled_channels ?? '—');
if (!list.length) {
grid.innerHTML = '<div class="empty-box">暂无频道数据。请确认服务端已启动。</div>';
return;
}
grid.innerHTML = list.map(ch => {
const on = !!ch.enabled;
return `<div class="gs-card ${on ? '' : 'gs-off'}">
<div class="gs-card-head">
<span class="gs-card-name">${escapeHtml(ch.name || ('频道 ' + ch.id))}</span>
<span class="gm-tag ${on ? 'gm-tag-green' : 'gm-tag-gray'}">${on ? '启用' : '停用'}</span>
</div>
<div class="gs-card-meta muted">ID ${ch.id} · ${gsChannelTypeText(ch.type)}${ch.group ? ' · ' + escapeHtml(ch.group) : ''}${ch.port ? ' · 端口 ' + ch.port : ''}</div>
<div class="gs-card-actions">
<button class="shell-btn ${on ? '' : 'primary'}" data-gs-channel="${ch.id}" data-gs-enable="${!on}">${on ? '停用' : '启用'}</button>
</div>
</div>`;
}).join('');
} catch (e) {
grid.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(e && e.message ? e.message : e) + '</div>';
}
}
function tabTitle(tab) {
return ({features:'服务端参数',global:'全局游戏参数',gameplay:'游戏参数',growth:'疲劳/成长',raid:'团本配置'})[tab] || tab;
}
function tabKeys(tab) {
const all = Object.keys(stFeatures);
const match = {
features: ['raidReviveSeconds','allDeadReturnSeconds','allowFreeRevive','hellOrbChance','hellDropChanceDenominator','equipmentRollMultiplier','epicPieceRollMultiplier','lukeEnergyRecoveryPercent','fiendWarSquadCount'],
global: [],
gameplay: ['hellOrbChance','hellDropChanceDenominator','equipmentRollMultiplier','epicPieceRollMultiplier','roomFatigueCost','rentalMaxReinforce','rentalMaxForge'],
growth: ['roomFatigueCost','dailyFatigueResetHour','adventureGroupBonusPercent','rentalMaxReinforce','rentalMaxForge'],
raid: ['antonBlackFogRecoverySeconds','antonBlackFogActiveSeconds','antonQuakeRecoverySeconds','antonQuakeActiveSeconds','antonNavalOpenDelaySeconds','antonNavalRecoverySeconds','antonNavalActiveSeconds','lukeEnergyRecovery0Seconds','lukeEnergyRecovery1Seconds','lukeEnergyRecovery2Seconds','lukePhaseTimeoutPercent','fiendWarAreaLimitPercent','fiendWarDieImprisonPercent','antonMemberMax','antonStartMinimumMembers','lukeDailyAccountEnterLimit','fiendWarDailyEnterLimit'],
};
if (tab === 'global') return all.filter(k => /soloTeleport|auction|allDead|revive/i.test(k));
return (match[tab] || []).filter(k => all.includes(k));
}
function renderActivity(body) {
const keys = Object.keys(stActivity);
body.innerHTML = `<div class="st-section"><h3>在线活动开关</h3><div class="st-form">${keys.map(k => {
const v = !!stActivity[k]; const label = ({daily:'每日签到',weekly:'每周签到',monthly:'每月签到',marble:'签到弹珠',returnee:'回归活动',returneeGift:'回归赠礼',lucky:'幸运活动',levelAiming:'升级预约',laborDay:'劳动节',legacyLevelup:'旧版升级',pcRoom:'网吧时间点',spendTime:'在线时长'})[k] || k;
return `<label class="st-check"><input type="checkbox" data-act-key="${k}" ${v?'checked':''}>${label}</label>`;
}).join('')}</div></div>`;
body.querySelectorAll('input[data-act-key]').forEach(inp => {
inp.addEventListener('change', () => { stActivity[inp.dataset.actKey] = inp.checked; });
});
}
$('stReload').addEventListener('click', loadServerTool);
$('stSave').addEventListener('click', async () => {
async function gsLoadActivities() {
const list = $('gsActivityList');
const summary = $('gsActivitySummary');
if (!list) return;
list.innerHTML = '<div class="empty-box">加载中…</div>';
try {
let r;
if (stTab === 'activity') {
r = await post('/api/servertool/activity', stActivity);
} else if (stTab === 'features' && $('stRaw')) {
r = await post('/api/servertool/raw', {name: stFeatureTab, text: $('stRaw').value});
} else if (stTab === 'gameplay') {
r = await post('/api/servertool/features', stFeatures);
const g = await post('/api/servertool/gameplay', stGameplay);
const bp = await post('/api/servertool/batch-pickup', {enabled: stBatchPickup});
if (!g.ok) r = g;
if (!bp.ok) r = bp;
const r = await api('/api/game-settings/activity-switches');
if (!r.ok) {
list.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(r.error || '未知错误') + '</div>';
summary.textContent = '';
return;
}
const d = r.data || {};
const items = d.switches || [];
const onCount = items.filter(x => x.enabled).length;
summary.textContent = '共 ' + items.length + ' 个活动 · 已开启 ' + onCount;
if (!items.length) {
list.innerHTML = '<div class="empty-box">暂无活动数据。请确认服务端已启动。</div>';
return;
}
list.innerHTML = items.map(it => {
const on = !!it.enabled;
return `<div class="gs-row ${on ? '' : 'gs-off'}">
<div class="gs-row-main">
<div class="gs-row-name">${escapeHtml(it.title || it.key)}</div>
<div class="gs-row-desc muted">${escapeHtml(it.description || '')}</div>
</div>
<button class="gs-switch ${on ? 'on' : ''}" data-gs-activity="${escapeHtml(it.key)}" data-gs-enable="${!on}" role="switch" aria-checked="${on}">
<span class="gs-knob"></span>
</button>
</div>`;
}).join('');
} catch (e) {
list.innerHTML = '<div class="empty-box">获取失败:' + escapeHtml(e && e.message ? e.message : e) + '</div>';
}
}
async function loadGameSettings() {
gsSwitchTab(gsCurrentTab);
if (gsCurrentTab === 'channels') await gsLoadChannels();
else await gsLoadActivities();
}
// 刷新按钮
$('gsRefresh').addEventListener('click', () => {
if (gsCurrentTab === 'channels') gsLoadChannels();
else gsLoadActivities();
});
// Tab 切换
document.querySelectorAll('#gsTabs .st-tab').forEach(el => {
el.addEventListener('click', () => {
gsSwitchTab(el.dataset.gs);
if (gsCurrentTab === 'channels') gsLoadChannels();
else gsLoadActivities();
});
});
// 频道开关点击(事件委托)
$('gsChannelGrid').addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-gs-channel]');
if (!btn) return;
const id = Number(btn.dataset.gsChannel);
const enable = btn.dataset.gsEnable === 'true';
btn.disabled = true;
btn.textContent = '处理中…';
try {
const r = await post('/api/game-settings/channel-switch', { channel_id: id, enabled: enable });
if (r.ok) {
gsToast(enable ? '频道已启用' : '频道已停用', 'success');
await gsLoadChannels();
} else {
r = await post('/api/servertool/features', stFeatures);
gsToast('操作失败:' + (r.error || '未知错误'), 'error');
btn.disabled = false;
btn.textContent = enable ? '启用' : '停用';
}
$('stStatus').textContent = r.ok ? ('已保存' + (r.warning ? '(' + r.warning + ')' : '')) : (r.error || '保存失败');
} catch (e) { $('stStatus').textContent = '保存失败:' + e.message; }
} catch (e) {
gsToast('操作失败:' + (e && e.message ? e.message : e), 'error');
btn.disabled = false;
btn.textContent = enable ? '启用' : '停用';
}
});
async function loadSettings() {
// 活动开关点击(事件委托)
$('gsActivityList').addEventListener('click', async (e) => {
const sw = e.target.closest('button[data-gs-activity]');
if (!sw) return;
const key = sw.dataset.gsActivity;
const enable = sw.dataset.gsEnable === 'true';
sw.disabled = true;
try {
const s = await api('/api/status');
$('setServerRoot').textContent = s.server_root || '未绑定';
$('setControl').textContent = s.control_exists ? '已找到 DNF90Control.exe' : '缺失';
$('setDb').textContent = s.db || '未找到';
$('setClient').textContent = s.client_dir || '未找到';
$('setRun').textContent = s.server_running ? '运行中' : '未运行';
$('setNative').textContent = s.native_available ? '可用' : '不可用';
} catch (e) { /* ignore */ }
}
$('setRefresh').addEventListener('click', loadSettings);
const r = await post('/api/game-settings/activity-switch', { key: key, enabled: enable });
if (r.ok) {
gsToast(enable ? '活动已开启' : '活动已关闭', 'success');
await gsLoadActivities();
} else {
gsToast('操作失败:' + (r.error || '未知错误'), 'error');
sw.disabled = false;
}
} catch (e) {
gsToast('操作失败:' + (e && e.message ? e.message : e), 'error');
sw.disabled = false;
}
});
// 顶栏拖动窗口(JS move 兜底,兼容 pywebview EdgeChromium)
// 同时支持第一行标题栏(.shell-topnav)与第二行面包屑栏(.shell-topbar)
+404
View File
@@ -0,0 +1,404 @@
# 支援兵页面模板(Companion Page Template)
> 从 DNF90 启动器 v2.1.0 中完整导出的「支援兵」页面模板。
> 导出日期:2026-11-04。
> 用途:日后如需恢复「支援兵」功能,按本文档把 HTML / JS / CSS / 后端接口 四个部分分别粘贴回对应文件即可。
---
## 一、HTML(index.html 中的 section)
插入位置:`<section class="shell-page" id="page-changelog">` 之前(或任意 shell-page 之间)。
```html
<section class="shell-page" id="page-companion">
<div class="page-pad">
<div class="page-heading"><div><h2>支援兵</h2><p class="muted">按账号配置支援兵;配置只影响下一次进入副本。</p></div><button class="shell-btn primary" id="cpSave">保存配置</button></div>
<div class="cp-toolbar">
<label>账号<select id="cpAccount" class="shell-input"></select></label>
<label>出战角色<select id="cpOwner" class="shell-input"></select></label>
<button class="shell-btn" id="cpReload">刷新</button>
<span id="cpStatus" class="muted"></span>
</div>
<div class="cp-flags">
<label class="st-check"><input type="checkbox" id="cpEnabled">启用支援兵</label>
<label class="st-check"><input type="checkbox" id="cpBalance">读取佩戴装备</label>
<label class="st-check"><input type="checkbox" id="cpProgression">启用成长推进</label>
<label class="st-check"><input type="checkbox" id="cpRandom">随机武器装备</label>
<label class="st-check"><input type="checkbox" id="cpBasicAttack">允许支援兵普攻</label>
</div>
<h3 class="cp-title">选择支援兵角色</h3>
<div class="cp-grid" id="cpGrid"><div class="empty-box">选择出战角色后加载支援兵列表</div></div>
<div class="cp-ai">
<div class="cp-ai-head"><h3>AI 策略</h3><select id="cpAISource" class="shell-input"></select></div>
<div class="st-form">
<label>行为模式<select id="cpAIBehavior"><option value="assist">辅助</option><option value="aggressive">好战</option></select></label>
<label>BUFF 优先级<select id="cpAIBuff"><option value="leader_first">优先队长</option><option value="assist_only">仅辅助</option><option value="off">关闭</option></select></label>
<label>职业连招<select id="cpAIComboPreset"><option value="auto">按职业自动</option><option value="sword_guard">鬼剑士:控制连招</option><option value="fighter_chain">格斗家:近身连招</option><option value="gunner_burst">神枪手:远程爆发</option><option value="mage_control">魔法师:范围控制</option><option value="priest_support">圣职者:辅助连招</option></select></label>
<label>范围技能最少目标<input id="cpAIAoe" type="number" min="2" max="6" step="1"></label>
<label class="st-check"><input type="checkbox" id="cpAIComboEnabled">启用连招</label>
<label class="st-check"><input type="checkbox" id="cpAIProtect">保护队长</label>
</div>
<details class="st-json">
<summary>高级调参:距离、追击、视野、重试和治疗阈值</summary>
<div class="st-form" id="cpAITuning">
<label>思考间隔(ms)<input data-cp-tuning="think_interval_ms" type="number" min="50" max="2000"></label>
<label>辅助攻击间隔(ms)<input data-cp-tuning="assist_attack_interval_ms" type="number" min="200" max="10000"></label>
<label>辅助横向范围<input data-cp-tuning="assist_leash_x" type="number" min="30" max="500"></label>
<label>辅助纵向范围<input data-cp-tuning="assist_leash_y" type="number" min="20" max="300"></label>
<label>追击范围<input data-cp-tuning="chase_range" type="number" min="20" max="1000"></label>
<label>视野范围<input data-cp-tuning="vision_range" type="number" min="100" max="20000"></label>
<label>目标黏性(ms)<input data-cp-tuning="target_stickiness_ms" type="number" min="0" max="10000"></label>
<label>队长治疗阈值(%)<input data-cp-tuning="heal_leader_percent" type="number" min="0" max="100"></label>
<label>自身治疗阈值(%)<input data-cp-tuning="heal_self_percent" type="number" min="0" max="100"></label>
<label>重试间隔(ms)<input data-cp-tuning="retry_ms" type="number" min="50" max="5000"></label>
<label>辅助重试(ms)<input data-cp-tuning="support_retry_ms" type="number" min="100" max="10000"></label>
<label>忙碌移动(ms)<input data-cp-tuning="busy_move_ms" type="number" min="100" max="10000"></label>
<label>卡住判定(ms)<input data-cp-tuning="stuck_ms" type="number" min="100" max="5000"></label>
<label>绕行等待(ms)<input data-cp-tuning="detour_ms" type="number" min="100" max="5000"></label>
<label>放弃目标(ms)<input data-cp-tuning="give_up_ms" type="number" min="500" max="20000"></label>
<label>跟随距离<input data-cp-tuning="follow_distance" type="number" min="20" max="300"></label>
</div>
</details>
<div class="st-note">AI 策略按角色保存;保存后下一次进入副本生效。</div>
</div>
<div class="st-note" id="cpHint">支援兵按账号隔离,只能选择同一账号下的其他角色。</div>
</div>
</section>
```
---
## 二、侧边栏导航入口(index.html 的 nav 内)
```html
<button class="shell-nav-item" data-shell-page="companion"><span class="nav-ico">👥</span>支援兵</button>
```
---
## 三、JavaScript(shell.js 中的支援兵模块)
插入位置:`// ---------------- 一键换装 ----------------` 区块之后、`// ---------------- 服务端工具 ----------------` 区块之前。
```javascript
// ---------------- 支援兵 ----------------
let cpAccounts = [], cpCharacters = [], cpState = null, cpSelected = new Set();
let cpAIProfiles = {}, cpAISource = '';
let cpLoading = false;
const CP_AI_DEFAULTS = {
behavior:'aggressive', buff_policy:'leader_first', combo_preset:'auto',
combo_enabled:true, aoe_min_targets:3, protect_leader:true,
tuning:{think_interval_ms:120,assist_attack_interval_ms:1800,assist_leash_x:180,assist_leash_y:90,
chase_range:50,vision_range:10000,target_stickiness_ms:1200,heal_leader_percent:85,
heal_self_percent:85,retry_ms:300,support_retry_ms:1500,busy_move_ms:2500,stuck_ms:700,
detour_ms:900,give_up_ms:3500,follow_distance:75}
};
async function loadCompanion() {
if (cpLoading) return;
cpLoading = true;
$('cpStatus').textContent = '读取账号和角色…';
try {
const a = await api('/api/accounts');
const ba = await api('/api/basic-attack');
$('cpBasicAttack').checked = !ba || ba.enabled !== false;
cpAccounts = (a && a.data) || [];
const sel = $('cpAccount');
sel.innerHTML = cpAccounts.map(x => `<option value="${x.account_id || x.username || ''}">${x.username || x.account_id}</option>`).join('');
if (!cpAccounts.length) { $('cpStatus').textContent = '没有账号'; return; }
await loadCompanionCharacters();
$('cpStatus').textContent = '';
} catch (e) {
$('cpStatus').textContent = '读取失败:' + e.message;
} finally { cpLoading = false; }
}
async function loadCompanionCharacters() {
const account = $('cpAccount').value;
const r = await api('/api/characters?account=' + encodeURIComponent(account));
cpCharacters = (r && r.data) || [];
const owner = $('cpOwner');
owner.innerHTML = cpCharacters.map(c => `<option value="${c.character_id}">${c.name} · Lv${c.level}</option>`).join('');
if (cpCharacters.length) await loadCompanionState();
else { $('cpGrid').innerHTML = '<div class="empty-box">当前账号没有角色</div>'; }
}
async function loadCompanionState() {
const owner = $('cpOwner').value;
if (!owner) return;
$('cpStatus').textContent = '读取支援兵配置…';
try {
const r = await api('/api/companion?character=' + encodeURIComponent(owner));
if (!r.ok) { $('cpStatus').textContent = r.error || '读取失败'; return; }
cpState = r.data || {};
const cfg = (cpState.state && cpState.state.config) || {};
$('cpEnabled').checked = !!cfg.enabled;
$('cpBalance').checked = !!cfg.balance_enabled;
$('cpProgression').checked = !!cfg.progression_enabled;
$('cpRandom').checked = !!cfg.random_equipment_enabled;
cpSelected = new Set((cfg.source_character_ids || []).map(String));
cpAIProfiles = JSON.parse(JSON.stringify(cfg.ai_profiles || {}));
cpAISource = [...cpSelected][0] || '';
renderCompanionGrid();
$('cpStatus').textContent = (cpState.state && cpState.state.preflight && cpState.state.preflight.reason_message) || '已读取';
} catch (e) { $('cpStatus').textContent = '读取失败:' + e.message; }
}
function renderCompanionGrid() {
const owner = String($('cpOwner').value || '');
const list = cpCharacters.filter(c => String(c.character_id) !== owner);
const grid = $('cpGrid');
if (!list.length) { grid.innerHTML = '<div class="empty-box">当前账号没有其他角色可作为支援兵</div>'; renderCpAI(); return; }
grid.innerHTML = list.map(c => {
const id = String(c.character_id), selected = cpSelected.has(id);
return `<label class="cp-card ${selected?'selected':''}">
<input type="checkbox" data-cp-source="${id}" ${selected?'checked':''}>
<b>${c.name || id}</b><small>Lv${c.level ?? '-'} · 职业 ${c.job ?? '-'}</small>
</label>`;
}).join('');
grid.querySelectorAll('input[data-cp-source]').forEach(inp => {
inp.onchange = () => {
const id = inp.dataset.cpSource;
if (inp.checked) cpSelected.add(id); else cpSelected.delete(id);
inp.closest('.cp-card').classList.toggle('selected', inp.checked);
renderCpAI();
};
});
renderCpAI();
}
function cpProfile(id) {
const raw = cpAIProfiles[String(id)] || {};
return {
...CP_AI_DEFAULTS, ...raw,
tuning: {...CP_AI_DEFAULTS.tuning, ...(raw.tuning || {})}
};
}
function renderCpAI() {
const select = $('cpAISource');
const ids = [...cpSelected];
if (!ids.includes(cpAISource)) cpAISource = ids[0] || '';
select.innerHTML = ids.map(id => {
const c = cpCharacters.find(x => String(x.character_id) === id);
return `<option value="${id}">${c ? c.name : id}</option>`;
}).join('');
if (cpAISource) select.value = cpAISource;
if (!cpAISource) {
$('cpAIBehavior').value = 'assist'; $('cpAIBuff').value = 'leader_first';
$('cpAIComboPreset').value = 'auto'; $('cpAIAoe').value = 3;
$('cpAIComboEnabled').checked = true; $('cpAIProtect').checked = true;
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
inp.value = CP_AI_DEFAULTS.tuning[inp.dataset.cpTuning] ?? 0;
});
return;
}
const p = cpProfile(cpAISource);
$('cpAIBehavior').value = ['assist','aggressive'].includes(p.behavior) ? p.behavior : 'aggressive';
$('cpAIBuff').value = p.buff_policy || 'leader_first';
$('cpAIComboPreset').value = p.combo_preset || 'auto';
$('cpAIAoe').value = Number(p.aoe_min_targets) || 3;
$('cpAIComboEnabled').checked = !!p.combo_enabled;
$('cpAIProtect').checked = !!p.protect_leader;
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
inp.value = p.tuning[inp.dataset.cpTuning] ?? 0;
});
}
function readCpAI() {
if (!cpAISource) return;
const p = cpProfile(cpAISource);
p.behavior = $('cpAIBehavior').value;
p.buff_policy = $('cpAIBuff').value;
p.combo_preset = $('cpAIComboPreset').value;
p.combo_enabled = $('cpAIComboEnabled').checked;
p.aoe_min_targets = Math.max(2, Math.min(6, Number($('cpAIAoe').value) || 3));
p.protect_leader = $('cpAIProtect').checked;
p.tuning = {...p.tuning};
document.querySelectorAll('[data-cp-tuning]').forEach(inp => {
const n = Number(inp.value);
if (Number.isFinite(n)) p.tuning[inp.dataset.cpTuning] = n;
});
cpAIProfiles[String(cpAISource)] = p;
}
$('cpAccount').addEventListener('change', async () => {
try { await loadCompanionCharacters(); } catch (e) { $('cpStatus').textContent = e.message; }
});
$('cpOwner').addEventListener('change', loadCompanionState);
$('cpReload').addEventListener('click', loadCompanion);
$('cpAISource').addEventListener('change', () => { cpAISource = $('cpAISource').value; renderCpAI(); });
['cpAIBehavior','cpAIBuff','cpAIComboPreset','cpAIAoe','cpAIComboEnabled','cpAIProtect'].forEach(id => {
$(id).addEventListener('change', readCpAI);
});
document.querySelectorAll('[data-cp-tuning]').forEach(inp => inp.addEventListener('change', readCpAI));
$('cpBasicAttack').addEventListener('change', async () => {
try {
const r = await post('/api/basic-attack', {enabled: $('cpBasicAttack').checked});
if (!r.ok) $('cpStatus').textContent = r.error || '普攻开关保存失败';
else $('cpStatus').textContent = $('cpBasicAttack').checked
? '已允许支援兵普攻;重启客户端后生效。'
: '已关闭支援兵普攻,技能释放不受影响;重启客户端后生效。';
} catch (e) { $('cpStatus').textContent = '普攻开关保存失败:' + e.message; }
});
$('cpSave').addEventListener('click', async () => {
const owner = $('cpOwner').value;
if (!owner) { $('cpStatus').textContent = '请先选择出战角色'; return; }
if ($('cpEnabled').checked && cpSelected.size === 0) {
$('cpStatus').textContent = '启用支援兵时至少选择一个支援兵角色'; return;
}
const state = (cpState && cpState.state) || {};
const cfg = state.config || {};
const sources = [...cpSelected];
readCpAI();
const payload = {
characterId: String(owner),
expectedRevision: Number(cfg.revision || 0),
enabled: $('cpEnabled').checked,
balanceEnabled: $('cpBalance').checked,
progressionEnabled: $('cpProgression').checked,
randomEquipmentEnabled: $('cpRandom').checked,
sourceCharacterId: sources[0] || '',
sourceCharacterIds: sources,
ai: cfg.ai || {},
aiProfiles: cpAIProfiles
};
$('cpStatus').textContent = '保存中…';
try {
const r = await post('/api/companion', payload);
if (!r.ok) { $('cpStatus').textContent = r.error || '保存失败'; return; }
cpState = r.data || cpState;
$('cpStatus').textContent = '支援兵配置已保存,下一次进入副本生效。';
} catch (e) { $('cpStatus').textContent = '保存失败:' + e.message; }
});
```
同时,需要在 `shellGo` 函数中加回页面切换调用:
```javascript
if (page === 'companion') loadCompanion();
```
以及在 `pages` 数组加回 `'companion'`,在 `crumb` 加回 `companion:'工具 / 支援兵'`。
---
## 四、后端接口(server.py)
### 4.1 GET 路由注册(do_GET 内)
```python
elif path == "/api/companion":
self._api_companion(qs)
elif path == "/api/basic-attack":
self._json({"ok": True, "enabled": not os.path.exists(
os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled"))})
```
### 4.2 POST 路由注册(do_POST 内)
```python
elif path == "/api/companion":
self._api_save_companion(data)
elif path == "/api/basic-attack":
enabled = bool(data.get("enabled", True))
flag = os.path.join(CLIENT_DIR, "90CN_basic_attack.disabled")
try:
if enabled:
if os.path.exists(flag):
os.remove(flag)
else:
os.makedirs(CLIENT_DIR, exist_ok=True)
with open(flag, "w", encoding="utf-8") as f:
f.write("disabled\n")
self._json({"ok": True, "enabled": enabled})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 500)
```
### 4.3 处理函数
```python
# ==================== 支援兵 ====================
def _api_companion(self, qs):
if NATIVE is None or not server_running():
self._json({"ok": False, "error": "服务端未运行,支援兵配置不可用"}, 400)
return
character = (qs.get("character") or qs.get("id") or [""])[0].strip()
if not character:
self._json({"ok": False, "error": "缺少角色 ID"}, 400)
return
try:
self._json({"ok": True, "data": NATIVE.companion_state(character)})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
def _api_save_companion(self, data):
if NATIVE is None or not server_running():
self._json({"ok": False, "error": "服务端未运行,无法保存支援兵配置"}, 400)
return
try:
self._json({"ok": True, "data": NATIVE.companion_save(data)})
except Exception as e:
self._json({"ok": False, "error": str(e)}, 502)
```
---
## 五、CSS 样式(shell.css 中的 cp- 相关)
支援兵页面复用了 `.st-form`、`.st-check`、`.st-json`、`.st-note` 等「服务端工具」样式(这些在服务端工具删除后需要一并保留或从模板恢复),以及以下独立的 cp- 样式(位于 shell.css 明暗主题区域):
```css
.cp-toolbar{display:flex;align-items:flex-end;gap:14px;flex-wrap:wrap;margin-bottom:16px}
.cp-toolbar label{display:flex;flex-direction:column;gap:5px;font-size:13px;color:#9a9eb5}
.cp-toolbar select{min-width:220px}
.cp-flags{display:flex;flex-wrap:wrap;gap:28px;border:1px solid rgba(255,255,255,.1);border-radius:10px}
.cp-title{font-size:15px;font-weight:400;color:#c9beff;margin:0 0 10px}
.cp-grid{display:grid;grid-template-columns:repeat(3,minmax(220px,1fr));gap:12px}
.cp-card{border:1px solid rgba(255,255,255,.12);border-radius:10px;padding:12px;background:rgba(20,20,36,.55);cursor:pointer}
.cp-card.selected{border-color:rgba(124,92,255,.7);background:rgba(124,92,255,.16)}
.cp-card b{font-size:14px;display:block;color:#eef0fb}
.cp-card small{font-size:12px;color:#9a9eb5}
.cp-card input{margin-right:7px}
.cp-ai{margin-top:20px;border:1px solid rgba(255,255,255,.1);border-radius:10px;background:rgba(20,20,36,.45);padding:14px}
.cp-ai-head{display:flex;align-items:center;gap:14px;margin-bottom:12px}
.cp-ai-head h3{margin:0;font-size:15px;font-weight:400;color:#c9beff}
.cp-ai-head select{width:240px}
.cp-ai .st-form{margin-bottom:10px}
```
> 注意:`cp-flags` 样式在原文件里是分成两行(续行),恢复时需合并为完整声明。
---
## 六、依赖说明
支援兵页面依赖以下「服务端工具」的通用样式(这些如果被一并删除,恢复支援兵时需一并恢复):
- `.st-form` / `.st-form label` / `.st-form input` / `.st-form select`
- `.st-check` / `.st-check input`
- `.st-json` / `.st-json summary` / `.st-json textarea`
- `.st-note`
依赖的后端接口:
- `/api/accounts`(账号列表)
- `/api/characters?account=`(角色列表)
- `/api/companion`(支援兵状态读取/保存)
- `/api/basic-attack`(普攻开关)
---
## 七、恢复步骤速查
1. `index.html`:粘贴 section + 侧边栏按钮
2. `shell.js`:粘贴 JS 模块,并在 `pages`/`crumb`/`shellGo` 三处加回 `companion`
3. `server.py`:粘贴路由注册 + 两个处理函数
4. `shell.css`:粘贴 cp- 样式(若 st- 样式已删,一并恢复 st- 通用样式)
5. 重新打包
---
(完)
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+1
View File
@@ -9,6 +9,7 @@
<AssemblyName>DNFLoadout</AssemblyName>
<RootNamespace>DNFLoadout</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\loadout.ico</ApplicationIcon>
</PropertyGroup>
</Project>
+277
View File
@@ -0,0 +1,277 @@
// Command companionoverlay — 支援兵覆盖工具(单文件版)。
//
// 把「支援兵 AIC 覆盖」做成一个可独立发布的可执行程序:内置 companionpvf 与
// profiles.json,用户只需拖入 Script.pvf(或直接双击用默认路径),即可:
//
// 1) 生成覆盖版 PVF(默认输出到同目录 Script.companion.pvf);
// 2) 可选地安装到客户端/服务端目录(自动备份、打印哈希)。
//
// 用法:
//
// 支援兵覆盖工具.exe 交互模式(使用默认客户端路径)
// 支援兵覆盖工具.exe <Script.pvf> 拖拽/命令行:只生成覆盖版
// 支援兵覆盖工具.exe <Script.pvf> --client <客户端目录> --server <服务端数据目录>
package main
import (
"bufio"
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
//go:embed companionpvf.exe profiles.json
var assets embed.FS
const (
appTitle = "DNF90 支援兵覆盖工具"
appVersion = "1.0.0"
defaultClient = `D:\90server\Client\Script.pvf`
minOutputBytes = 50 << 20
)
func main() {
fmt.Printf("%s v%s\n", appTitle, appVersion)
fmt.Println(strings.Repeat("=", 60))
args := os.Args[1:]
pvfPath, clientDir, serverDir, install, interactive := parseArgs(args)
if interactive {
pvfPath = prompt("要处理的 Script.pvf 路径", pvfPath)
if !ask("是否同时安装到客户端/服务端目录?(y/N): ") {
install = false
} else {
install = true
clientDir = prompt("客户端目录(含 Script.pvf)", clientDir)
serverDir = prompt("服务端数据目录(含 Script.pvf)", serverDir)
}
}
if !install {
clientDir, serverDir = "", ""
}
if strings.TrimSpace(pvfPath) == "" {
fail("没有指定 Script.pvf")
}
source, err := filepath.Abs(pvfPath)
if err != nil || !exists(source) {
fail("找不到 PVF:" + pvfPath)
}
outPath := strings.TrimSuffix(source, filepath.Ext(source)) + ".companion.pvf"
if dir := filepath.Dir(source); dir != "" {
outPath = filepath.Join(dir, "Script.companion.pvf")
}
fmt.Printf("输入 PVF:%s\n", source)
fmt.Printf("输出 PVF:%s\n", outPath)
if err := runOverlay(source, outPath); err != nil {
fail(err.Error())
}
size := fileSize(outPath)
fmt.Printf("覆盖版已生成:%d 字节(%.1f MB)\n", size, float64(size)/(1<<20))
if size < minOutputBytes {
fail("输出文件过小,疑似失败(原 PVF 未被修改)")
}
hash := fileHash(outPath)
fmt.Printf("覆盖版 SHA256:%s\n", hash)
if install {
if err := installTo(source, outPath, hash, clientDir, serverDir); err != nil {
fail(err.Error())
}
} else {
fmt.Println()
fmt.Println("未安装。手动替换方法:")
fmt.Println(" 客户端:把输出文件改名为 Script.pvf 覆盖到 客户端目录。")
fmt.Println(" 服务端:同样覆盖到 服务端 runtime\\data\\dnf\\Script.pvf。")
fmt.Println(" 完成后重启服务端,再启动游戏。")
}
fmt.Println()
fmt.Println("完成。")
if interactive {
fmt.Print("按回车退出...")
bufio.NewReader(os.Stdin).ReadString('\n')
}
}
func parseArgs(args []string) (pvf, clientDir, serverDir string, install, interactive bool) {
clientDir = `D:\90server\Client`
serverDir = `D:\90server\Server\runtime\data\dnf`
pvf = defaultClient
interactive = len(args) == 0
for i := 0; i < len(args); i++ {
switch args[i] {
case "--client":
if i+1 < len(args) {
clientDir = args[i+1]
install = true
i++
}
case "--server":
if i+1 < len(args) {
serverDir = args[i+1]
install = true
i++
}
case "--install":
install = true
default:
if !strings.HasPrefix(args[i], "--") {
pvf = args[i]
}
}
}
return pvf, clientDir, serverDir, install, interactive
}
func runOverlay(source, outPath string) error {
toolPath := filepath.Join(os.TempDir(), fmt.Sprintf("companionpvf-%d.exe", time.Now().UnixNano()))
toolBytes, err := assets.ReadFile("companionpvf.exe")
if err != nil {
return fmt.Errorf("读取内置工具失败:%w", err)
}
if err := os.WriteFile(toolPath, toolBytes, 0o700); err != nil {
return fmt.Errorf("释放内置工具失败:%w", err)
}
defer os.Remove(toolPath)
profilesPath := filepath.Join(os.TempDir(), fmt.Sprintf("companion-profiles-%d.json", time.Now().UnixNano()))
profilesBytes, err := assets.ReadFile("profiles.json")
if err != nil {
return fmt.Errorf("读取内置配置失败:%w", err)
}
if err := os.WriteFile(profilesPath, profilesBytes, 0o600); err != nil {
return fmt.Errorf("释放内置配置失败:%w", err)
}
defer os.Remove(profilesPath)
fmt.Println("生成覆盖版(约需 10–60 秒)...")
cmd := exec.Command(toolPath, "--profile-overlay", source, outPath, profilesPath)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("覆盖失败:%w(原 PVF 未被修改)", err)
}
return nil
}
func installTo(source, outPath, hash, clientDir, serverDir string) error {
if hasProcess("DNF") {
return fmt.Errorf("请先关闭 DNF 客户端:PVF 正被占用")
}
targets := []string{
filepath.Join(clientDir, "Script.pvf"),
filepath.Join(serverDir, "Script.pvf"),
}
stamp := time.Now().Format("20060102-150405")
for _, target := range targets {
if !exists(target) {
fmt.Printf("跳过(不存在):%s\n", target)
continue
}
if fileHash(target) == hash {
fmt.Printf("已是最新,跳过:%s\n", target)
continue
}
backup := target + ".bak_before_companion_" + stamp
if err := copyFile(target, backup); err != nil {
return fmt.Errorf("备份失败:%w", err)
}
fmt.Printf("已备份:%s\n", backup)
if err := copyFile(outPath, target); err != nil {
return fmt.Errorf("安装失败:%w", err)
}
fmt.Printf("已安装:%s\n", target)
}
fmt.Println("请重启 DNF90 服务端(服务端启动时加载 PVF),再启动游戏。")
return nil
}
func prompt(label, fallback string) string {
fmt.Printf("%s [%s]: ", label, fallback)
reader := bufio.NewReader(os.Stdin)
text, _ := reader.ReadString('\n')
text = strings.TrimSpace(text)
if text == "" {
return fallback
}
return strings.Trim(text, `"`)
}
func ask(label string) bool {
fmt.Print(label)
reader := bufio.NewReader(os.Stdin)
text, _ := reader.ReadString('\n')
text = strings.ToLower(strings.TrimSpace(text))
return text == "y" || text == "yes"
}
func exists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func fileSize(path string) int64 {
info, err := os.Stat(path)
if err != nil {
return 0
}
return info.Size()
}
func fileHash(path string) string {
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return ""
}
return strings.ToUpper(hex.EncodeToString(h.Sum(nil)))
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
func hasProcess(name string) bool {
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq "+name+".exe", "/NH")
output, err := cmd.Output()
if err != nil {
return false
}
return strings.Contains(strings.ToLower(string(output)), strings.ToLower(name+".exe"))
}
func fail(message string) {
fmt.Fprintf(os.Stderr, "\n[错误] %s\n", message)
fmt.Print("按回车退出...")
bufio.NewReader(os.Stdin).ReadString('\n')
os.Exit(1)
}
@@ -0,0 +1,126 @@
package dnf90
import (
"encoding/json"
"io"
"net/http"
"strings"
appkit "longheng.io/server/internal/platform/app"
httpx "longheng.io/server/internal/platform/httpx"
"longheng.io/server/internal/services/dnfbridge"
)
// companionPanelAdminPath is the local-only endpoint used by the GM tool's
// "支援兵属性" page. It is intentionally unauthenticated: the admin mux binds
// to loopback, like the existing /local endpoints, and the page is served by
// the local GM tool.
const companionPanelAdminPath = "/local/companion-panel"
func registerCompanionPanelAdmin(env *appkit.Env, bridge *dnfbridge.Service) {
if env == nil || env.AdminMux == nil || bridge == nil {
return
}
env.AdminMux.HandleFunc(companionPanelAdminPath, func(w http.ResponseWriter, r *http.Request) {
applyCompanionPanelCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
switch r.Method {
case http.MethodGet:
characterID := strings.TrimSpace(r.URL.Query().Get("character_id"))
if characterID == "" {
characterID = strings.TrimSpace(r.URL.Query().Get("characterId"))
}
values, found, err := bridge.CompanionPanelValues(r.Context(), characterID)
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"character_id": characterID,
"found": found,
"values": values,
})
case http.MethodPost:
var body struct {
CharacterID string `json:"character_id"`
Values map[string]string `json:"values"`
}
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
if err := decoder.Decode(&body); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
if strings.TrimSpace(body.CharacterID) == "" {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "character_id is required",
})
return
}
if err := bridge.SaveCompanionPanelValues(r.Context(), body.CharacterID, body.Values); err != nil {
httpx.WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
values, found, _ := bridge.CompanionPanelValues(r.Context(), body.CharacterID)
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"character_id": body.CharacterID,
"found": found,
"values": values,
})
default:
w.Header().Set("Allow", "GET, POST, OPTIONS")
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{
"error": "method not allowed",
})
}
})
// Base-estimate generation: PVF growth + worn equipment + weapon forging.
// Enchant cards, amplification, reinforcement, quality, sets, titles, auras
// and pets are intentionally not modelled; the GM page marks the result as
// an estimate and lets the operator correct it.
env.AdminMux.HandleFunc(companionPanelGeneratePath, func(w http.ResponseWriter, r *http.Request) {
applyCompanionPanelCORS(w)
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method != http.MethodPost {
w.Header().Set("Allow", "POST, OPTIONS")
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{
"error": "method not allowed",
})
return
}
var body struct {
CharacterID string `json:"character_id"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
values, err := bridge.GenerateCompanionPanelSnapshot(r.Context(), body.CharacterID)
if err != nil {
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"ok": true,
"character_id": body.CharacterID,
"estimate": true,
"values": values,
})
})
}
const companionPanelGeneratePath = "/local/companion-panel/generate"
func applyCompanionPanelCORS(w http.ResponseWriter) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Admin-Token")
w.Header().Set("Access-Control-Allow-Private-Network", "true")
}
@@ -52,6 +52,10 @@ const (
// gmChannelReloadOperation 频道热重载:重读 channel_info.etc 并热替换频道表 +
// 对账游戏端口监听,不需要重启进程。
gmChannelReloadOperation = "POST /dnf/gm/channels/reload"
// gmActivitySwitchesOperation 活动开关设置:切换某活动是否下发。
gmActivitySwitchesOperation = "POST /dnf/gm/activity-switches"
// gmChannelSwitchOperation 频道开关设置:切换某频道是否启用。
gmChannelSwitchOperation = "POST /dnf/gm/channel-switch"
// gmRecordQueryOperation 记录查询。只读操作,但沿用统一的操作串便于审计日志对齐。
gmRecordQueryOperation = "POST /dnf/gm/records/query"
)
@@ -125,6 +129,58 @@ func registerGMAdminRoutes(env *appkit.Env, service *dnfbridge.Service) {
}
writeGMData(w, http.StatusOK, result)
}))
// 活动开关:读取当前各活动的开关状态(供启动器「游戏设置 → 活动开关」展示)。
env.AdminMux.HandleFunc("GET /dnf/gm/activity-switches", read(func(w http.ResponseWriter, r *http.Request) {
switches, err := service.GMActivitySwitches(r.Context())
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, switches)
}))
// 活动开关:切换某活动的开/关。
env.AdminMux.HandleFunc("POST /dnf/gm/activity-switches", write(gmActivitySwitchesOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
Key string `json:"key"`
Enabled bool `json:"enabled"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
switches, err := service.GMSetActivitySwitch(r.Context(), request.Key, request.Enabled)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, switches)
}))
// 频道开关:读取当前各频道的启用状态(含被手动关闭的频道)。
env.AdminMux.HandleFunc("GET /dnf/gm/channel-switches", read(func(w http.ResponseWriter, r *http.Request) {
switches, err := service.GMChannelSwitches(r.Context())
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, switches)
}))
// 频道开关:切换某频道的启用/停用(改运行时目录并热对账端口,无需重启)。
env.AdminMux.HandleFunc("POST /dnf/gm/channel-switch", write(gmChannelSwitchOperation, func(w http.ResponseWriter, r *http.Request) {
var request struct {
ChannelID int `json:"channel_id"`
Enabled bool `json:"enabled"`
}
if err := decodeGMJSON(w, r, &request); err != nil {
writeGMToolError(w, err)
return
}
switches, err := service.GMSetChannelSwitch(r.Context(), request.ChannelID, request.Enabled)
if err != nil {
writeGMToolError(w, err)
return
}
writeGMData(w, http.StatusOK, switches)
}))
// 记录查询(点券消费/对话/副本)。三类记录都落在服务端的 JSONL 文件里,
// 这里只做检索,不改任何运行态,所以走 read()。
env.AdminMux.HandleFunc("GET /dnf/gm/records/meta", read(func(w http.ResponseWriter, r *http.Request) {
@@ -34,6 +34,9 @@ type Options struct {
GamePortBase int
MaxUsers int
CurrentUsers int
// DisabledIDs 是运行时被关闭的频道 ID 集合(来自启动器「频道开关」)。
// 这些频道不进入目录,也不会开游戏端口监听。
DisabledIDs map[int]bool
}
// Catalog 是从 channel_info.etc 派生的只读频道目录。
@@ -56,6 +59,9 @@ func New(index *channelinfo.Index, options Options) (*Catalog, error) {
if item.ID <= 0 {
continue
}
if options.DisabledIDs != nil && options.DisabledIDs[item.ID] {
continue
}
if _, ok := seen[item.ID]; ok {
continue
}
@@ -17,7 +17,13 @@ import (
// 严格版:源表缺少引导频道时直接失败。只开少数频道(甚至只开特殊区域频道)的运维配置
// 请走 Build90CNOnlineScriptAllowingMissingBootstrap。
func Build90CNOnlineScript(data []byte, sourceServerID, advertiseServerID, bootstrapChannelID int) ([]byte, error) {
script, bootstrapFound, err := build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID)
return Build90CNOnlineScriptFiltered(data, sourceServerID, advertiseServerID, bootstrapChannelID, nil)
}
// Build90CNOnlineScriptFiltered 是 Build90CNOnlineScript 的带禁用频道过滤版。
// disabledIDs 里的频道不会进入下发给客户端的频道表(与 channelcatalog 的过滤保持一致)。
func Build90CNOnlineScriptFiltered(data []byte, sourceServerID, advertiseServerID, bootstrapChannelID int, disabledIDs map[int]bool) ([]byte, error) {
script, bootstrapFound, err := build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID, disabledIDs)
if err != nil {
return nil, err
}
@@ -45,12 +51,22 @@ func Build90CNOnlineScriptAllowingMissingBootstrap(
data []byte,
sourceServerID, advertiseServerID, bootstrapChannelID int,
) ([]byte, bool, error) {
return build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID)
return build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID, nil)
}
// Build90CNOnlineScriptAllowingMissingBootstrapFiltered 是带禁用频道过滤的宽松版。
func Build90CNOnlineScriptAllowingMissingBootstrapFiltered(
data []byte,
sourceServerID, advertiseServerID, bootstrapChannelID int,
disabledIDs map[int]bool,
) ([]byte, bool, error) {
return build90CNOnlineScript(data, sourceServerID, advertiseServerID, bootstrapChannelID, disabledIDs)
}
func build90CNOnlineScript(
data []byte,
sourceServerID, advertiseServerID, bootstrapChannelID int,
disabledIDs map[int]bool,
) ([]byte, bool, error) {
if sourceServerID <= 0 || advertiseServerID < 0 || bootstrapChannelID <= 0 {
return nil, false, errors.New("channel script source, advertisement, and bootstrap IDs are invalid")
@@ -124,6 +140,10 @@ func build90CNOnlineScript(
if activeServer == sourceServerID {
record := append([]string(nil), fields[start:end]...)
id, _ := strconv.Atoi(record[0])
if disabledIDs != nil && disabledIDs[id] {
start = end
continue
}
channelType, _ := strconv.Atoi(record[2])
record[2] = strconv.Itoa(OnlineTypeFor90CN(id, channelType))
if id == bootstrapChannelID {
@@ -0,0 +1,234 @@
package companion
import (
"sort"
"strconv"
"strings"
)
// TeamMaxCompanions is the number of support soldiers that may enter with the
// owner. The current client's ordinary party has four slots: the owner plus
// three companions. Keeping the limit here (rather than in the bridge) makes
// every matcher, GM preflight and status projection agree.
const TeamMaxCompanions = 3
// DefaultTeamAIProfile is the fallback policy for an auto-matched teammate
// that has no per-character GM profile. Team members must actually join the
// fight, so the default is the aggressive AIC variant; operators can still
// override any character through ai_profiles (assist/aggressive).
func DefaultTeamAIProfile(classID uint16) AIProfile {
profile := DefaultAIProfile(classID)
profile.Behavior = "aggressive"
return profile
}
// TeamCandidate is the detached roster row used by the automatic matcher. It
// deliberately contains no session, inventory or mutable character pointers:
// the bridge reads one consistent repository list and passes only identity,
// level, job and online state here.
type TeamCandidate struct {
CharacterID string
Level int
Job string
Slot int
Online bool
Deleted bool
}
// TeamSkip explains why a candidate did not enter the frozen run. It is
// evidence for the GM status and packet log, not a client-facing error.
type TeamSkip struct {
CharacterID string
Reason string
}
// TeamMatch is the deterministic result of one dungeon-entry match.
type TeamMatch struct {
Selected []string
Skipped []TeamSkip
}
const (
TeamSkipOwner = "source_is_owner"
TeamSkipOnline = "source_online"
TeamSkipUnavailable = "source_unavailable"
TeamSkipLevel = "level_unsupported"
TeamSkipDeleted = "source_deleted"
TeamSkipInvalid = "source_invalid"
TeamSkipJob = "job_unsupported"
TeamSkipPartyFull = "party_full"
)
// MatchTeam forms the teammate roster for one dungeon.
//
// Rules, in order:
// 1. The GM's explicit selection is authoritative and keeps its order.
// 2. Remaining slots are filled automatically from the same account's offline,
// non-deleted characters.
// 3. Automatic picks prefer higher level first, then one character per job
// before duplicating a job, then the lower slot/id for stable output.
//
// The result is frozen by the caller. This function is pure so the selection
// cannot depend on map iteration order or on repository timing.
func MatchTeam(ownerID string, configured []string, candidates []TeamCandidate, limit int) TeamMatch {
ownerID = strings.TrimSpace(ownerID)
if limit <= 0 || limit > TeamMaxCompanions {
limit = TeamMaxCompanions
}
byID := make(map[string]TeamCandidate, len(candidates))
for _, candidate := range candidates {
id := strings.TrimSpace(candidate.CharacterID)
if !validCharacterID(id) {
continue
}
if _, exists := byID[id]; exists {
continue
}
candidate.CharacterID = id
candidate.Job = strings.TrimSpace(candidate.Job)
byID[id] = candidate
}
result := TeamMatch{
Selected: make([]string, 0, limit),
Skipped: make([]TeamSkip, 0, 4),
}
selected := make(map[string]struct{}, limit)
skip := func(id, reason string) {
if len(result.Skipped) >= 16 {
return
}
result.Skipped = append(result.Skipped, TeamSkip{CharacterID: id, Reason: reason})
}
selectID := func(id string) bool {
if _, exists := selected[id]; exists {
return true
}
if len(result.Selected) >= limit {
return false
}
selected[id] = struct{}{}
result.Selected = append(result.Selected, id)
return true
}
// Phase 1: preserve the operator's manual choice.
for _, raw := range configured {
id := strings.TrimSpace(raw)
if !validCharacterID(id) {
skip(id, TeamSkipInvalid)
continue
}
if _, exists := selected[id]; exists {
continue
}
if id == ownerID {
skip(id, TeamSkipOwner)
continue
}
candidate, found := byID[id]
if !found {
skip(id, TeamSkipUnavailable)
continue
}
if reason := teamCandidateReason(candidate); reason != "" {
skip(id, reason)
continue
}
if !selectID(id) {
skip(id, TeamSkipPartyFull)
}
}
// Phase 2: auto-fill. Sorting is made total (level, slot, numeric id) so a
// database returning rows in a different order cannot change the roster.
available := make([]TeamCandidate, 0, len(byID))
for id, candidate := range byID {
if id == ownerID {
continue
}
if _, exists := selected[id]; exists {
continue
}
if teamCandidateReason(candidate) != "" {
continue
}
available = append(available, candidate)
}
sort.Slice(available, func(i, j int) bool {
if available[i].Level != available[j].Level {
return available[i].Level > available[j].Level
}
if available[i].Slot != available[j].Slot {
return available[i].Slot < available[j].Slot
}
return teamNumericID(available[i].CharacterID) < teamNumericID(available[j].CharacterID)
})
usedJobs := make(map[string]struct{}, limit)
for id := range selected {
if candidate, ok := byID[id]; ok {
usedJobs[teamJobKey(candidate.Job)] = struct{}{}
}
}
// First pass: one character per job keeps the team varied.
for _, candidate := range available {
if len(result.Selected) >= limit {
break
}
job := teamJobKey(candidate.Job)
if job == "" {
continue
}
if _, exists := usedJobs[job]; exists {
continue
}
if selectID(candidate.CharacterID) {
usedJobs[job] = struct{}{}
}
}
// Second pass: fill the remaining slots by level/slot/id.
for _, candidate := range available {
if len(result.Selected) >= limit {
break
}
if _, exists := selected[candidate.CharacterID]; exists {
continue
}
selectID(candidate.CharacterID)
}
return result
}
func teamCandidateReason(candidate TeamCandidate) string {
if candidate.Deleted {
return TeamSkipDeleted
}
if candidate.Level < 1 || candidate.Level > 90 {
return TeamSkipLevel
}
if candidate.Online {
return TeamSkipOnline
}
if _, err := strconv.ParseUint(strings.TrimSpace(candidate.Job), 10, 8); err != nil {
return TeamSkipJob
}
return ""
}
func teamJobKey(job string) string {
parsed, err := strconv.ParseUint(strings.TrimSpace(job), 10, 8)
if err != nil {
return ""
}
return strconv.FormatUint(parsed, 10)
}
func teamNumericID(id string) int {
parsed, err := strconv.Atoi(strings.TrimSpace(id))
if err != nil {
return int(^uint(0) >> 1)
}
return parsed
}
@@ -0,0 +1,95 @@
package companion
import (
"reflect"
"testing"
)
func TestMatchTeamKeepsManualSelectionAndFillsByLevelAndJob(t *testing.T) {
candidates := []TeamCandidate{
{CharacterID: "1", Level: 90, Job: "0", Slot: 0},
{CharacterID: "2", Level: 90, Job: "12", Slot: 1},
{CharacterID: "3", Level: 90, Job: "3", Slot: 2},
{CharacterID: "4", Level: 90, Job: "0", Slot: 3},
{CharacterID: "5", Level: 21, Job: "2", Slot: 4},
}
match := MatchTeam("1", []string{"4"}, candidates, 3)
if want := []string{"4", "2", "3"}; !reflect.DeepEqual(match.Selected, want) {
t.Fatalf("selected=%v want=%v", match.Selected, want)
}
}
func TestMatchTeamSkipsOwnerOnlineDeletedLowLevelAndInvalid(t *testing.T) {
candidates := []TeamCandidate{
{CharacterID: "1", Level: 90, Job: "0", Slot: 0}, // owner
{CharacterID: "2", Level: 90, Job: "0", Slot: 1, Online: true}, // online
{CharacterID: "3", Level: 90, Job: "0", Slot: 2, Deleted: true},
{CharacterID: "4", Level: 0, Job: "0", Slot: 3},
{CharacterID: "5", Level: 90, Job: "x", Slot: 4},
{CharacterID: "0", Level: 90, Job: "0", Slot: 5},
{CharacterID: "6", Level: 90, Job: "1", Slot: 6},
}
match := MatchTeam("1", []string{"2", "3", "4", "5", "0", "99"}, candidates, 3)
if want := []string{"6"}; !reflect.DeepEqual(match.Selected, want) {
t.Fatalf("selected=%v want=%v", match.Selected, want)
}
assertSkip(t, match, "2") // online
assertSkip(t, match, "3") // deleted
assertSkip(t, match, "4") // low level
assertSkip(t, match, "5") // unsupported job
assertSkip(t, match, "99") // missing
}
func TestMatchTeamCapsAtThreeAndIsDeterministic(t *testing.T) {
candidates := []TeamCandidate{
{CharacterID: "9", Level: 90, Job: "0", Slot: 9},
{CharacterID: "7", Level: 90, Job: "2", Slot: 7},
{CharacterID: "8", Level: 90, Job: "4", Slot: 8},
{CharacterID: "6", Level: 90, Job: "1", Slot: 6},
}
first := MatchTeam("1", nil, candidates, 99)
if len(first.Selected) != TeamMaxCompanions {
t.Fatalf("selected=%v want %d entries", first.Selected, TeamMaxCompanions)
}
// Slot order wins inside the same-levelled, different-job set.
if want := []string{"6", "7", "8"}; !reflect.DeepEqual(first.Selected, want) {
t.Fatalf("selected=%v want=%v", first.Selected, want)
}
for i := 0; i < 20; i++ {
again := MatchTeam("1", nil, candidates, 99)
if !reflect.DeepEqual(again.Selected, first.Selected) {
t.Fatalf("iteration %d selected=%v want=%v", i, again.Selected, first.Selected)
}
}
}
func TestMatchTeamEmptyRosterReturnsNoSelection(t *testing.T) {
match := MatchTeam("1", []string{"2"}, nil, 3)
if len(match.Selected) != 0 {
t.Fatalf("selected=%v want empty", match.Selected)
}
assertSkip(t, match, "2")
}
func TestDefaultTeamAIProfileIsAggressiveAndJobScoped(t *testing.T) {
profile := DefaultTeamAIProfile(12)
if profile.Behavior != "aggressive" {
t.Fatalf("behavior=%q want aggressive", profile.Behavior)
}
if profile.ClassID != 12 {
t.Fatalf("class id=%d want 12", profile.ClassID)
}
if err := profile.Validate(); err != nil {
t.Fatalf("default team profile is invalid: %v", err)
}
}
func assertSkip(t *testing.T, match TeamMatch, id string) {
t.Helper()
for _, skipped := range match.Skipped {
if skipped.CharacterID == id {
return
}
}
t.Fatalf("candidate %s was not recorded as skipped: %+v", id, match.Skipped)
}
@@ -373,7 +373,7 @@ func (s *Service) buildScriptBody() ([]byte, error) {
if err != nil {
return nil, err
}
script, err = s.buildChannelScript(raw)
script, err = s.buildChannelScriptFiltered(raw, s.loadDisabledChannelIDs())
if err != nil {
return nil, fmt.Errorf("build 90CN online channel script: %w", err)
}
@@ -10,8 +10,8 @@ const (
boundMovieSHA256 = "D62E3623C39CD9FCED5F6C4C7F5E6E18BEB69EB269ABF5D713EDE08EE8C926A1"
boundMovieBytes = int64(22846532)
boundDllSHA256 = "CF2A0C4626DF2A5D056DC87CFB3D6AA07BC7A4EF7888B5B3B80A006BAD2204AF"
boundDllBytes = int64(1229824)
boundDllSHA256 = "F6DDA53482B6B8F6A3FD155FF0A15EB590C5AFF9572140004242793DC957561C"
boundDllBytes = int64(1230336)
)
@@ -63,6 +63,16 @@ type currentDungeonCompanionRun struct {
Enabled bool
BalanceEnabled bool
RandomEquipmentEnabled bool
TeamMode bool
TeamLeaderID uint16
TeamSize int
Online bool
OnlineAt time.Time
OfflineAt time.Time
OfflineReason string
LogoutSent bool
NativePartyID uint16
NativePartySent bool
Ready bool
Reason string
ReasonMessage string
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"os"
"sort"
"strconv"
@@ -369,6 +370,22 @@ func mustProjectionCharacterID(value string) uint64 {
return parsed
}
// buildCurrentDungeonCompanionOfflineBody is the exact present=0 projection
// body. The client's decoder requires all identity/count fields to be zero,
// the body length to be exactly the 72-byte header, and a non-zero revision.
// One body clears the whole squad, so the caller sends it once per run.
func buildCurrentDungeonCompanionOfflineBody(revision uint64) []byte {
if revision == 0 {
revision = 1
}
body := make([]byte, currentDungeonCompanionProjectionHeaderSize)
binary.LittleEndian.PutUint32(body[0:4], currentDungeonCompanionProjectionMagic)
body[4] = currentDungeonCompanionProjectionVersion
body[5] = 0
binary.LittleEndian.PutUint64(body[8:16], revision)
return body
}
func (s *Service) currentDungeonCompanionProjectionSourceID(
ctx context.Context,
session *gameSession,
@@ -531,6 +548,22 @@ func (s *Service) sendCurrentDungeonCompanionProjection(
"private_opcode", currentDungeonCompanionProjectionOpcode,
"body_len", len(body),
"template_mutation", false)
return s.sendGameUpperRawClass(
session, currentDungeonCompanionProjectionOpcode, body, 0)
if err := s.sendGameUpperRawClass(
session, currentDungeonCompanionProjectionOpcode, body, 0); err != nil {
return err
}
// The dungeon is now loaded on the client (projection is sent after op29 and
// player placement), so a notice here is visible instead of being swallowed
// by the loading screen.
if repos.Settings != nil {
if snapshot, found := loadCompanionPanelSnapshot(ctx, repos,
strconv.FormatUint(uint64(sourceID), 10)); found {
_ = s.sendCurrentClientNotice(session,
fmt.Sprintf("支援兵面板 HP=%d MP=%d 力量=%d 物攻=%d 物防=%d",
snapshot.HPMax, snapshot.MPMax, snapshot.Strength,
snapshot.PhysicalAttack, snapshot.PhysicalDefense),
currentClientNoticeDefaultColor, "companion-panel-summary")
}
}
return nil
}
@@ -0,0 +1,544 @@
package dnfbridge
import (
"context"
"encoding/binary"
"fmt"
"io"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
)
// Headless volunteer sessions.
//
// These are real game sessions from the server's point of view: they are in
// Service.gameSessions, have a character generation, a native party peer
// endpoint, and can be driven through the ordinary party/dungeon owners. Their
// transport is a sink: outbound packets are accepted and discarded because no
// client is attached. The owner's real client receives the normal party
// prelude (op11/op9/op153 + per-member mode0/mode1) and therefore sees real
// party actors, not the APC projection.
//
// This path is gated and OFF by default while the AIC action driver is not yet
// attached.
const (
currentCompanionHeadlessEnv = "DNF_COMPANION_HEADLESS"
currentCompanionHeadlessFlagFile = "companion_headless.enabled"
currentCompanionHeadlessPortBase = 45000
)
func currentCompanionHeadlessEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentCompanionHeadlessEnv))) {
case "0", "off", "false", "no":
return false
}
if _, err := os.Stat(currentCompanionHeadlessFlagFile); err == nil {
return true
}
return false
}
func headlessDebug(message string) {
file, err := os.OpenFile("companion_headless_debug.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return
}
defer file.Close()
_, _ = file.WriteString(time.Now().Format("15:04:05.000") + " " + message + "\n")
}
type companionHeadlessConn struct {
mu sync.Mutex
closed bool
port int
}
func (c *companionHeadlessConn) Read([]byte) (int, error) { return 0, io.EOF }
func (c *companionHeadlessConn) Write(data []byte) (int, error) {
return len(data), nil
}
func (c *companionHeadlessConn) Close() error {
c.mu.Lock()
c.closed = true
c.mu.Unlock()
return nil
}
func (c *companionHeadlessConn) LocalAddr() net.Addr {
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: c.port}
}
func (c *companionHeadlessConn) RemoteAddr() net.Addr {
return &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1), Port: c.port}
}
func (c *companionHeadlessConn) SetDeadline(time.Time) error { return nil }
func (c *companionHeadlessConn) SetReadDeadline(time.Time) error { return nil }
func (c *companionHeadlessConn) SetWriteDeadline(time.Time) error { return nil }
func (s *Service) ensureHeadlessCompanionSessions(
ctx context.Context,
ownerSession *gameSession,
sources []string,
accountID string,
) []uint16 {
if s == nil || ownerSession == nil || len(sources) == 0 {
return nil
}
headlessDebug(fmt.Sprintf("ensure_sessions owner=%d sources=%v", ownerSession.selectedCharacterID, sources))
created := make([]uint16, 0, len(sources))
for _, raw := range sources {
id, ok := currentDungeonCompanionProjectionSourceIDValue(raw)
if !ok || id == ownerSession.selectedCharacterID {
continue
}
if real, online := s.onlineGameSession(id); online && real != nil {
if !real.companionHeadless {
continue
}
created = append(created, id)
continue
}
session := &gameSession{
conn: &companionHeadlessConn{port: currentCompanionHeadlessPortBase + int(id)},
connID: "companion-headless-" + strconv.FormatUint(uint64(id), 10),
accountID: accountID,
selectedCharacterID: id,
characterGeneration: s.nextCompanionGeneration(),
channel: ownerSession.channel,
companionHeadless: true,
companionVirtual: false,
companionOwnerID: ownerSession.selectedCharacterID,
}
session.partyPeerMu.Lock()
session.partyPeer = currentPartyPeerEndpointRegistration{
InnerIPv4: [4]byte{127, 0, 0, 1},
OuterIPv4: [4]byte{127, 0, 0, 1},
Port: uint16(currentCompanionHeadlessPortBase + int(id)),
MTU: currentPartyPeerDefaultPathMTU,
}
session.partyRoutingKey = s.nextPartyRoutingKey.Add(1)
session.partyPathMTU = currentPartyPeerDefaultPathMTU
session.partyPeerMu.Unlock()
session.townMu.Lock()
session.townPositionSnapshot = ownerSession.townPositionSnapshot
session.townMu.Unlock()
s.mu.Lock()
if s.gameSessions == nil {
s.gameSessions = make(map[uint16]*gameSession)
}
s.gameSessions[id] = session
s.mu.Unlock()
go s.runHeadlessCompanionAIC(session)
created = append(created, id)
}
if len(created) != 0 {
headlessDebug(fmt.Sprintf("sessions_created ids=%v", created))
s.logGameEvent(ownerSession, "game-companion-headless-sessions-online",
"owner_character_id", ownerSession.selectedCharacterID,
"character_ids", uint16ListLog(created))
}
return created
}
// dropHeadlessCompanionSessionsForOwner removes the previous login's headless
// sessions when the feature is switched off. Without this, a relog after
// deleting the flag would leave the synthetic party members in the manager and
// the client would stay at "connecting".
func (s *Service) dropHeadlessCompanionSessionsForOwner(ownerID uint16) {
if s == nil || ownerID == 0 {
return
}
manager := s.runtimePartyManagerForService()
removed := make([]uint16, 0, 4)
type removedMember struct {
id uint16
generation uint64
}
members := make([]removedMember, 0, 4)
s.mu.Lock()
for id, session := range s.gameSessions {
if session == nil || !session.companionHeadless || session.companionOwnerID != ownerID {
continue
}
delete(s.gameSessions, id)
removed = append(removed, id)
members = append(members, removedMember{id: id, generation: session.characterGeneration})
}
s.mu.Unlock()
if manager != nil {
for _, member := range members {
manager.Leave(member.id, member.generation)
}
}
if len(removed) != 0 {
s.logPacketEvent("game-companion-headless-sessions-dropped",
"owner_character_id", ownerID,
"character_ids", uint16ListLog(removed))
}
}
// CompanionForceEnter drives the ordinary op16 dungeon entry for an online
// character without requiring the client to send op16. It exists for the
// headless-party PoC: the client's party window can be stuck on its P2P gate
// even though the server party is complete, which blocks the only packet that
// would start the entry.
func (s *Service) CompanionForceEnter(characterID uint16, dungeonID uint32) error {
if s == nil || characterID == 0 || dungeonID == 0 {
return fmt.Errorf("companion force enter needs character and dungeon")
}
session, online := s.onlineGameSession(characterID)
if !online || session == nil {
return fmt.Errorf("character %d is not online", characterID)
}
body := make([]byte, dungeoncmd.SelectDungeonRequestSize)
binary.LittleEndian.PutUint32(body[0:4], dungeonID)
body[4] = 0 // difficulty
binary.LittleEndian.PutUint16(body[5:7], 0)
body[7] = 0 // selection mode
body[8] = 0 // runtime state
binary.LittleEndian.PutUint16(body[9:11], 0)
binary.LittleEndian.PutUint32(body[11:15], 0)
body[15] = 0 // the current 21-byte writer rejects a non-zero value here
binary.LittleEndian.PutUint32(body[16:20],
uint32(currentSceneActorObjectKey(characterID)))
body[20] = 0
session.companionForceEnter = true
defer func() { session.companionForceEnter = false }()
s.ensureForceSelectorOrigin(session)
headlessDebug(fmt.Sprintf("force_enter character=%d dungeon=%d", characterID, dungeonID))
return s.handleDungeonSelectUpper(session, body)
}
func (s *Service) ensureForceSelectorOrigin(session *gameSession) bool {
if s == nil || session == nil {
return false
}
session.townMu.Lock()
if session.townSelectorOriginBound {
session.townMu.Unlock()
return true
}
origin := session.townPositionSnapshot
session.townMu.Unlock()
// The live town snapshot carries the current area (for example 38/2), while
// the dungeon return origin is validated against the durable character
// location (38/1). Use the durable ids and only borrow x/y from the live
// position.
if repos, ok := s.repositoryGroup(); ok && repos.Character != nil {
if record, found, err := repos.Character.Load(context.Background(),
strconv.FormatUint(uint64(session.selectedCharacterID), 10)); err == nil && found {
townID := byte(numericCharacterStatValue(record, "town_id"))
areaID := byte(numericCharacterStatValue(record, "area_id"))
if townID == 0 {
townID = origin.TownID
}
if areaID == 0 {
areaID = origin.AreaID
}
origin.TownID = townID
origin.AreaID = areaID
if !origin.PositionValid {
origin.PositionX = uint16(numericCharacterStatValue(record, "pos_x"))
origin.PositionY = uint16(numericCharacterStatValue(record, "pos_y"))
origin.MovementCode = byte(numericCharacterStatValue(record, "direction"))
origin.PositionValid = true
}
}
}
if !origin.PositionValid {
headlessDebug(fmt.Sprintf("force_origin_unavailable char=%d", session.selectedCharacterID))
return false
}
origin.CharacterID = session.selectedCharacterID
session.townMu.Lock()
session.townSelectorOriginSnapshot = origin
session.townSelectorOriginBound = true
session.townMu.Unlock()
headlessDebug(fmt.Sprintf("force_origin_bound char=%d town=%d area=%d x=%d y=%d",
session.selectedCharacterID, origin.TownID, origin.AreaID, origin.PositionX, origin.PositionY))
return true
}
func (s *Service) bindHeadlessSelectorOrigin(leader, follower *gameSession) bool {
if s == nil || leader == nil || follower == nil {
return false
}
leader.townMu.Lock()
origin := leader.townSelectorOriginSnapshot
bound := leader.townSelectorOriginBound
leader.townMu.Unlock()
if !bound || !origin.PositionValid {
headlessDebug(fmt.Sprintf("selector_origin_missing char=%d bound=%v valid=%v",
follower.selectedCharacterID, bound, origin.PositionValid))
return false
}
origin.CharacterID = follower.selectedCharacterID
follower.townMu.Lock()
follower.townSelectorOriginSnapshot = origin
follower.townSelectorOriginBound = true
follower.townMu.Unlock()
headlessDebug(fmt.Sprintf("selector_origin_bound char=%d town=%d area=%d x=%d y=%d",
follower.selectedCharacterID, origin.TownID, origin.AreaID, origin.PositionX, origin.PositionY))
return true
}
func (s *Service) ensureHeadlessPartyActorProjections(ctx context.Context, leader *gameSession, runtime *runtimeDungeonState) {
if s == nil || runtime == nil || runtime.Companion == nil {
return
}
for _, entry := range runtime.Companion.Sources {
if !entry.Ready || entry.SourceCharacterID == 0 {
continue
}
identity, online := s.onlineGameSessionCharacterSnapshot(entry.SourceCharacterID)
if !online || identity.session == nil || !identity.session.companionHeadless {
continue
}
if _, found := currentPartyActorProjectionSnapshot(
identity.session, entry.SourceCharacterID, identity.generation); found {
continue
}
state := entry.ProgressionSnapshot
if state.Source.CharacterID == "" {
headlessDebug(fmt.Sprintf("actor_projection_missing_state char=%d", entry.SourceCharacterID))
continue
}
name := strings.TrimSpace(state.Source.Name)
if name == "" {
name = entry.SourceName
}
ownerChannel := currentPartySceneOwnerContext(identity.session)
mode0, err := s.buildCurrentSceneObjectListBodyForSessionInContextStrict(
ctx, identity.session, entry.SourceCharacterID, name, state.Source, true, ownerChannel)
if err != nil || len(mode0) == 0 {
headlessDebug(fmt.Sprintf("actor_projection_mode0_failed char=%d err=%v", entry.SourceCharacterID, err))
continue
}
reader := csharpLegacyUserInfoReader{
ctx: ctx,
characterID: state.Source.CharacterID,
service: s,
session: identity.session,
suppressActorSkillResources: true,
equipmentSnapshot: &state.Equipment,
}
mode1 := reader.buildCurrentUserInfoMode1WithEquipmentForOwner(
state.Source, true, entry.SourceCharacterID, true, 0,
currentSceneObjectRoute, ownerChannel)
if len(mode1) == 0 {
headlessDebug(fmt.Sprintf("actor_projection_mode1_failed char=%d", entry.SourceCharacterID))
continue
}
if err := captureCurrentPartyActorProjection(
identity.session, entry.SourceCharacterID, mode0, mode1); err != nil {
headlessDebug(fmt.Sprintf("actor_projection_capture_failed char=%d err=%v", entry.SourceCharacterID, err))
continue
}
headlessDebug(fmt.Sprintf("actor_projection_captured char=%d owner=%d mode0=%d mode1=%d",
entry.SourceCharacterID, ownerChannel, len(mode0), len(mode1)))
}
}
func (s *Service) publishHeadlessActorToLeader(headless *gameSession, runtime *runtimeDungeonState) bool {
if s == nil || headless == nil || runtime == nil || runtime.partyDungeonOwner == nil {
return false
}
leader, online := s.onlineGameSession(runtime.partyDungeonOwner.leaderID)
if !online || leader == nil {
return false
}
projection, found := currentPartyActorProjectionSnapshot(
headless, headless.selectedCharacterID, headless.characterGeneration)
if !found {
return false
}
for _, body := range [][]byte{projection.mode0, projection.mode1} {
if len(body) == 0 {
continue
}
if err := s.sendGameUpperRawClass(
leader, uint16(dnfenum.CmdPacketSetUDPIPPort), body, 0); err != nil {
return false
}
}
leader.townMu.Lock()
position := leader.townPositionSnapshot
leader.townMu.Unlock()
x := int(position.PositionX) + int(headless.selectedCharacterID%3)*40
y := int(position.PositionY)
area := buildCurrentTownUserAreaNotificationBody(
currentSceneActorObjectKey(headless.selectedCharacterID),
position.TownID, 0xff, uint16(x), uint16(y), position.MovementCode,
currentActorDefaultUserStateBits)
if err := s.sendGameUpperRawClass(leader, currentTownUserAreaNotificationMsgID, area, 0); err != nil {
return false
}
move := buildCurrentTownUserPositionNotificationBody(
currentSceneActorObjectKey(headless.selectedCharacterID),
uint16(x), uint16(y), position.MovementCode, 100)
if err := s.sendGameUpperRawClass(leader, currentTownUserPositionNotificationMsgID, move, 0); err != nil {
return false
}
headlessDebug(fmt.Sprintf("headless_actor_projected char=%d leader=%d mode0=%d mode1=%d x=%d y=%d",
headless.selectedCharacterID, leader.selectedCharacterID, len(projection.mode0), len(projection.mode1), x, y))
return true
}
func (s *Service) publishHeadlessActorsBeforeStart(leader *gameSession, runtime *runtimeDungeonState) {
if s == nil || leader == nil || runtime == nil || runtime.partyDungeonOwner == nil {
return
}
for _, member := range runtime.partyDungeonOwner.members {
identity, online := s.onlineGameSessionCharacterSnapshot(member.userID)
if !online || identity.session == nil || !identity.session.companionHeadless ||
identity.session.headlessActorPublished {
continue
}
if s.publishHeadlessActorToLeader(identity.session, runtime) {
identity.session.headlessActorPublished = true
}
}
}
func (s *Service) ensureHeadlessCompanionParty(ownerSession *gameSession, ids []uint16) {
if s == nil || ownerSession == nil || len(ids) == 0 {
return
}
manager := s.runtimePartyManagerForService()
if manager == nil {
return
}
ownerIdentity, ok := s.boundGameSessionCharacterSnapshot(ownerSession)
if !ok {
headlessDebug("party owner_identity_missing")
return
}
ownerMember, ok := s.runtimePartyMemberForIdentity(ownerIdentity)
if !ok {
headlessDebug(fmt.Sprintf("party owner_member_invalid owner=%d gen=%d", ownerIdentity.character, ownerIdentity.generation))
return
}
snapshot, found := manager.SnapshotByUser(ownerIdentity.character, ownerIdentity.generation)
headlessDebug(fmt.Sprintf("party snapshot_before found=%v id=%d members=%d", found, snapshot.ID, len(snapshot.Members)))
if !found {
created := manager.EnsureLeader(ownerMember, runtimePartyStateSnapshot(ownerSession))
if !created.OK {
headlessDebug("party ensure_leader_failed reason=" + created.Reason)
s.logGameEvent(ownerSession, "game-companion-headless-party-failed",
"stage", "leader", "reason", created.Reason)
return
}
snapshot = created.Party
} else if len(snapshot.Members) > 1 {
// A real party already owns this roster; never mix headless volunteers in.
headlessDebug(fmt.Sprintf("party real_party_present members=%d", len(snapshot.Members)))
return
}
for _, id := range ids {
identity, online := s.onlineGameSessionCharacterSnapshot(id)
if !online || identity.session == nil || !identity.session.companionHeadless {
headlessDebug(fmt.Sprintf("party join_skip id=%d online=%v", id, online))
continue
}
member, memberOK := s.runtimePartyMemberForIdentity(identity)
if !memberOK {
headlessDebug(fmt.Sprintf("party join_member_invalid id=%d", id))
continue
}
joined := manager.Join(snapshot.ID, member)
if !joined.OK {
headlessDebug(fmt.Sprintf("party join_failed id=%d reason=%s", id, joined.Reason))
s.logGameEvent(ownerSession, "game-companion-headless-party-failed",
"stage", "join", "member_id", id, "reason", joined.Reason)
continue
}
snapshot = joined.Party
headlessDebug(fmt.Sprintf("party joined id=%d members=%d", id, len(snapshot.Members)))
}
if len(snapshot.Members) <= 1 {
headlessDebug("party no_members_after_join")
return
}
state := s.publishRuntimePartySnapshot(snapshot)
headlessDebug(fmt.Sprintf("party published party_id=%d members=%d", state.PartyID, len(snapshot.Members)))
if state.PartyID <= 0 {
s.logGameEvent(ownerSession, "game-companion-headless-party-failed",
"stage", "publish")
return
}
s.logGameEvent(ownerSession, "game-companion-headless-party-online",
"owner_character_id", ownerSession.selectedCharacterID,
"party_id", state.PartyID,
"member_count", len(snapshot.Members))
}
// runHeadlessCompanionAIC is the server-side action driver for one headless
// volunteer. The first milestone commits real monster deaths as that character:
// the existing op39 owner validates the room actor, applies reward/clear state,
// and broadcasts the death to every real party member, so the player sees the
// volunteer actually clearing the room. Movement/skill choreography is a later
// layer on top of the same driver.
func (s *Service) runHeadlessCompanionAIC(session *gameSession) {
if s == nil || session == nil {
return
}
ticker := time.NewTicker(1500 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
if session.selectedCharacterID == 0 {
return
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
if runtime == nil || runtime.Room == nil || runtime.partyDungeonOwner == nil {
session.dungeon.mu.Unlock()
continue
}
snapshot := runtime.Room.Snapshot()
session.dungeon.mu.Unlock()
// A real follower sends op37 FINISH_LOADING after every room load; the
// party owner's loading gate waits for every member before it releases
// the leader. The headless session has no client, so issue the same
// request itself. The handler is idempotent per loading cycle.
if !session.headlessActorPublished {
if s.publishHeadlessActorToLeader(session, runtime) {
session.headlessActorPublished = true
}
}
_ = s.sendFinishLoadingStatus(session, make([]byte, currentFinishLoadingLegacyRequestBodySize))
var target uint32
for _, monster := range snapshot.Monsters {
if monster.State == runtimeDungeonMonsterAnnounced {
target = monster.ObjectKey
break
}
}
if target == 0 {
continue
}
body := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(body[0:4], target)
binary.LittleEndian.PutUint16(body[4:6],
currentSceneActorObjectKey(session.selectedCharacterID))
if err := s.handleDungeonMonsterDeath(session, body); err != nil {
s.logGameEvent(session, "game-companion-headless-ai-death-failed",
"character_id", session.selectedCharacterID,
"object_key", target,
"error", err)
continue
}
s.logGameEvent(session, "game-companion-headless-ai-action",
"character_id", session.selectedCharacterID,
"action", "kill_monster",
"object_key", target,
"room", snapshot.Coordinate.String(),
"map_id", snapshot.MapID)
}
}
@@ -0,0 +1,430 @@
package dnfbridge
import (
"context"
"os"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/party"
dnffriend "longheng.io/server/internal/modules/dnf/unitedfriend"
)
// Volunteers are persistent social characters: the native friend list and the
// native party frame both resolve them through a synthetic presence session.
// The session has no socket; it exists only while the server process is up so
// the operator's friend window keeps seeing the volunteers as online, including
// after a dungeon ends.
const (
currentCompanionNativePartyEnv = "DNF_COMPANION_NATIVE_PARTY"
// Presence of this file disables the native op9/op153 frame without a
// restart. The friend presence stays online; only the party window is
// suppressed, which is the fast rollback if a client build rejects the
// synthetic roster.
currentCompanionNativePartyDisableFile = "companion_native_party.disabled"
currentCompanionVirtualPrefix = "companion-presence-"
)
func currentCompanionNativePartyEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentCompanionNativePartyEnv))) {
case "0", "off", "false", "no":
return false
}
if _, err := os.Stat(currentCompanionNativePartyDisableFile); err == nil {
return false
}
return true
}
func (s *Service) companionPresenceSession(characterID uint16) (*gameSession, bool) {
if s == nil || characterID == 0 {
return nil, false
}
// A real client always wins over a synthetic presence row.
if real, online := s.onlineGameSession(characterID); online && real != nil {
return real, false
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
session := s.companionSessions[characterID]
return session, session != nil
}
// companionPresenceFriendChannel returns the friend-row channel for a synthetic
// volunteer. It copies the field under companionPresenceMu because ensure may
// update the channel when the owner moves.
func (s *Service) companionPresenceFriendChannel(characterID uint16) (uint16, bool) {
if s == nil || characterID == 0 {
return 0, false
}
if real, online := s.onlineGameSession(characterID); online && real != nil {
return 0, false
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
session := s.companionSessions[characterID]
if session == nil {
return 0, false
}
return uint16(session.channel.ID), true
}
func (s *Service) companionSessionSnapshot(characterID uint16) (boundGameSessionCharacter, bool) {
session, ok := s.companionPresenceSession(characterID)
if !ok || session == nil || session.companionVirtual == false {
return boundGameSessionCharacter{}, false
}
return boundGameSessionCharacter{
session: session,
character: characterID,
generation: session.characterGeneration,
virtual: true,
}, true
}
func (s *Service) companionSessionCurrent(identity boundGameSessionCharacter) bool {
if s == nil || identity.session == nil || identity.character == 0 {
return false
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
return s.companionSessions[identity.character] == identity.session &&
identity.session.selectedCharacterID == identity.character &&
identity.session.characterGeneration == identity.generation
}
func (s *Service) nextCompanionGeneration() uint64 {
s.mu.Lock()
defer s.mu.Unlock()
s.nextGameSessionGeneration++
if s.nextGameSessionGeneration == 0 {
s.nextGameSessionGeneration++
}
return s.nextGameSessionGeneration
}
// ensureCompanionPresenceForSession makes the currently configured volunteers
// visible in the native friend window for this owner. It is called at the login
// scene tail (before op273) and again at dungeon entry.
func (s *Service) ensureCompanionPresenceForSession(ctx context.Context, session *gameSession) {
if s == nil || session == nil || session.selectedCharacterID == 0 ||
!currentDungeonCompanionTeamModeEnabled() {
return
}
if ctx == nil {
ctx = context.Background()
}
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil || repos.Settings == nil {
return
}
ownerID := session.selectedCharacterID
ownerKey := strconv.FormatUint(uint64(ownerID), 10)
cfg, _, _, err := s.loadCurrentDungeonCompanionConfig(
ctx, ownerKey, strings.TrimSpace(s.accountIDForSession(session)), repos)
if err != nil {
s.logGameEvent(session, "game-companion-presence-config-failed",
"owner_character_id", ownerID, "error", err)
return
}
if !cfg.Enabled {
s.dropCompanionPresenceForOwner(ownerID)
return
}
sources := cfg.Sources()
accountID := strings.TrimSpace(s.accountIDForSession(session))
if accountID == "" {
owner, found, loadErr := repos.Character.Load(ctx, ownerKey)
if loadErr != nil || !found {
return
}
accountID = strings.TrimSpace(owner.AccountID)
}
matched, _, applied := s.autoMatchCurrentDungeonCompanionSources(
ctx, repos, accountID, ownerID, sources)
if applied {
sources = matched
}
if len(sources) == 0 {
return
}
ownerName := ""
if _, name, _, found := s.selectedCharacterForEnter(ctx, session); found {
ownerName = name
}
s.ensureCompanionFriendRelations(ctx, session, ownerName, sources)
if currentCompanionHeadlessEnabled() {
ids := s.ensureHeadlessCompanionSessions(ctx, session, sources, accountID)
s.ensureHeadlessCompanionParty(session, ids)
} else {
s.dropHeadlessCompanionSessionsForOwner(ownerID)
s.ensureCompanionVirtualSessions(ctx, session, sources, accountID)
}
s.logGameEvent(session, "game-companion-presence-online",
"owner_character_id", ownerID,
"source_character_ids", strings.Join(sources, ","),
"count", len(sources))
}
func (s *Service) ensureCompanionVirtualSessions(
ctx context.Context,
ownerSession *gameSession,
sources []string,
accountID string,
) {
if s == nil || ownerSession == nil || len(sources) == 0 {
return
}
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil {
return
}
for _, raw := range sources {
id, ok := currentDungeonCompanionProjectionSourceIDValue(raw)
if !ok || id == ownerSession.selectedCharacterID {
continue
}
if _, realOnline := s.onlineGameSession(id); realOnline {
continue
}
character, found, err := repos.Character.Load(ctx, strconv.FormatUint(uint64(id), 10))
if err != nil || !found || character.Stats["delete_flag"] != 0 ||
strings.TrimSpace(character.Name) == "" {
continue
}
// Allocate the generation before taking companionPresenceMu: the
// generation owner lives under Service.mu, and bindGameSessionCharacter
// holds Service.mu while dropping a virtual session.
generation := s.nextCompanionGeneration()
s.companionPresenceMu.Lock()
if s.companionSessions == nil {
s.companionSessions = make(map[uint16]*gameSession)
}
session := s.companionSessions[id]
if session == nil {
session = &gameSession{
connID: currentCompanionVirtualPrefix + strconv.FormatUint(uint64(id), 10),
accountID: accountID,
selectedCharacterID: id,
characterGeneration: generation,
channel: ownerSession.channel,
companionVirtual: true,
companionOwnerID: ownerSession.selectedCharacterID,
}
s.companionSessions[id] = session
} else {
session.channel = ownerSession.channel
session.companionOwnerID = ownerSession.selectedCharacterID
session.accountID = accountID
}
s.companionPresenceMu.Unlock()
}
}
func (s *Service) ensureCompanionFriendRelations(
ctx context.Context,
session *gameSession,
ownerName string,
sources []string,
) {
if s == nil || session == nil || strings.TrimSpace(ownerName) == "" {
return
}
repos, ok := s.repositoryGroup()
if !ok || repos.UnitedFriend == nil || repos.Character == nil {
return
}
owner, err := dnffriend.NewOwner(repos, nil)
if err != nil {
return
}
ownerID := session.selectedCharacterID
s.friendMu.Lock()
defer s.friendMu.Unlock()
for _, raw := range sources {
id, ok := currentDungeonCompanionProjectionSourceIDValue(raw)
if !ok || id == ownerID {
continue
}
character, found, loadErr := repos.Character.Load(ctx, strconv.FormatUint(uint64(id), 10))
if loadErr != nil || !found || strings.TrimSpace(character.Name) == "" {
continue
}
if _, addErr := owner.Add(ctx, ownerName, ownerID, character.Name, id); addErr != nil {
if addErr != dnffriend.ErrFull {
s.logGameEvent(session, "game-companion-friend-add-failed",
"owner_character_id", ownerID,
"source_character_id", id,
"error", addErr)
}
continue
}
s.logGameEvent(session, "game-companion-friend-ensured",
"owner_character_id", ownerID,
"source_character_id", id,
"name", character.Name)
}
}
func (s *Service) dropCompanionPresenceCharacter(characterID uint16) {
if s == nil || characterID == 0 {
return
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
delete(s.companionSessions, characterID)
}
func (s *Service) dropCompanionPresenceForOwner(ownerID uint16) {
if s == nil || ownerID == 0 {
return
}
s.companionPresenceMu.Lock()
defer s.companionPresenceMu.Unlock()
for id, session := range s.companionSessions {
if session != nil && session.companionOwnerID == ownerID {
delete(s.companionSessions, id)
}
}
}
func companionPartyID(ownerID uint16) uint16 {
id := uint16(0xE000) | (ownerID & 0x0FFF)
if id == 0 || id == 0xFFFF {
id = 0xE001
}
return id
}
func (s *Service) buildCompanionPartyState(session *gameSession, run *currentDungeonCompanionRun) alignedcmd.PartyState {
if s == nil || session == nil || run == nil {
return alignedcmd.PartyState{}
}
state := alignedcmd.PartyState{
PartyID: int(companionPartyID(session.selectedCharacterID)),
IsLeader: true,
UserID: session.selectedCharacterID,
UserState: 100,
MaxMembers: 4,
}
state.Members = append(state.Members, alignedcmd.PartyMemberState{
UserID: session.selectedCharacterID, Slot: 0, UserState: 100, HPPercent: 100, MPPercent: 100,
})
slot := byte(1)
for _, entry := range run.Sources {
if !entry.Ready || entry.SourceCharacterID == 0 {
continue
}
if slot >= state.MaxMembers {
break
}
state.Members = append(state.Members, alignedcmd.PartyMemberState{
UserID: entry.SourceCharacterID, Slot: slot, UserState: 100, HPPercent: 100, MPPercent: 100,
})
slot++
}
return state
}
// publishCurrentDungeonCompanionNativeParty builds the native op9 party frame
// (kind-0 complete roster) followed by op153 HP/MP rows. It intentionally does
// not send op11: the volunteers have no P2P socket, and a made-up endpoint
// would leave the client stuck on "connecting". The scene actors themselves
// still come from the support-soldier projection.
func (s *Service) publishCurrentDungeonCompanionNativeParty(session *gameSession, runtime *runtimeDungeonState) {
if s == nil || session == nil || runtime == nil || runtime.Companion == nil ||
currentCompanionHeadlessEnabled() ||
!currentCompanionNativePartyEnabled() {
return
}
run := runtime.Companion
if !run.Ready || len(run.Sources) == 0 {
return
}
currentParty := runtimePartyStateSnapshot(session)
if currentParty.PartyID > 0 && len(runtimePartyMembers(currentParty)) > 1 {
s.logGameEvent(session, "game-companion-native-party-skipped",
"owner_character_id", session.selectedCharacterID,
"reason", "owner_already_in_native_party")
return
}
if runtime.partyDungeonOwner != nil || isCurrentImplementedRaidChannel(session) {
s.logGameEvent(session, "game-companion-native-party-skipped",
"owner_character_id", session.selectedCharacterID,
"reason", "party_or_raid_owner_has_priority")
return
}
sources := make([]string, 0, len(run.Sources))
for _, entry := range run.Sources {
if entry.Ready && entry.SourceCharacterID != 0 {
sources = append(sources, strconv.FormatUint(uint64(entry.SourceCharacterID), 10))
}
}
s.ensureCompanionVirtualSessions(
context.Background(), session, sources, strings.TrimSpace(s.accountIDForSession(session)))
state := s.buildCompanionPartyState(session, run)
if len(state.Members) <= 1 {
return
}
sent, err := s.sendCurrentPartyActorFrameProjection(
session, session, state, "companion_native_party")
if err != nil {
s.logGameEvent(session, "game-companion-native-party-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"error", err)
return
}
if !sent {
s.logGameEvent(session, "game-companion-native-party-deferred",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"reason", "op9_projection_not_delivered")
return
}
if err := s.sendRuntimePartyRealtimeInfoLocal(session, state); err != nil {
s.logGameEvent(session, "game-companion-native-party-rows-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"error", err)
return
}
run.NativePartyID = uint16(state.PartyID)
run.NativePartySent = true
s.logGameEvent(session, "game-companion-native-party-sent",
"owner_character_id", session.selectedCharacterID,
"party_id", state.PartyID,
"member_count", len(state.Members),
"source_character_ids", uint16ListLog(currentDungeonCompanionTeamSourceIDs(run)))
}
// clearCurrentDungeonCompanionNativeParty empties only the party window; the
// virtual sessions and friend rows stay online, which is the requested
// post-dungeon state.
func (s *Service) clearCurrentDungeonCompanionNativeParty(session *gameSession, run *currentDungeonCompanionRun) {
if s == nil || session == nil || run == nil || !run.NativePartySent || run.NativePartyID == 0 {
return
}
body := buildCurrentSceneOp9ActorRemovalBodyInContext(
run.NativePartyID, currentPartySceneOwnerContext(session))
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketRecoverStamina), body, 0); err != nil {
s.logGameEvent(session, "game-companion-native-party-clear-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", run.NativePartyID,
"error", err)
}
if err := s.sendGameUpperRawClassCodec(
session, 0x0099, party.BuildEmptyRealtimeInfo(), 0, true); err != nil {
s.logGameEvent(session, "game-companion-native-party-clear-rows-failed",
"owner_character_id", session.selectedCharacterID,
"party_id", run.NativePartyID,
"error", err)
}
run.NativePartySent = false
s.logGameEvent(session, "game-companion-native-party-cleared",
"owner_character_id", session.selectedCharacterID,
"party_id", run.NativePartyID,
"friends_online", true)
}
@@ -0,0 +1,129 @@
package dnfbridge
import (
"bytes"
"encoding/binary"
"net"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
)
type companionPartyTestConn struct {
net.Conn
write bytes.Buffer
}
func (c *companionPartyTestConn) Write(data []byte) (int, error) { return c.write.Write(data) }
func (c *companionPartyTestConn) SetWriteDeadline(time.Time) error {
return nil
}
func TestCompanionPresenceFriendRowRendersOnline(t *testing.T) {
service := &Service{}
virtual := &gameSession{
selectedCharacterID: 6,
characterGeneration: 7,
channel: channelcatalog.Channel{ID: 11},
companionVirtual: true,
companionOwnerID: 2,
}
service.companionSessions = map[uint16]*gameSession{6: virtual}
presence := service.currentUnitedFriendPresence(6)
if presence.Busy {
t.Fatal("volunteer friend row must not render busy/登录中")
}
if presence.ChannelID != 11 {
t.Fatalf("friend row channel=%d want 11", presence.ChannelID)
}
}
func TestBuildCompanionPartyStateUsesNativeFourSlotRoster(t *testing.T) {
service := &Service{}
owner := &gameSession{selectedCharacterID: 2}
run := &currentDungeonCompanionRun{
Ready: true,
Sources: []currentDungeonCompanionSourceRun{
{Ready: true, SourceCharacterID: 6},
{Ready: false, SourceCharacterID: 7},
{Ready: true, SourceCharacterID: 3},
},
}
state := service.buildCompanionPartyState(owner, run)
if state.PartyID <= 0 || state.PartyID >= 0xFFFF {
t.Fatalf("party id=%d is not a printable native party", state.PartyID)
}
if state.UserID != 2 || !state.IsLeader || state.MaxMembers != 4 {
t.Fatalf("party identity=%+v", state)
}
if len(state.Members) != 3 {
t.Fatalf("members=%v want owner + 2 ready volunteers", state.Members)
}
want := []struct {
id uint16
slot byte
}{{2, 0}, {6, 1}, {3, 2}}
for index, expected := range want {
member := state.Members[index]
if member.UserID != expected.id || member.Slot != expected.slot {
t.Fatalf("member %d=%+v want id=%d slot=%d", index, member, expected.id, expected.slot)
}
if member.HPPercent != 100 || member.MPPercent != 100 {
t.Fatalf("member %d hp/mp=%d/%d want 100/100", index, member.HPPercent, member.MPPercent)
}
}
}
func TestClearCompanionNativePartySendsOp9ThenOp153(t *testing.T) {
connection := &companionPartyTestConn{}
session := &gameSession{
conn: connection,
connID: "companion-party-clear",
selectedCharacterID: 2,
characterGeneration: 3,
channel: channelcatalog.Channel{ID: 11},
}
run := &currentDungeonCompanionRun{
Ready: true,
NativePartyID: 0xE002,
NativePartySent: true,
}
service := &Service{}
service.clearCurrentDungeonCompanionNativeParty(session, run)
if run.NativePartySent {
t.Fatal("native party flag was not cleared")
}
stream := connection.write.Bytes()
headerSize := service.gameUpperHeaderSize()
msgIDs := make([]uint16, 0, 2)
for len(stream) > 0 {
if len(stream) < headerSize {
t.Fatalf("truncated packet header: %d", len(stream))
}
total := int(binary.LittleEndian.Uint32(stream[3:7]))
if total < headerSize || total > len(stream) {
t.Fatalf("packet total=%d stream=%d", total, len(stream))
}
msgIDs = append(msgIDs, binary.LittleEndian.Uint16(stream[1:3]))
stream = stream[total:]
}
if len(msgIDs) != 2 || msgIDs[0] != 9 || msgIDs[1] != 0x0099 {
t.Fatalf("clear packets=%v want [9 153]", msgIDs)
}
}
func TestCompanionSessionIdentityIsCurrentOnlyWhileRegistered(t *testing.T) {
service := &Service{}
virtual := &gameSession{selectedCharacterID: 6, characterGeneration: 9, companionVirtual: true}
service.companionSessions = map[uint16]*gameSession{6: virtual}
identity, ok := service.companionSessionSnapshot(6)
if !ok || !identity.virtual || !service.boundGameSessionCharacterCurrent(identity) {
t.Fatalf("virtual identity=%+v ok=%v current=%v", identity, ok, service.boundGameSessionCharacterCurrent(identity))
}
delete(service.companionSessions, 6)
if service.boundGameSessionCharacterCurrent(identity) {
t.Fatal("removed volunteer session must fail the liveness check")
}
}
@@ -0,0 +1,306 @@
package dnfbridge
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
dnfcharstat "longheng.io/server/internal/modules/dnf/charstat"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// Companion panel snapshot (Option B): the client captures the source
// character's final character-info panel values and stores them in a settings
// record; the support-soldier projection prefers this snapshot over the
// PVF-derived base vector so the companion can mirror the source panel.
const (
companionPanelSnapshotKeyPrefix = "character:"
companionPanelSnapshotKeySuffix = ":companion-panel-v1"
)
var errCompanionPanelCharacterID = errors.New("companion panel: character_id is required")
type companionPanelSnapshot struct {
HPMax int64
MPMax int64
Strength int64
Intelligence int64
Vitality int64
Spirit int64
PhysicalAttack int64
MagicalAttack int64
IndependentAttack int64
PhysicalDefense int64
MagicalDefense int64
FireResistance int64
WaterResistance int64
DarkResistance int64
LightResistance int64
MoveSpeed int64
AttackSpeed int64
CastSpeed int64
HitRecovery int64
JumpPower int64
Weight int64
InventoryLimit int64
HPRegenSpeed int64
MPRegenSpeed int64
SourceClient string
// The following values are shown on the character-info panel but are not
// part of the 92-byte companion mode1 blob; they are stored so the GM panel
// can mirror the full panel and future projections can consume them.
// Percentages are stored scaled by 10 (70.0% -> 700); the three speed fields
// above use the blob's scale of 100 (60.50% -> 6050).
PhysicalCritical int64
MagicalCritical int64
MagicResistance int64
HitRate int64
DodgeRate int64
Stiffness int64
FireDamage int64
WaterDamage int64
LightDamage int64
DarkDamage int64
}
func companionPanelSnapshotScope(characterID string) string {
return companionPanelSnapshotKeyPrefix + strings.TrimSpace(characterID) +
companionPanelSnapshotKeySuffix
}
func companionPanelSnapshotInt(values map[string]string, names ...string) int64 {
for _, name := range names {
raw, ok := values[name]
if !ok {
continue
}
parsed, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
if err == nil {
return parsed
}
}
return 0
}
func loadCompanionPanelSnapshot(
ctx context.Context,
repos dnfrepo.Group,
characterID string,
) (companionPanelSnapshot, bool) {
if repos.Settings == nil || strings.TrimSpace(characterID) == "" {
return companionPanelSnapshot{}, false
}
record, found, err := repos.Settings.Load(
ctx, companionPanelSnapshotScope(characterID))
if err != nil || !found {
return companionPanelSnapshot{}, false
}
values := record.Values
snapshot := companionPanelSnapshot{
HPMax: companionPanelSnapshotInt(values, "hp_max"),
MPMax: companionPanelSnapshotInt(values, "mp_max"),
Strength: companionPanelSnapshotInt(values, "strength"),
Intelligence: companionPanelSnapshotInt(values, "intelligence"),
Vitality: companionPanelSnapshotInt(values, "vitality"),
Spirit: companionPanelSnapshotInt(values, "spirit"),
PhysicalAttack: companionPanelSnapshotInt(values, "physical_attack"),
MagicalAttack: companionPanelSnapshotInt(values, "magical_attack"),
IndependentAttack: companionPanelSnapshotInt(values, "independent_attack"),
PhysicalDefense: companionPanelSnapshotInt(values, "physical_defense"),
MagicalDefense: companionPanelSnapshotInt(values, "magical_defense"),
FireResistance: companionPanelSnapshotInt(values, "fire_resistance"),
WaterResistance: companionPanelSnapshotInt(values, "water_resistance"),
DarkResistance: companionPanelSnapshotInt(values, "dark_resistance"),
LightResistance: companionPanelSnapshotInt(values, "light_resistance"),
MoveSpeed: companionPanelSnapshotInt(values, "move_speed"),
AttackSpeed: companionPanelSnapshotInt(values, "attack_speed"),
CastSpeed: companionPanelSnapshotInt(values, "cast_speed"),
HitRecovery: companionPanelSnapshotInt(values, "hit_recovery"),
JumpPower: companionPanelSnapshotInt(values, "jump_power"),
Weight: companionPanelSnapshotInt(values, "weight"),
InventoryLimit: companionPanelSnapshotInt(values, "inventory_limit"),
HPRegenSpeed: companionPanelSnapshotInt(values, "hp_regen"),
MPRegenSpeed: companionPanelSnapshotInt(values, "mp_regen"),
SourceClient: strings.TrimSpace(values["source_client"]),
PhysicalCritical: companionPanelSnapshotInt(values, "physical_critical"),
MagicalCritical: companionPanelSnapshotInt(values, "magical_critical"),
MagicResistance: companionPanelSnapshotInt(values, "magic_resistance"),
HitRate: companionPanelSnapshotInt(values, "hit_rate"),
DodgeRate: companionPanelSnapshotInt(values, "dodge_rate"),
Stiffness: companionPanelSnapshotInt(values, "stiffness"),
FireDamage: companionPanelSnapshotInt(values, "fire_damage"),
WaterDamage: companionPanelSnapshotInt(values, "water_damage"),
LightDamage: companionPanelSnapshotInt(values, "light_damage"),
DarkDamage: companionPanelSnapshotInt(values, "dark_damage"),
}
if snapshot.HPMax <= 0 || snapshot.MPMax <= 0 || snapshot.Strength <= 0 {
return companionPanelSnapshot{}, false
}
return snapshot, true
}
func (s companionPanelSnapshot) Vector() dnfcharstat.Vector {
return dnfcharstat.Vector{
HPMax: s.HPMax,
MPMax: s.MPMax,
Strength: s.Strength,
Intelligence: s.Intelligence,
Vitality: s.Vitality,
Spirit: s.Spirit,
PhysicalAttack: s.PhysicalAttack,
PhysicalDefense: s.PhysicalDefense,
MagicalAttack: s.MagicalAttack,
MagicalDefense: s.MagicalDefense,
IndependentAttack: s.IndependentAttack,
FireResistance: s.FireResistance,
WaterResistance: s.WaterResistance,
DarkResistance: s.DarkResistance,
LightResistance: s.LightResistance,
InventoryLimit: s.InventoryLimit,
HPRegenSpeed: s.HPRegenSpeed,
MPRegenSpeed: s.MPRegenSpeed,
MoveSpeed: s.MoveSpeed,
AttackSpeed: s.AttackSpeed,
CastSpeed: s.CastSpeed,
HitRecovery: s.HitRecovery,
JumpPower: s.JumpPower,
Weight: s.Weight,
}
}
// CompanionPanelValues returns the raw stored panel snapshot values for the GM
// editor. found is false when no snapshot has been saved yet.
func (s *Service) CompanionPanelValues(ctx context.Context, characterID string) (map[string]string, bool, error) {
repos, ok := s.repositoryGroup()
if !ok {
return nil, false, ErrGMRepositoryUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return nil, false, errCompanionPanelCharacterID
}
record, found, err := repos.Settings.Load(ctx, companionPanelSnapshotScope(characterID))
if err != nil {
return nil, false, err
}
if !found {
return map[string]string{}, false, nil
}
values := make(map[string]string, len(record.Values))
nonEmpty := false
for k, v := range record.Values {
values[k] = v
if strings.TrimSpace(v) != "" {
nonEmpty = true
}
}
// A record whose values are all empty is a leftover of a blank GM save and
// carries no usable panel; report it as not configured so the editor does
// not show a misleading "已读取".
if !nonEmpty {
return map[string]string{}, false, nil
}
return values, true, nil
}
// SaveCompanionPanelValues replaces the stored panel snapshot values.
func (s *Service) SaveCompanionPanelValues(ctx context.Context, characterID string, values map[string]string) error {
repos, ok := s.repositoryGroup()
if !ok {
return ErrGMRepositoryUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return errCompanionPanelCharacterID
}
cleaned := make(map[string]string, len(values))
for k, v := range values {
key := strings.TrimSpace(k)
if key == "" {
continue
}
cleaned[key] = strings.TrimSpace(v)
}
return repos.Settings.Save(ctx, dnfrepo.SettingsRecord{
Scope: companionPanelSnapshotScope(characterID),
Values: cleaned,
})
}
// GenerateCompanionPanelSnapshot builds a best-effort panel snapshot from the
// server's PVF character growth plus the character's worn equipment PVF stats
// (and weapon forging). It deliberately does not model enchant cards,
// amplification, reinforcement, quality, set effects, titles, auras or pets:
// the saved values are a BASE ESTIMATE that the GM page can correct.
func (s *Service) GenerateCompanionPanelSnapshot(ctx context.Context, characterID string) (map[string]string, error) {
repos, ok := s.repositoryGroup()
if !ok {
return nil, ErrGMRepositoryUnavailable
}
characterID = strings.TrimSpace(characterID)
if characterID == "" {
return nil, errCompanionPanelCharacterID
}
character, found, err := repos.Character.Load(ctx, characterID)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("character %s not found", characterID)
}
base, ok := s.characterPVFStatsForUserInfo(ctx, nil, character, true)
if !ok {
return nil, fmt.Errorf("character %s PVF stats unavailable", characterID)
}
stats := s.addEquipmentPVFStatsForUserInfo(ctx, nil, character, base)
values := companionPanelValuesFromVector(stats, character)
return values, nil
}
func companionPanelValuesFromVector(stats dnfcharstat.Vector, character dnfrepo.CharacterRecord) map[string]string {
values := make(map[string]string, 24)
put := func(key string, value int64) {
values[key] = strconv.FormatInt(value, 10)
}
put("hp_max", stats.HPMax)
put("mp_max", stats.MPMax)
put("strength", stats.Strength)
put("intelligence", stats.Intelligence)
put("vitality", stats.Vitality)
put("spirit", stats.Spirit)
put("physical_attack", stats.PhysicalAttack)
put("magical_attack", stats.MagicalAttack)
put("independent_attack", stats.IndependentAttack)
put("physical_defense", stats.PhysicalDefense)
put("magical_defense", stats.MagicalDefense)
put("fire_resistance", stats.FireResistance)
put("water_resistance", stats.WaterResistance)
put("dark_resistance", stats.DarkResistance)
put("light_resistance", stats.LightResistance)
put("move_speed", stats.MoveSpeed)
put("attack_speed", stats.AttackSpeed)
put("cast_speed", stats.CastSpeed)
put("hit_recovery", stats.HitRecovery)
jump := stats.JumpPower
if jump == 0 {
jump = character.Stats["stat_jump_power"]
}
put("jump_power", jump)
weight := stats.Weight
if weight == 0 {
weight = character.Stats["stat_weight"]
}
put("weight", weight)
inventory := stats.InventoryLimit
if inventory == 0 {
inventory = character.Stats["stat_inventory_limit"]
}
put("inventory_limit", inventory)
put("hp_regen", stats.HPRegenSpeed)
put("mp_regen", stats.MPRegenSpeed)
values["source_client"] = "server-estimate"
return values
}
@@ -0,0 +1,192 @@
package dnfbridge
import (
"context"
"os"
"strings"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
// Real-actor PoC for the volunteer squad.
//
// This path is deliberately switch-gated and OFF by default. It publishes each
// volunteer as a real character actor (mode0 + mode1 + mode3, objectKey =
// character id, non-zero party owner context) instead of the support-soldier
// APC projection. It is the first milestone of the "real character enters the
// dungeon" route; attaching the client AIC to these actors is the next client
// patch step, so this path currently ships appearance/roster only.
const (
currentCompanionRealActorsEnv = "DNF_COMPANION_REAL_ACTORS"
currentCompanionRealActorsFlagFile = "companion_real_actors.enabled"
currentCompanionRealActorsMode0Kind = "real_party_actor"
)
func currentCompanionRealActorsEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentCompanionRealActorsEnv))) {
case "0", "off", "false", "no":
return false
}
if _, err := os.Stat(currentCompanionRealActorsFlagFile); err == nil {
return true
}
return false
}
// publishCurrentDungeonCompanionRealActors returns true only when every ready
// volunteer was published as a real actor. A partial failure returns false so
// the caller can keep the proven projection path instead of showing a broken
// half-team.
func (s *Service) publishCurrentDungeonCompanionRealActors(
session *gameSession,
runtime *runtimeDungeonState,
) bool {
if s == nil || session == nil || runtime == nil || runtime.Companion == nil ||
!currentCompanionRealActorsEnabled() {
return false
}
run := runtime.Companion
if !run.Ready || len(run.Sources) == 0 {
return false
}
ownerContext := currentPartySceneOwnerContext(session)
if ownerContext == 0 && session.channel.ID > 0 && session.channel.ID <= 0xff {
ownerContext = byte(session.channel.ID)
}
if ownerContext == 0 {
s.logGameEvent(session, "game-companion-real-actors-skipped",
"owner_character_id", session.selectedCharacterID,
"reason", "party_owner_context_not_committed")
return false
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
type realRow struct {
id uint16
name string
mode0 []byte
mode1 []byte
}
rows := make([]realRow, 0, len(run.Sources))
for _, entry := range run.Sources {
if !entry.Ready || entry.SourceCharacterID == 0 {
continue
}
state := entry.ProgressionSnapshot
if state.Source.CharacterID == "" {
return false
}
name := strings.TrimSpace(state.Source.Name)
if name == "" {
name = entry.SourceName
}
mode0, err := s.buildCurrentSceneObjectListBodyForSessionInContextStrict(
ctx, session, entry.SourceCharacterID, name, state.Source, true, ownerContext)
if err != nil || len(mode0) < 3 {
s.logGameEvent(session, "game-companion-real-actor-failed",
"source_character_id", entry.SourceCharacterID,
"stage", "mode0", "error", err)
return false
}
reader := csharpLegacyUserInfoReader{
ctx: ctx,
characterID: state.Source.CharacterID,
service: s,
session: session,
suppressActorSkillResources: true,
equipmentSnapshot: &state.Equipment,
}
mode1 := reader.buildCurrentUserInfoMode1WithEquipmentForOwner(
state.Source, true, entry.SourceCharacterID, true, 0,
currentSceneObjectRoute, ownerContext)
if len(mode1) < 3 {
s.logGameEvent(session, "game-companion-real-actor-failed",
"source_character_id", entry.SourceCharacterID,
"stage", "mode1")
return false
}
rows = append(rows, realRow{id: entry.SourceCharacterID, name: name, mode0: mode0, mode1: mode1})
}
if len(rows) == 0 {
return false
}
// The client's native multi-row reader creates scene actors for a party
// prelude. Sending one row at a time produced global visuals that a dungeon
// room never rendered; the roster must be one counted mode0/mode1 body.
var multiMode0, multiMode1 packetWriter
multiMode0.writeByte(0)
multiMode0.writeUint16(uint16(len(rows)))
multiMode1.writeByte(1)
multiMode1.writeUint16(uint16(len(rows)))
for _, row := range rows {
multiMode0.writeBytes(row.mode0[3:])
multiMode1.writeBytes(row.mode1[3:])
}
for _, body := range [][]byte{multiMode0.bytes(), multiMode1.bytes()} {
if err := s.sendGameUpperRawClass(
session, uint16(dnfenum.CmdPacketSetUDPIPPort), body, 0); err != nil {
s.logGameEvent(session, "game-companion-real-actor-failed",
"stage", "multi-row-send", "error", err)
return false
}
}
session.townMu.Lock()
position := session.townPositionSnapshot
session.townMu.Unlock()
stateBits := currentActorDefaultUserStateBits
if runtime.Character.Stats != nil {
if value, exists := runtime.Character.Stats["user_state_bits"]; exists && value >= 0 && value <= 0xff {
stateBits = byte(value)
}
}
for index, row := range rows {
x := int(position.PositionX) + index*40
y := int(position.PositionY)
if !position.PositionValid {
x = int(numericCharacterStatValue(runtime.Character, "pos_x")) + index*40
y = int(numericCharacterStatValue(runtime.Character, "pos_y"))
}
if x < 0 {
x = 0
}
if x > 0xffff {
x = 0xffff
}
if y < 0 {
y = 0
}
if y > 0xffff {
y = 0xffff
}
areaBody := buildCurrentTownUserAreaNotificationBody(
currentSceneActorObjectKey(row.id),
position.TownID, 0xff, uint16(x), uint16(y),
position.MovementCode, stateBits,
)
if err := s.sendGameUpperRawClass(
session, currentTownUserAreaNotificationMsgID, areaBody, 0); err != nil {
s.logGameEvent(session, "game-companion-real-actor-failed",
"source_character_id", row.id,
"stage", "op23", "error", err)
return false
}
s.logGameEvent(session, "game-companion-real-actor-sent",
"source_character_id", row.id,
"name", row.name,
"owner_context", ownerContext,
"object_key", currentSceneActorObjectKey(row.id),
"mode0_bytes", len(row.mode0),
"mode1_bytes", len(row.mode1),
"path", currentCompanionRealActorsMode0Kind)
}
sent := len(rows)
if sent == 0 {
return false
}
s.logGameEvent(session, "game-companion-real-actors-published",
"owner_character_id", session.selectedCharacterID,
"count", sent,
"party_owner_context", ownerContext,
"aic", "pending_client_attachment")
return true
}
@@ -0,0 +1,268 @@
package dnfbridge
import (
"context"
"encoding/binary"
"os"
"strconv"
"strings"
"time"
dnfcompanion "longheng.io/server/internal/modules/dnf/companion"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// The teammate squad is the default support-soldier entry shape. It can be
// disabled per deployment with DNF_COMPANION_TEAM_MODE=0; the GM settings stay
// authoritative for which characters are preferred, but the server fills the
// remaining slots from the same account at dungeon entry.
const currentDungeonCompanionTeamModeEnv = "DNF_COMPANION_TEAM_MODE"
const currentDungeonCompanionTeamRole = "teammate"
func currentDungeonCompanionTeamModeEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(currentDungeonCompanionTeamModeEnv))) {
case "0", "off", "false", "no":
return false
default:
return true
}
}
// autoMatchCurrentDungeonCompanionSources expands the operator's saved
// selection into a frozen teammate roster for this dungeon.
//
// This is a read-only matchmaking step: no source character is modified and no
// session is created for it. The actual snapshot/AIC/projection pipeline still
// runs per selected source exactly as before, so a bad or deleted character
// fails on its own row instead of aborting the whole team.
func (s *Service) autoMatchCurrentDungeonCompanionSources(
ctx context.Context,
repos dnfrepo.Group,
accountID string,
ownerCharacterID uint16,
configured []string,
) ([]string, []dnfcompanion.TeamSkip, bool) {
if s == nil || repos.Character == nil || strings.TrimSpace(accountID) == "" ||
!currentDungeonCompanionTeamModeEnabled() {
return configured, nil, false
}
records, err := repos.Character.ListByAccount(ctx, accountID, dnfrepo.MaxCharacterSlotLimit)
if err != nil {
s.logPacketEvent("dnf-companion-team-match-deferred",
"owner_character_id", ownerCharacterID,
"reason", "character_list_unavailable",
"error", err)
return configured, nil, false
}
ownerLevel := 0
if owner, ownerFound, ownerErr := repos.Character.Load(ctx, strconv.FormatUint(uint64(ownerCharacterID), 10)); ownerErr == nil && ownerFound {
ownerLevel = owner.Level
}
levelFloor := ownerLevel - 10
if levelFloor < 1 {
levelFloor = 1
}
candidates := make([]dnfcompanion.TeamCandidate, 0, len(records))
for _, record := range records {
id := numericCharacterID(record)
if id <= 0 || id >= int(^uint16(0)) {
continue
}
if ownerLevel > 0 && record.Level > 0 && record.Level < levelFloor {
// A level-21 character in a level-90 party's dungeon entry preflight
// makes the whole party fail the level gate. Do not auto-fill with a
// character the dungeon will reject.
s.logPacketEvent("dnf-companion-team-match-level-skipped",
"owner_character_id", ownerCharacterID,
"candidate_character_id", id,
"candidate_level", record.Level,
"owner_level", ownerLevel,
"level_floor", levelFloor)
continue
}
session, online := s.onlineGameSession(uint16(id))
if online && session != nil && session.companionHeadless {
// Our own headless presence is not a competing real player. Without
// this, every later auto-match (preflight/dungeon entry) excludes
// the volunteers it just logged in.
online = false
}
candidates = append(candidates, dnfcompanion.TeamCandidate{
CharacterID: strconv.Itoa(id),
Level: record.Level,
Job: strings.TrimSpace(record.Job),
Slot: record.Slot,
Online: online,
Deleted: record.Stats["delete_flag"] != 0,
})
}
match := dnfcompanion.MatchTeam(
strconv.FormatUint(uint64(ownerCharacterID), 10),
configured,
candidates,
dnfcompanion.TeamMaxCompanions,
)
skipped := make([]string, 0, len(match.Skipped))
for _, entry := range match.Skipped {
if len(skipped) >= 16 {
break
}
skipped = append(skipped, entry.CharacterID+":"+entry.Reason)
}
s.logPacketEvent("dnf-companion-team-match",
"owner_character_id", ownerCharacterID,
"configured", strings.Join(configured, ","),
"selected", strings.Join(match.Selected, ","),
"selected_count", len(match.Selected),
"skipped", strings.Join(skipped, ","),
"limit", dnfcompanion.TeamMaxCompanions)
return match.Selected, match.Skipped, true
}
func currentDungeonCompanionTeamSourceIDs(run *currentDungeonCompanionRun) []uint16 {
if run == nil {
return nil
}
out := make([]uint16, 0, len(run.Sources))
for _, entry := range run.Sources {
if entry.Ready {
out = append(out, entry.SourceCharacterID)
}
}
if len(out) == 0 && run.Ready && run.SourceCharacterID != 0 {
out = append(out, run.SourceCharacterID)
}
return out
}
func currentDungeonCompanionTeamRevision(run *currentDungeonCompanionRun) uint64 {
if run == nil {
return 1
}
if len(run.Body) >= currentDungeonCompanionProjectionHeaderSize {
if revision := binary.LittleEndian.Uint64(run.Body[8:16]); revision != 0 {
return revision
}
}
for _, entry := range run.Sources {
if len(entry.Body) >= currentDungeonCompanionProjectionHeaderSize {
if revision := binary.LittleEndian.Uint64(entry.Body[8:16]); revision != 0 {
return revision
}
}
}
return 1
}
func (s *Service) markCurrentDungeonCompanionTeamOnline(
session *gameSession,
run *currentDungeonCompanionRun,
source string,
) {
if s == nil || run == nil || !run.Ready {
return
}
run.Online = true
run.OnlineAt = s.gameplayNow()
run.OfflineAt = time.Time{}
run.OfflineReason = ""
run.LogoutSent = false
s.logGameEvent(session, "game-dungeon-companion-team-online",
"source", source,
"team_mode", run.TeamMode,
"team_leader_id", run.TeamLeaderID,
"team_size", run.TeamSize,
"ready_count", currentDungeonCompanionReadyCount(run),
"source_character_ids", uint16ListLog(currentDungeonCompanionTeamSourceIDs(run)),
"online_at", run.OnlineAt)
}
// sendCurrentDungeonCompanionTeamOffline publishes the explicit present=0
// projection. The client clears every queued/live support actor on that body,
// so one packet is enough for the whole squad and it is safe to call from every
// dungeon boundary: the run's LogoutSent flag makes it idempotent.
func (s *Service) sendCurrentDungeonCompanionTeamOffline(
session *gameSession,
runtime *runtimeDungeonState,
reason string,
) {
if s == nil || session == nil || runtime == nil {
return
}
run := runtime.Companion
if run == nil || !run.Ready || run.LogoutSent {
return
}
run.LogoutSent = true
run.Online = false
run.OfflineAt = s.gameplayNow()
run.OfflineReason = strings.TrimSpace(reason)
// The dungeon party frame goes away; the synthetic friend sessions do not.
s.clearCurrentDungeonCompanionNativeParty(session, run)
ids := currentDungeonCompanionTeamSourceIDs(run)
body := buildCurrentDungeonCompanionOfflineBody(currentDungeonCompanionTeamRevision(run))
s.logGameEvent(session, "game-dungeon-companion-team-offline",
"reason", run.OfflineReason,
"team_mode", run.TeamMode,
"team_leader_id", run.TeamLeaderID,
"team_size", run.TeamSize,
"offline_count", len(ids),
"source_character_ids", uint16ListLog(ids),
"offline_at", run.OfflineAt,
"private_opcode", currentDungeonCompanionProjectionOpcode,
"body_len", len(body))
if err := s.sendGameUpperRawClass(
session,
currentDungeonCompanionProjectionOpcode,
body,
0,
); err != nil {
s.logGameEvent(session, "game-dungeon-companion-team-offline-send-failed",
"reason", run.OfflineReason,
"error", err)
}
}
func (s *Service) sendCurrentDungeonCompanionTeamOfflineCurrent(
session *gameSession,
reason string,
) {
if s == nil || session == nil {
return
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
session.dungeon.mu.Unlock()
s.sendCurrentDungeonCompanionTeamOffline(session, runtime, reason)
}
func uint16ListLog(values []uint16) string {
if len(values) == 0 {
return ""
}
var builder strings.Builder
for index, value := range values {
if index != 0 {
builder.WriteByte(',')
}
builder.WriteString(strconv.Itoa(int(value)))
}
return builder.String()
}
// runtimeDungeonCompanionCurrent is the compare step used by exit paths that
// opportunistically publish the logout before taking session.dungeon.mu. The
// final authority remains retireCurrentDungeonRuntimeLocked; this only avoids
// sending a stale run's packet when a newer dungeon already replaced it.
func (s *Service) runtimeDungeonCompanionCurrent(
session *gameSession,
runtime *runtimeDungeonState,
) bool {
if s == nil || session == nil || runtime == nil {
return false
}
session.dungeon.mu.Lock()
defer session.dungeon.mu.Unlock()
return session.dungeon.runtime == runtime
}
@@ -0,0 +1,111 @@
package dnfbridge
import (
"bytes"
"encoding/binary"
"net"
"testing"
"time"
dnfcompanion "longheng.io/server/internal/modules/dnf/companion"
)
// companionOfflineTestConn is intentionally local to this test so the file can
// be compiled without the large shared service_test.go harness.
type companionOfflineTestConn struct {
net.Conn
write bytes.Buffer
}
func (c *companionOfflineTestConn) Write(data []byte) (int, error) { return c.write.Write(data) }
func (c *companionOfflineTestConn) SetWriteDeadline(time.Time) error {
return nil
}
func TestBuildCurrentDungeonCompanionOfflineBodyMatchesPresentZeroContract(t *testing.T) {
body := buildCurrentDungeonCompanionOfflineBody(12345)
if len(body) != currentDungeonCompanionProjectionHeaderSize {
t.Fatalf("body length=%d want=%d", len(body), currentDungeonCompanionProjectionHeaderSize)
}
if got := binary.LittleEndian.Uint32(body[0:4]); got != currentDungeonCompanionProjectionMagic {
t.Fatalf("magic=%#x want=%#x", got, currentDungeonCompanionProjectionMagic)
}
if body[4] != currentDungeonCompanionProjectionVersion {
t.Fatalf("version=%d want=%d", body[4], currentDungeonCompanionProjectionVersion)
}
if body[5] != 0 {
t.Fatalf("flags=%#x want 0 (present must be clear)", body[5])
}
if got := binary.LittleEndian.Uint16(body[6:8]); got != 0 {
t.Fatalf("source_character=%d want 0", got)
}
if got := binary.LittleEndian.Uint64(body[8:16]); got != 12345 {
t.Fatalf("revision=%d want 12345", got)
}
for offset := 16; offset < len(body); offset++ {
if body[offset] != 0 {
t.Fatalf("byte %d=%#x want 0", offset, body[offset])
}
}
}
func TestSendCurrentDungeonCompanionTeamOfflineIsIdempotent(t *testing.T) {
connection := &companionOfflineTestConn{}
session := &gameSession{conn: connection, connID: "companion-team-logout"}
run := &currentDungeonCompanionRun{
Ready: true,
TeamMode: true,
TeamLeaderID: 1,
TeamSize: 2,
Online: true,
Sources: []currentDungeonCompanionSourceRun{
{Ready: true, SourceCharacterID: 2, Body: buildCurrentDungeonCompanionOfflineBody(77)},
{Ready: true, SourceCharacterID: 3, Body: buildCurrentDungeonCompanionOfflineBody(77)},
},
}
runtime := &runtimeDungeonState{Companion: run}
service := &Service{}
service.sendCurrentDungeonCompanionTeamOffline(session, runtime, "dungeon_return_town")
if !run.LogoutSent || run.Online {
t.Fatalf("run state after logout = sent:%v online:%v", run.LogoutSent, run.Online)
}
if run.OfflineAt.IsZero() || run.OfflineReason != "dungeon_return_town" {
t.Fatalf("offline evidence = at:%v reason:%q", run.OfflineAt, run.OfflineReason)
}
first := connection.write.Len()
if first == 0 {
t.Fatal("logout packet was not sent")
}
// A second boundary must not replay the packet.
service.sendCurrentDungeonCompanionTeamOffline(session, runtime, "return_select_character")
if connection.write.Len() != first {
t.Fatalf("second logout wrote %d extra bytes", connection.write.Len()-first)
}
stream := connection.write.Bytes()
headerSize := service.gameUpperHeaderSize()
if len(stream) < headerSize {
t.Fatalf("logout stream too short: %d", len(stream))
}
total := int(binary.LittleEndian.Uint32(stream[3:7]))
if total != len(stream) {
t.Fatalf("logout packet total=%d stream=%d", total, len(stream))
}
if msgID := binary.LittleEndian.Uint16(stream[1:3]); msgID != currentDungeonCompanionProjectionOpcode {
t.Fatalf("msg id=%#x want=%#x", msgID, currentDungeonCompanionProjectionOpcode)
}
body := stream[headerSize:total]
if len(body) != currentDungeonCompanionProjectionHeaderSize || body[5] != 0 {
t.Fatalf("logout body length=%d flags=%d", len(body), body[5])
}
}
func TestMatchTeamAutoFillKeepsProjectionPipelinePerSource(t *testing.T) {
// The matcher is pure; this guard pins the cap the bridge passes so a future
// change cannot silently form a five-player dungeon party.
if dnfcompanion.TeamMaxCompanions != 3 {
t.Fatalf("TeamMaxCompanions=%d want 3", dnfcompanion.TeamMaxCompanions)
}
}
@@ -45,6 +45,20 @@ type gameSession struct {
completedAutoNextRuntime *runtimeDungeonState
conn net.Conn
// companionVirtual marks a synthetic session that exists only so the native
// friend list and party frame can resolve an offline volunteer as an online
// character. It has no socket and must never receive a normal send path.
companionVirtual bool
// companionHeadless is a real session in gameSessions with a sink transport.
// Party/dungeon owners treat it as a real member; no client is attached.
companionHeadless bool
companionOwnerID uint16
// companionForceEnter marks the admin-driven op16 so dungeon entry can log
// its exact refusal reason to the plaintext headless debug file.
companionForceEnter bool
// headlessActorPublished marks that this headless member's own actor rows
// were projected to the leader after the dungeon room committed.
headlessActorPublished bool
// writer owns this session's blocking socket writes so the event loop never
// parks on a client that stopped reading. See game_session_writer.go for the
// live 48-second stall measurement that made it necessary. nil means "no
@@ -13,6 +13,10 @@ type boundGameSessionCharacter struct {
session *gameSession
character uint16
generation uint64
// virtual marks a companion presence session. Such a session is not in
// Service.gameSessions (it has no socket), so the liveness check below must
// consult Service.companionSessions instead.
virtual bool
}
func (s *Service) bindGameSessionCharacter(session *gameSession, charID uint16) {
@@ -53,6 +57,13 @@ func (s *Service) bindGameSessionCharacter(session *gameSession, charID uint16)
s.stopCurrentSpendTimeClock(session, "selected_character_changed")
s.stopCurrentPetGrowthClock(session, "selected_character_changed")
}
if oldID := session.selectedCharacterID; oldID != 0 && oldID != charID {
session.dungeon.mu.Lock()
pendingCompanionRuntime := session.dungeon.runtime
session.dungeon.mu.Unlock()
s.sendCurrentDungeonCompanionTeamOffline(
session, pendingCompanionRuntime, "selected_character_changed")
}
session.dungeon.mu.Lock()
if oldID := session.selectedCharacterID; oldID != 0 && oldID != charID {
runtime := session.dungeon.runtime
@@ -143,6 +154,9 @@ func (s *Service) bindGameSessionCharacter(session *gameSession, charID uint16)
session.collectboxSelectionRefreshSent = false
}
session.selectedCharacterID = charID
// A real client binding this character always retires the synthetic
// volunteer presence row, so the friend list cannot claim two online copies.
s.dropCompanionPresenceCharacter(charID)
if resident != nil && resident != session {
if current := s.gameSessions[charID]; current == resident {
s.mu.Unlock()
@@ -436,6 +450,7 @@ func (s *Service) releaseDisconnectedDungeonAttempt(session *gameSession, reason
// 掉线不退队 —— 座位由 raid_disconnect.go 的宽限窗口守着,这里存的坐标只作
// 诊断与重连参考(重连实际一律回 PVF 等待室起点,见 runtimeFiendWarRejoinLocation)。
s.captureRuntimeFiendWarRejoinPosition(session)
s.sendCurrentDungeonCompanionTeamOfflineCurrent(session, "disconnected")
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
if runtime == nil {
@@ -538,6 +553,9 @@ func (s *Service) boundGameSessionCharacterCurrent(identity boundGameSessionChar
if s == nil || identity.session == nil || identity.character == 0 {
return false
}
if identity.virtual {
return s.companionSessionCurrent(identity)
}
s.mu.Lock()
defer s.mu.Unlock()
return s.gameSessions[identity.character] == identity.session &&
@@ -73,6 +73,7 @@ func (s *Service) commitCurrentAvatarSocketOpen(session *gameSession, request cu
if err != nil {
return currentSocketMutationResult{}, err
}
socketTypes = currentAvatarSocketTypesForOpen(rule, socketTypes)
if len(socketTypes) == 0 {
return currentSocketMutationResult{}, fmt.Errorf("%w: avatar socket definitions missing item=%d", errCurrentSocketPVFInvalid, request.TargetItemID)
}
@@ -0,0 +1,283 @@
package dnfbridge
// gm_activity_switches.go 提供「活动开关」的运行时配置:服主在启动器的
// 「游戏设置 → 活动开关」页里切换某个活动是否下发,这里负责持久化 + 缓存,
// 并让活动目录下发(buildCurrentOnlineEventInfoBodyAt)与各活动投影读取它。
//
// 为什么单独一份 scope 而不是塞进 gm-gameplay:
//
// gameplay 那一份 scope 管的是"数值旋钮"(掉落率、倍率、疲劳、燃烧),
// 活动开关管的是"这个活动要不要在客户端显示/可参与",语义与读写频率都不同。
// 分开后,启动器页面可以各自独立刷新,也不用担心一份 scope 里键越堆越多。
//
// 持久化与 gameplay 设置同模式:repositories.Settings + scope + mutex 缓存。
// 缓存未就绪时首次读取落一次仓储,之后走内存;写入即时更新缓存并落盘。
//
// 开关的默认值(defaults)来自当前部署的真实状态,不是"全开":
//
// spend_time 累计在线得好礼(2347) 默认关:本部署走 PC 房泡点(228)分支
// level_aiming 等级预约(222) 默认关:按运营要求已关闭
// joust 骑士马战(2365) 默认关:活动已取消
// monthly 月度签到(2370) 默认开
// weekly 周周签到(2208) 默认开
// pcroom PC 房时间点(228) 默认开:当前生效的泡点活动
import (
"context"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const gmActivitySwitchesScope = "global:dnf90:gm-activity-switches"
// 活动开关键名。值与 true/false 对应("1"/"true" 为开)。
const (
gmActivitySpendTimeKey = "spend_time"
gmActivityLevelAimingKey = "level_aiming"
gmActivityJoustKey = "joust"
gmActivityMonthlyKey = "monthly_attendance"
gmActivityWeeklyKey = "weekly_attendance"
gmActivityPCRoomKey = "pcroom_timepoint"
)
// gmActivitySwitchDefinition 描述一个可切换的活动。
type gmActivitySwitchDefinition struct {
Key string // 持久化键名
Title string // 中文名
Description string // 中文说明
Default bool // 默认开关状态
}
// gmActivitySwitchDefinitions 全部可切换活动(顺序即启动器展示顺序)。
var gmActivitySwitchDefinitions = []gmActivitySwitchDefinition{
{
Key: gmActivityPCRoomKey,
Title: "PC房时间点奖励",
Description: "泡点/挂机得奖励(事件 228/561)。当前部署默认开启。",
Default: true,
},
{
Key: gmActivitySpendTimeKey,
Title: "累计在线得好礼",
Description: "按累计在线时长领取奖励(事件 2347)。与 PC 房泡点互斥,默认关闭。",
Default: false,
},
{
Key: gmActivityMonthlyKey,
Title: "月度签到",
Description: "每月推荐副本通关计数领奖(事件 2370)。默认开启。",
Default: true,
},
{
Key: gmActivityWeeklyKey,
Title: "周周签到",
Description: "每周签到领奖(事件 2208)。默认开启。",
Default: true,
},
{
Key: gmActivityLevelAimingKey,
Title: "等级预约(命运/决策)",
Description: "全职LV预约得好礼(事件 222)。按运营要求默认关闭。",
Default: false,
},
{
Key: gmActivityJoustKey,
Title: "骑士马战",
Description: "骑士马战竞猜活动(事件 2365)。已取消,默认关闭。",
Default: false,
},
}
// GMActivitySwitch 单个活动的当前开关状态。
type GMActivitySwitch struct {
Key string `json:"key"`
Title string `json:"title"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
}
// GMActivitySwitches 活动开关总览(供 GM 接口 / 启动器展示)。
type GMActivitySwitches struct {
Switches []GMActivitySwitch `json:"switches"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// gmActivitySwitchesFromRecord 从设置记录解析全部活动开关。
// 缺失的键回落对应默认值;非法值回落默认值(不做硬失败,保持可恢复)。
func gmActivitySwitchesFromRecord(record dnfrepo.SettingsRecord, found bool) map[string]bool {
out := make(map[string]bool, len(gmActivitySwitchDefinitions))
for _, def := range gmActivitySwitchDefinitions {
out[def.Key] = def.Default
}
if !found {
return out
}
for _, def := range gmActivitySwitchDefinitions {
raw := strings.TrimSpace(record.Values[def.Key])
if raw == "" {
continue
}
out[def.Key] = raw == "1" || strings.EqualFold(raw, "true")
}
return out
}
// GMActivitySwitches 返回当前活动开关总览(带缓存)。
func (s *Service) GMActivitySwitches(ctx context.Context) (GMActivitySwitches, error) {
if s == nil {
return GMActivitySwitches{}, ErrGMUnavailable
}
s.gmActivitySwitchesMu.Lock()
defer s.gmActivitySwitchesMu.Unlock()
if s.gmActivitySwitchesReady {
return s.gmActivitySwitchesSnapshotLocked(), nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return GMActivitySwitches{}, ErrGMUnavailable
}
record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope)
if err != nil {
return GMActivitySwitches{}, err
}
s.gmActivitySwitches = gmActivitySwitchesFromRecord(record, found)
s.gmActivitySwitchesAt = record.UpdatedAt
s.gmActivitySwitchesReady = true
return s.gmActivitySwitchesSnapshotLocked(), nil
}
// GMSetActivitySwitch 切换某个活动的开关状态,返回更新后的总览。
func (s *Service) GMSetActivitySwitch(ctx context.Context, key string, enabled bool) (GMActivitySwitches, error) {
if s == nil {
return GMActivitySwitches{}, ErrGMUnavailable
}
key = strings.TrimSpace(strings.ToLower(key))
if !validGMActivitySwitchKey(key) {
return GMActivitySwitches{}, fmt.Errorf("%w: 未知的活动开关 %q", ErrGMInvalidRequest, key)
}
s.gmActivitySwitchesMu.Lock()
defer s.gmActivitySwitchesMu.Unlock()
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return GMActivitySwitches{}, ErrGMUnavailable
}
// 先确保缓存已就绪(读当前值),再改。
if !s.gmActivitySwitchesReady {
record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope)
if err != nil {
return GMActivitySwitches{}, err
}
s.gmActivitySwitches = gmActivitySwitchesFromRecord(record, found)
s.gmActivitySwitchesAt = record.UpdatedAt
s.gmActivitySwitchesReady = true
}
// 读最新记录用于落盘(避免覆盖其它并发写入)。
record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope)
if err != nil {
return GMActivitySwitches{}, err
}
if !found {
record = dnfrepo.SettingsRecord{Scope: gmActivitySwitchesScope}
} else {
record = dnfrepo.CloneSettings(record)
}
if record.Values == nil {
record.Values = make(map[string]string)
}
record.Values[key] = strconv.FormatBool(enabled)
record.UpdatedAt = time.Now().UTC()
if err := dnfrepo.SaveSettingsFields(ctx, repositories.Settings, record, dnfrepo.SettingsFieldValues); err != nil {
return GMActivitySwitches{}, err
}
s.gmActivitySwitches[key] = enabled
s.gmActivitySwitchesAt = record.UpdatedAt
return s.gmActivitySwitchesSnapshotLocked(), nil
}
// activitySwitchEnabled 读取某个活动的当前开关(内部无锁读取,调用方自行加锁)。
func (s *Service) activitySwitchEnabledLocked(key string) bool {
if s == nil || s.gmActivitySwitches == nil {
return false
}
enabled, ok := s.gmActivitySwitches[key]
return ok && enabled
}
// ActivitySwitchEnabled 读取某个活动的当前开关(公开、带锁)。
//
// 语义:开关「开启」意味着该活动照常下发/可参与;「关闭」意味着不下发、
// 客户端不显示该活动。未持久化过时回落定义里的默认值。
//
// 注意默认值的含义:本方法在缓存未就绪时会尽力加载一次(懒加载),
// 加载失败或仓储不可用时,回落"默认值"——即该活动在定义里的 Default。
// 这样即使 GM 接口从未被调用,玩法侧也能按部署默认(而非全关)工作。
func (s *Service) ActivitySwitchEnabled(ctx context.Context, key string) bool {
if s == nil {
return false
}
key = strings.TrimSpace(strings.ToLower(key))
s.gmActivitySwitchesMu.Lock()
defer s.gmActivitySwitchesMu.Unlock()
if !s.gmActivitySwitchesReady {
if repositories, ok := s.repositoryGroup(); ok && repositories.Settings != nil {
if record, found, err := repositories.Settings.Load(ctx, gmActivitySwitchesScope); err == nil {
s.gmActivitySwitches = gmActivitySwitchesFromRecord(record, found)
s.gmActivitySwitchesAt = record.UpdatedAt
s.gmActivitySwitchesReady = true
}
}
}
if s.gmActivitySwitches == nil {
for _, def := range gmActivitySwitchDefinitions {
if def.Key == key {
return def.Default
}
}
return false
}
if enabled, ok := s.gmActivitySwitches[key]; ok {
return enabled
}
for _, def := range gmActivitySwitchDefinitions {
if def.Key == key {
return def.Default
}
}
return false
}
// gmActivitySwitchesSnapshotLocked 组装总览(调用方须持 gmActivitySwitchesMu)。
func (s *Service) gmActivitySwitchesSnapshotLocked() GMActivitySwitches {
switches := make([]GMActivitySwitch, 0, len(gmActivitySwitchDefinitions))
for _, def := range gmActivitySwitchDefinitions {
enabled := def.Default
if s.gmActivitySwitches != nil {
if value, ok := s.gmActivitySwitches[def.Key]; ok {
enabled = value
}
}
switches = append(switches, GMActivitySwitch{
Key: def.Key,
Title: def.Title,
Description: def.Description,
Enabled: enabled,
})
}
return GMActivitySwitches{
Switches: switches,
UpdatedAt: s.gmActivitySwitchesAt,
}
}
func validGMActivitySwitchKey(key string) bool {
for _, def := range gmActivitySwitchDefinitions {
if def.Key == key {
return true
}
}
return false
}
@@ -0,0 +1,303 @@
package dnfbridge
// gm_channel_switches.go 提供「频道开关」的运行时配置:服主在启动器的
// 「游戏设置 → 频道开关」页里关掉某个频道,这里持久化禁用频道集合,
// 并在频道目录(channelcatalog.Catalog)与客户端频道脚本(channelScript)
// 构建时过滤掉被禁用的频道,随后对账游戏端口监听(关掉的频道不再开端口)。
//
// 与 gm_channel_reload.go 的热重载区别:
//
// 热重载是"重读 channel_info.etc 这个文件",适合运维在文件里改完后一键生效;
// 频道开关是"运行时按频道 ID 记录禁用状态",不要求改文件,开关即改内存目录 +
// 落盘,重启后依然生效(从 Settings 读回)。
//
// 持久化与 activity/gameplay 设置同模式:repositories.Settings + scope + mutex 缓存。
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/channelinfo"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const gmChannelSwitchesScope = "global:dnf90:gm-channel-switches"
// gmChannelDisabledKeyPrefix 是持久化里"禁用频道"键的前缀,键形如 "disabled:<id>"。
const gmChannelDisabledKeyPrefix = "disabled:"
// GMChannelSwitch 单个频道的开关状态。
type GMChannelSwitch struct {
ID int `json:"id"`
Name string `json:"name"`
Group string `json:"group,omitempty"`
Type uint8 `json:"type"`
Port int `json:"port"`
Enabled bool `json:"enabled"`
}
// GMChannelSwitches 频道开关总览(供 GM 接口 / 启动器展示)。
type GMChannelSwitches struct {
TotalChannels int `json:"total_channels"`
EnabledChannels int `json:"enabled_channels"`
DisabledChannels int `json:"disabled_channels"`
Channels []GMChannelSwitch `json:"channels"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// gmDisabledChannelIDsFromRecord 从设置记录解析禁用频道 ID 集合。
func gmDisabledChannelIDsFromRecord(record dnfrepo.SettingsRecord, found bool) map[int]bool {
out := make(map[int]bool)
if !found {
return out
}
for key, value := range record.Values {
if !strings.HasPrefix(key, gmChannelDisabledKeyPrefix) {
continue
}
if strings.TrimSpace(value) != "1" && !strings.EqualFold(strings.TrimSpace(value), "true") {
continue
}
idText := strings.TrimPrefix(key, gmChannelDisabledKeyPrefix)
id, err := strconv.Atoi(idText)
if err != nil || id <= 0 {
continue
}
out[id] = true
}
return out
}
// gmDisabledChannelIDsLocked 返回当前禁用的频道 ID 集合(调用方须持 gmChannelSwitchesMu)。
func (s *Service) gmDisabledChannelIDsLocked() map[int]bool {
if s == nil || s.gmChannelSwitches == nil {
return nil
}
return s.gmChannelSwitches
}
// GMChannelSwitches 返回当前频道开关总览(带缓存)。
func (s *Service) GMChannelSwitches(ctx context.Context) (GMChannelSwitches, error) {
if s == nil {
return GMChannelSwitches{}, ErrGMUnavailable
}
s.gmChannelSwitchesMu.Lock()
defer s.gmChannelSwitchesMu.Unlock()
if s.gmChannelSwitchesReady {
return s.gmChannelSwitchesSnapshotLocked(), nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return GMChannelSwitches{}, ErrGMUnavailable
}
record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope)
if err != nil {
return GMChannelSwitches{}, err
}
s.gmChannelSwitches = gmDisabledChannelIDsFromRecord(record, found)
s.gmChannelSwitchesAt = record.UpdatedAt
s.gmChannelSwitchesReady = true
return s.gmChannelSwitchesSnapshotLocked(), nil
}
// GMSetChannelSwitch 切换某频道的启用状态,返回更新后的总览。
//
// 生效机制:更新持久化的禁用集合 → 重建频道目录与脚本(应用禁用过滤)→
// 对账游戏端口监听。与 GMReloadChannels 一样,失败时保留原运行态。
func (s *Service) GMSetChannelSwitch(ctx context.Context, channelID int, enabled bool) (GMChannelSwitches, error) {
if s == nil {
return GMChannelSwitches{}, ErrGMUnavailable
}
if channelID <= 0 {
return GMChannelSwitches{}, fmt.Errorf("%w: channel id must be positive", ErrGMInvalidRequest)
}
s.gmChannelSwitchesMu.Lock()
defer s.gmChannelSwitchesMu.Unlock()
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return GMChannelSwitches{}, ErrGMUnavailable
}
if !s.gmChannelSwitchesReady {
record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope)
if err != nil {
return GMChannelSwitches{}, err
}
s.gmChannelSwitches = gmDisabledChannelIDsFromRecord(record, found)
s.gmChannelSwitchesAt = record.UpdatedAt
s.gmChannelSwitchesReady = true
}
// 确认该频道确实存在于当前目录(不能凭空开关一个不存在的频道)。
active := s.currentCatalog()
if active == nil {
return GMChannelSwitches{}, ErrGMUnavailable
}
if _, exists := active.Channel(channelID); !exists {
return GMChannelSwitches{}, fmt.Errorf("%w: channel %d not found", ErrGMInvalidRequest, channelID)
}
// 读最新记录落盘。
record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope)
if err != nil {
return GMChannelSwitches{}, err
}
if !found {
record = dnfrepo.SettingsRecord{Scope: gmChannelSwitchesScope}
} else {
record = dnfrepo.CloneSettings(record)
}
if record.Values == nil {
record.Values = make(map[string]string)
}
key := gmChannelDisabledKeyPrefix + strconv.Itoa(channelID)
if enabled {
delete(record.Values, key)
} else {
record.Values[key] = "1"
}
record.UpdatedAt = time.Now().UTC()
if err := dnfrepo.SaveSettingsFields(ctx, repositories.Settings, record, dnfrepo.SettingsFieldValues); err != nil {
return GMChannelSwitches{}, err
}
// 更新内存缓存。
newDisabled := gmDisabledChannelIDsFromRecord(record, true)
s.gmChannelSwitches = newDisabled
s.gmChannelSwitchesAt = record.UpdatedAt
// 应用过滤:重建目录与脚本并热替换(复用热重载的对账逻辑,但源是内存禁用集合)。
if err := s.applyChannelSwitchesLocked(newDisabled); err != nil {
// 目录应用失败不覆盖已落盘的开关,下次读回仍会尝试应用。
return GMChannelSwitches{}, fmt.Errorf("%w: 应用频道开关失败: %v", ErrGMUnavailable, err)
}
return s.gmChannelSwitchesSnapshotLocked(), nil
}
// applyChannelSwitchesLocked 用禁用集合重建频道目录与脚本并热替换(调用方须持 gmChannelSwitchesMu)。
// 与 GMReloadChannels 同源,但过滤来源是内存禁用集合而非 etc 文件。
func (s *Service) applyChannelSwitchesLocked(disabled map[int]bool) error {
raw, err := osReadFile(s.options.channelInfoFile)
if err != nil {
return fmt.Errorf("load dnf channel info: %w", err)
}
index, err := channelinfo.Parse(raw)
if err != nil {
return fmt.Errorf("parse dnf channel info: %w", err)
}
catalog, err := channelcatalog.New(index, channelcatalog.Options{
ServerID: s.channelServerID(),
DisabledIDs: disabled,
})
if err != nil {
return err
}
script, err := s.buildChannelScriptFiltered(raw, disabled)
if err != nil {
return fmt.Errorf("build 90CN online channel script: %w", err)
}
s.mu.Lock()
oldCatalog := s.catalog
runCtx := s.runCtx
if s.cancel == nil || runCtx == nil {
s.mu.Unlock()
return fmt.Errorf("服务未启动,无法应用频道开关")
}
s.catalog = catalog
s.channelScript = append([]byte(nil), script...)
s.mu.Unlock()
// 对账游戏端口监听(与 GMReloadChannels 同逻辑)。
oldPorts := []int(nil)
if oldCatalog != nil {
oldPorts = oldCatalog.GamePorts()
}
newPorts := catalog.GamePorts()
for _, port := range oldPorts {
if containsIntValue(newPorts, port) {
continue
}
_ = s.closeGamePort(port)
}
for _, port := range newPorts {
if containsIntValue(oldPorts, port) {
continue
}
if err := s.listenGamePort(runCtx, port); err != nil {
s.logWarn("dnfbridge channel switch open port failed", "port", port, "error", err)
}
}
s.logInfo("dnfbridge channel switches applied",
"disabled", len(disabled),
"total_channels", len(catalog.Channels()))
return nil
}
// buildChannelScriptFiltered 用禁用集合生成客户端频道脚本。
func (s *Service) buildChannelScriptFiltered(raw []byte, disabled map[int]bool) ([]byte, error) {
if s.channelTableStrict() {
return channelinfo.Build90CNOnlineScriptFiltered(
raw,
s.channelServerID(),
s.channelAdvertiseID(),
dnfenum.BootstrapChannelID,
disabled,
)
}
script, bootstrapPresent, err := channelinfo.Build90CNOnlineScriptAllowingMissingBootstrapFiltered(
raw,
s.channelServerID(),
s.channelAdvertiseID(),
dnfenum.BootstrapChannelID,
disabled,
)
if err != nil {
return nil, err
}
if !bootstrapPresent {
s.logWarn("dnfbridge channel table has no bootstrap channel; applying switch anyway",
"bootstrap_channel", dnfenum.BootstrapChannelID)
}
return script, nil
}
// gmChannelSwitchesSnapshotLocked 组装总览(调用方须持 gmChannelSwitchesMu)。
func (s *Service) gmChannelSwitchesSnapshotLocked() GMChannelSwitches {
active := s.currentCatalog()
disabled := s.gmDisabledChannelIDsLocked()
if active == nil {
return GMChannelSwitches{
Channels: []GMChannelSwitch{},
UpdatedAt: s.gmChannelSwitchesAt,
}
}
channels := active.Channels()
out := make([]GMChannelSwitch, 0, len(channels))
enabledCount := 0
for _, channel := range channels {
isDisabled := disabled != nil && disabled[channel.ID]
enabled := !isDisabled
if enabled {
enabledCount++
}
out = append(out, GMChannelSwitch{
ID: channel.ID,
Name: channel.Name,
Group: channel.Group,
Type: channel.Type,
Port: channel.Port,
Enabled: enabled,
})
}
return GMChannelSwitches{
TotalChannels: len(channels),
EnabledChannels: enabledCount,
DisabledChannels: len(channels) - enabledCount,
Channels: out,
UpdatedAt: s.gmChannelSwitchesAt,
}
}
@@ -59,6 +59,10 @@ func (s *Service) sendCurrentMonthlyAttendance(session *gameSession, claim bool)
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
// 活动开关:月度签到被关闭时不下发(客户端活动窗口不显示该活动)。
if !s.ActivitySwitchEnabled(context.Background(), gmActivityMonthlyKey) {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), currentSpendTimeWriteTimeout)
defer cancel()
catalog, err := s.currentMonthlyCatalog(ctx)
@@ -488,12 +488,15 @@ func (s *Service) sendCurrentSpendTimeProtocolState(
return false, nil
}
var eventInfoTransport []byte
// 活动开关:pcroom_timepoint 开关决定走「PC 房时间点(228)」还是「累计在线得好礼(2347)」。
// 开关关闭时强制走 2347 分支(即使 etc/pcroomtimepoint.etc 存在)。
usePCRoom := catalog.nativePCRoom != nil && s.ActivitySwitchEnabled(context.Background(), gmActivityPCRoomKey)
if includeFirstProcessDescriptor {
eventInfoBody, err := buildCurrentOnlineEventInfoBodyAt(
catalog.rewardItemIDs,
catalog.sumRewardItemIDs,
catalog.totalStageSeconds,
catalog.nativePCRoom != nil,
usePCRoom,
catalog.laborDay, s.gameplayNow(),
)
if err != nil {
@@ -526,7 +529,7 @@ func (s *Service) sendCurrentSpendTimeProtocolState(
}
// Install the directory before the selected online owner's state. The
// original four-plus-four panel is 228/561; 2347/1206 is a different HUD.
if catalog.nativePCRoom != nil {
if usePCRoom {
if err := s.sendCurrentPCRoomTimePoint(session, catalog, 0, -1); err != nil {
return descriptorSent, err
}
@@ -0,0 +1,434 @@
package dnfbridge
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"time"
dnfauction "longheng.io/server/internal/modules/dnf/auction"
)
type currentServerFeatureSettings struct {
RaidReviveSeconds int `json:"raidReviveSeconds"`
AllDeadReturnSeconds int `json:"allDeadReturnSeconds"`
AllowFreeRevive *bool `json:"allowFreeRevive"`
HellOrbChance int `json:"hellOrbChance"`
HellDropChanceDenominator int `json:"hellDropChanceDenominator"`
EquipmentRollMultiplier int `json:"equipmentRollMultiplier"`
EpicPieceRollMultiplier int `json:"epicPieceRollMultiplier"`
AntonRaidAttackSeconds int `json:"antonRaidAttackSeconds"`
LukeEnergyRecoveryPercent int `json:"lukeEnergyRecoveryPercent"`
FiendWarSquadCount int `json:"fiendWarSquadCount"`
RoomFatigueCost int `json:"roomFatigueCost"`
DailyFatigueResetHour int `json:"dailyFatigueResetHour"`
AdventureGroupBonusPercent int `json:"adventureGroupBonusPercent"`
RentalMaxReinforce int `json:"rentalMaxReinforce"`
RentalMaxForge int `json:"rentalMaxForge"`
ReviveByDungeon map[string]int `json:"reviveByDungeon"`
AntonBlackFogRecoverySeconds int `json:"antonBlackFogRecoverySeconds"`
AntonBlackFogActiveSeconds int `json:"antonBlackFogActiveSeconds"`
AntonQuakeRecoverySeconds int `json:"antonQuakeRecoverySeconds"`
AntonQuakeActiveSeconds int `json:"antonQuakeActiveSeconds"`
AntonNavalOpenDelaySeconds int `json:"antonNavalOpenDelaySeconds"`
AntonNavalRecoverySeconds int `json:"antonNavalRecoverySeconds"`
AntonNavalActiveSeconds int `json:"antonNavalActiveSeconds"`
LukeEnergyRecovery0Seconds int `json:"lukeEnergyRecovery0Seconds"`
LukeEnergyRecovery1Seconds int `json:"lukeEnergyRecovery1Seconds"`
LukeEnergyRecovery2Seconds int `json:"lukeEnergyRecovery2Seconds"`
LukePhaseTimeoutPercent int `json:"lukePhaseTimeoutPercent"`
FiendWarAreaLimitPercent int `json:"fiendWarAreaLimitPercent"`
FiendWarDieImprisonPercent int `json:"fiendWarDieImprisonPercent"`
SoloTeleportCooldownSeconds int `json:"soloTeleportCooldownSeconds"`
SoloTeleportCooldownIndices string `json:"soloTeleportCooldownIndices"`
AuctionStandardListingLimit int `json:"auctionStandardListingLimit"`
AuctionPremiumListingLimit int `json:"auctionPremiumListingLimit"`
AuctionStandardDurationHours int `json:"auctionStandardDurationHours"`
AuctionPremiumDurationHours int `json:"auctionPremiumDurationHours"`
AntonMemberMax int `json:"antonMemberMax"`
AntonStartMinimumMembers int `json:"antonStartMinimumMembers"`
AntonSingleStartMinimumMembers int `json:"antonSingleStartMinimumMembers"`
AntonStartDelaySeconds int `json:"antonStartDelaySeconds"`
AntonPhaseBreakSeconds int `json:"antonPhaseBreakSeconds"`
AntonEntryCostItem int `json:"antonEntryCostItem"`
AntonEntryCostCount int `json:"antonEntryCostCount"`
AntonWeeklyEnterCountA int `json:"antonWeeklyEnterCountA"`
AntonWeeklyEnterCountB int `json:"antonWeeklyEnterCountB"`
AntonPhaseTimeoutSeconds string `json:"antonPhaseTimeoutSeconds"`
LukeDailyAccountEnterLimit int `json:"lukeDailyAccountEnterLimit"`
LukeSanctumTimerCount int `json:"lukeSanctumTimerCount"`
LukeEntryCostItem int `json:"lukeEntryCostItem"`
LukeEntryCostCount int `json:"lukeEntryCostCount"`
FiendWarMemberMax int `json:"fiendWarMemberMax"`
FiendWarStartMinimumMembers int `json:"fiendWarStartMinimumMembers"`
FiendWarDisconnectWaitSeconds int `json:"fiendWarDisconnectWaitSeconds"`
FiendWarEntryCostItem int `json:"fiendWarEntryCostItem"`
FiendWarEntryCostCount int `json:"fiendWarEntryCostCount"`
FiendWarRetry *bool `json:"fiendWarRetry"`
FiendWarDailyEnterLimit int `json:"fiendWarDailyEnterLimit"`
AuctionStandardFeePercent int `json:"auctionStandardFeePercent"`
AuctionPremiumFeePercent int `json:"auctionPremiumFeePercent"`
}
func GMSaveServerFeatures(data []byte) error {
var values map[string]json.RawMessage
if err := json.Unmarshal(data, &values); err != nil {
return err
}
path := currentServerConfigPath("server-features.json")
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func currentServerConfigPath(name string) string {
candidates := []string{filepath.Join("config", name)}
if executable, err := os.Executable(); err == nil {
candidates = append(candidates,
filepath.Join(filepath.Dir(executable), "..", "config", name),
filepath.Join(filepath.Dir(executable), "config", name),
)
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate
}
}
return candidates[0]
}
func currentServerFeatures() currentServerFeatureSettings {
data, err := os.ReadFile(currentServerConfigPath("server-features.json"))
if err != nil {
return currentServerFeatureSettings{}
}
var settings currentServerFeatureSettings
if err := json.Unmarshal(data, &settings); err != nil {
return currentServerFeatureSettings{}
}
return settings
}
func currentActivityEnabled(key string) bool {
data, err := os.ReadFile(currentServerConfigPath("activity.json"))
if err != nil {
return true
}
var values map[string]bool
if err := json.Unmarshal(data, &values); err != nil {
return true
}
enabled, found := values[key]
if !found {
return true
}
return enabled
}
func currentConfiguredAllDeadReturnDelay() time.Duration {
const fallback = currentPartyDungeonAllDeadReturnDelay
settings := currentServerFeatures()
if settings.AllDeadReturnSeconds < 1 || settings.AllDeadReturnSeconds > 60 {
return fallback
}
return time.Duration(settings.AllDeadReturnSeconds) * time.Second
}
func currentConfiguredAllowFreeRevive() bool {
settings := currentServerFeatures()
if settings.AllowFreeRevive == nil {
return currentDungeonUseCoinAllowFreeRevive
}
return *settings.AllowFreeRevive
}
func currentConfiguredHellOrbChance() int {
settings := currentServerFeatures()
if settings.HellOrbChance < 0 || settings.HellOrbChance > currentDungeonHellOrbRollDenominator {
return currentDungeonHellOrbChance
}
return settings.HellOrbChance
}
func currentConfiguredHellDropChanceDenominator() int {
settings := currentServerFeatures()
if settings.HellDropChanceDenominator < 1 || settings.HellDropChanceDenominator > 1_000_000 {
return currentHellPartyDropChanceDenominator
}
return settings.HellDropChanceDenominator
}
func currentConfiguredAntonRaidAttackSeconds() int {
settings := currentServerFeatures()
if settings.AntonRaidAttackSeconds < 60 || settings.AntonRaidAttackSeconds > 24*60*60 {
return antonRaidAttackSeconds
}
return settings.AntonRaidAttackSeconds
}
func currentConfiguredLukeEnergyRecoveryPercent() int {
settings := currentServerFeatures()
if settings.LukeEnergyRecoveryPercent < 1 || settings.LukeEnergyRecoveryPercent > 1000 {
return 100
}
return settings.LukeEnergyRecoveryPercent
}
func currentConfiguredFiendWarSquadCount() int {
settings := currentServerFeatures()
if settings.FiendWarSquadCount < 1 || settings.FiendWarSquadCount > 8 {
return currentFiendWarSquadCount
}
return settings.FiendWarSquadCount
}
func currentConfiguredRoomFatigueCost() int64 {
settings := currentServerFeatures()
if settings.RoomFatigueCost < 0 || settings.RoomFatigueCost > 100 {
return currentDungeonRoomFatigueCost
}
return int64(settings.RoomFatigueCost)
}
func currentConfiguredDailyFatigueResetHour() int {
settings := currentServerFeatures()
if settings.DailyFatigueResetHour < 0 || settings.DailyFatigueResetHour > 23 {
return 6
}
return settings.DailyFatigueResetHour
}
func currentConfiguredAdventureGroupBonusPercent() int {
settings := currentServerFeatures()
if settings.AdventureGroupBonusPercent < 0 || settings.AdventureGroupBonusPercent > 1000 {
return currentDungeonAdventureGroupDefaultClearBonusPercent
}
return settings.AdventureGroupBonusPercent
}
func currentConfiguredRentalMaxReinforce() int {
settings := currentServerFeatures()
if settings.RentalMaxReinforce < 0 || settings.RentalMaxReinforce > 31 {
return currentRentalMaxReinforce
}
return settings.RentalMaxReinforce
}
func currentConfiguredRentalMaxForge() int {
settings := currentServerFeatures()
if settings.RentalMaxForge < 0 || settings.RentalMaxForge > 255 {
return currentRentalMaxForge
}
return settings.RentalMaxForge
}
func currentConfiguredDungeonReviveSeconds(dungeonID int64) (int, bool) {
if dungeonID <= 0 {
return 0, false
}
settings := currentServerFeatures()
if settings.ReviveByDungeon == nil {
return 0, false
}
value, found := settings.ReviveByDungeon[strconv.FormatInt(dungeonID, 10)]
if !found || value < 1 || value > 86400 {
return 0, false
}
return value, true
}
func currentConfiguredDungeonReviveDelay(dungeonID int64) time.Duration {
seconds, found := currentConfiguredDungeonReviveSeconds(dungeonID)
if !found {
return currentConfiguredRaidReviveDelay()
}
return time.Duration(seconds) * time.Second
}
func currentAntonDuration(configured int, fallback time.Duration) time.Duration {
if configured < 1 || configured > 86400 {
return fallback
}
return time.Duration(configured) * time.Second
}
func currentAntonBlackFogRecovery() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonBlackFogRecoverySeconds, antonBlackFogRecovery)
}
func currentAntonBlackFogActive() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonBlackFogActiveSeconds, antonBlackFogActive)
}
func currentAntonQuakeRecovery() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonQuakeRecoverySeconds, antonQuakeRecovery)
}
func currentAntonQuakeActive() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonQuakeActiveSeconds, antonQuakeActive)
}
func currentAntonNavalOpenDelay() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonNavalOpenDelaySeconds, antonNavalOpenDelay)
}
func currentAntonNavalRecovery() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonNavalRecoverySeconds, antonNavalRecovery)
}
func currentAntonNavalActive() time.Duration {
return currentAntonDuration(currentServerFeatures().AntonNavalActiveSeconds, antonNavalActive)
}
func currentLukeEnergyRecovery(tier int) time.Duration {
if tier < 0 {
tier = 0
}
if tier >= len(lukeEnergyControlRecovery) {
tier = len(lukeEnergyControlRecovery) - 1
}
settings := currentServerFeatures()
values := [...]int{
settings.LukeEnergyRecovery0Seconds,
settings.LukeEnergyRecovery1Seconds,
settings.LukeEnergyRecovery2Seconds,
}
if values[tier] >= 1 && values[tier] <= 86400 {
return time.Duration(values[tier]) * time.Second
}
percent := settings.LukeEnergyRecoveryPercent
if percent < 1 || percent > 1000 {
percent = 100
}
return time.Duration(int64(lukeEnergyControlRecovery[tier]) * int64(percent) / 100)
}
func currentLukePhaseTimeoutPercent() int {
percent := currentServerFeatures().LukePhaseTimeoutPercent
if percent < 1 || percent > 1000 {
return 100
}
return percent
}
func currentFiendWarAreaLimitPercent() int {
percent := currentServerFeatures().FiendWarAreaLimitPercent
if percent < 1 || percent > 1000 {
return 100
}
return percent
}
func currentFiendWarDieImprisonPercent() int {
percent := currentServerFeatures().FiendWarDieImprisonPercent
if percent < 1 || percent > 1000 {
return 100
}
return percent
}
func currentConfiguredSoloTeleportCooldownSeconds() uint32 {
seconds := currentServerFeatures().SoloTeleportCooldownSeconds
if seconds < 1 || seconds > 86400 {
return currentSoloTeleportCooldownSeconds
}
return uint32(seconds)
}
func currentConfiguredSoloTeleportIndices() []byte {
raw := strings.TrimSpace(currentServerFeatures().SoloTeleportCooldownIndices)
if raw == "" {
return nil
}
if strings.EqualFold(raw, "off") || strings.EqualFold(raw, "none") {
return []byte{}
}
var indices []byte
for _, field := range strings.Split(raw, ",") {
value, err := strconv.ParseUint(strings.TrimSpace(field), 10, 8)
if err == nil && currentSoloTeleportCooldownIndexAccepted(byte(value)) {
indices = append(indices, byte(value))
}
}
return indices
}
func currentConfiguredAuctionListingLimit(premium bool) int {
settings := currentServerFeatures()
value := settings.AuctionStandardListingLimit
if premium {
value = settings.AuctionPremiumListingLimit
}
if value < 1 || value > 1000 {
if premium {
return currentAuctionPremiumListingLimit
}
return currentAuctionStandardListingLimit
}
return value
}
func currentConfiguredAuctionDuration(premium bool) time.Duration {
settings := currentServerFeatures()
hours := settings.AuctionStandardDurationHours
if premium {
hours = settings.AuctionPremiumDurationHours
}
if hours < 1 || hours > 720 {
if premium {
return currentAuctionPremiumDuration
}
return currentAuctionStandardDuration
}
return time.Duration(hours) * time.Hour
}
func currentConfiguredAuctionFeePercent(premium bool) int64 {
settings := currentServerFeatures()
value := settings.AuctionStandardFeePercent
if premium {
value = settings.AuctionPremiumFeePercent
}
if value <= 0 || value > 100 {
return dnfauction.SaleFeePercentFor(premium)
}
return int64(value)
}
// currentConfiguredRaidReviveDelay reads the optional runtime/config/
// server-features.json written by the standalone server tool. It only changes
// the server-side death watchdog; the current EXE still owns its ten-second
// revive prompt.
func currentConfiguredRaidReviveDelay() time.Duration {
const fallback = currentDungeonDeathReturnDelay
settings := currentServerFeatures()
if settings.RaidReviveSeconds < 1 || settings.RaidReviveSeconds > 60 {
return fallback
}
return time.Duration(settings.RaidReviveSeconds) * time.Second
}
// currentConfiguredEquipmentRollMultiplier keeps the local test-end special
// drop tuning (2x equipment rolls) configurable by the standalone server tool.
func currentConfiguredEquipmentRollMultiplier() int {
settings := currentServerFeatures()
if settings.EquipmentRollMultiplier < 1 || settings.EquipmentRollMultiplier > 100 {
return 1
}
return settings.EquipmentRollMultiplier
}
// currentConfiguredEpicPieceRollMultiplier keeps the local test-end special
// drop tuning (3x epic fragment rolls) configurable by the standalone tool.
func currentConfiguredEpicPieceRollMultiplier() int {
settings := currentServerFeatures()
if settings.EpicPieceRollMultiplier < 1 || settings.EpicPieceRollMultiplier > 100 {
return 1
}
return settings.EpicPieceRollMultiplier
}
+41 -2
View File
@@ -85,6 +85,12 @@ type Service struct {
// 配置按账号落库(repos.Settings),运行态按副本冻结;这把锁只保护那一段
// "读当前 revision → 校验 → 写回"的临界区,避免并发保存把 revision 撞掉。
companionConfigMu sync.Mutex
// companionPresenceMu guards the synthetic sessions that expose a volunteer
// in the native friend list / party frame. These sessions have no socket and
// are deliberately NOT stored in gameSessions: only the social and party
// projection helpers below may resolve them.
companionPresenceMu sync.Mutex
companionSessions map[uint16]*gameSession
mu sync.Mutex
catalog *channelcatalog.Catalog
@@ -278,6 +284,16 @@ type Service struct {
gmFatigueMode string
gmBurningEnabled bool
gmGameplaySettingsAt time.Time
// gmActivitySwitches 是活动开关的运行时缓存(见 gm_activity_switches.go)。
gmActivitySwitchesMu sync.Mutex
gmActivitySwitchesReady bool
gmActivitySwitches map[string]bool
gmActivitySwitchesAt time.Time
// gmChannelSwitches 是频道开关(禁用频道 ID 集合)的运行时缓存(见 gm_channel_switches.go)。
gmChannelSwitchesMu sync.Mutex
gmChannelSwitchesReady bool
gmChannelSwitches map[int]bool
gmChannelSwitchesAt time.Time
automaticRobotAppearanceMu sync.Mutex
automaticRobotAvatarConfig *currentAvatarFittingRoomConfig
automaticRobotAvatarLoadErr error
@@ -1009,17 +1025,40 @@ func (s *Service) loadChannelAssets() (*channelcatalog.Catalog, []byte, error) {
if err != nil {
return nil, nil, fmt.Errorf("parse dnf channel info: %w", err)
}
catalog, err := channelcatalog.New(index, channelcatalog.Options{ServerID: s.channelServerID()})
disabled := s.loadDisabledChannelIDs()
catalog, err := channelcatalog.New(index, channelcatalog.Options{
ServerID: s.channelServerID(),
DisabledIDs: disabled,
})
if err != nil {
return nil, nil, err
}
script, err := s.buildChannelScript(raw)
script, err := s.buildChannelScriptFiltered(raw, disabled)
if err != nil {
return nil, nil, fmt.Errorf("build 90CN online channel script: %w", err)
}
return catalog, script, nil
}
// loadDisabledChannelIDs 读取已持久化的禁用频道集合。启动时 Settings 未就绪或读失败
// 时返回 nil(视为无禁用频道),不阻塞服务启动。
func (s *Service) loadDisabledChannelIDs() map[int]bool {
if s == nil {
return nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
record, found, err := repositories.Settings.Load(ctx, gmChannelSwitchesScope)
if err != nil || !found {
return nil
}
return gmDisabledChannelIDsFromRecord(record, true)
}
func (s *Service) buildChannelScript(raw []byte) ([]byte, error) {
if s.channelTableStrict() {
return channelinfo.Build90CNOnlineScript(
@@ -69,6 +69,21 @@ func currentSocketAvatarSocketTypes(catalog *pvfDungeonDropCatalog, itemID int64
return socketTypes, nil
}
// currentAvatarSocketTypesForOpen applies the avatar-part socket layout that
// the PVF's [avatar type select] does not encode explicitly. Rare coat/pants
// avatars carry one platinum (0x10) socket followed by two normal C sockets;
// their PVF section only lists the two C tags, so the opener used to produce a
// two-socket result. Keep other avatar parts and explicit three-socket layouts
// untouched.
func currentAvatarSocketTypesForOpen(rule currentEquipmentPlacementRule, socketTypes []byte) []byte {
out := append([]byte(nil), socketTypes...)
if (rule.targetSlot == 3 || rule.targetSlot == 4) &&
len(out) == 2 && out[0] == 0x04 && out[1] == 0x04 {
return []byte{0x10, 0x04, 0x04}
}
return out
}
func currentSocketEmblemType(catalog *pvfDungeonDropCatalog, itemID int64) byte {
if catalog == nil || itemID <= 0 || itemID > math.MaxUint32 {
return 0
@@ -68,6 +68,10 @@ func (s *Service) sendCurrentWeeklyAttendance(session *gameSession, claim, bonus
if s == nil || session == nil || session.selectedCharacterID == 0 {
return nil
}
// 活动开关:周周签到被关闭时不下发(客户端活动窗口不显示该活动)。
if !s.ActivitySwitchEnabled(context.Background(), gmActivityWeeklyKey) {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), currentSpendTimeWriteTimeout)
defer cancel()
c, err := s.currentWeeklyCatalog(ctx)