Compare commits

...

3 Commits

Author SHA1 Message Date
4df2c044db Merge branch 'main' of http://10.27.17.121:3000/kawagiri/TH1 into main 2026-05-29 17:57:28 +08:00
699daf23ed skill相关 2026-05-29 17:57:24 +08:00
0254e40afe 修复多语言空白行 2026-05-29 17:56:50 +08:00
15 changed files with 190 additions and 18 deletions

View File

@ -1,6 +1,6 @@
---
name: th1-network-sync
description: TH1 project-specific network synchronization guide for Unity C# multiplayer, Steam P2P, GameNetSender/GameNetReceiver, SteamLobbyManager, SimpleP2P, lobby MapConfig/MemberCiv player slot/team/AI/ready-state sync before game start, MapData/NetData recovery, action sync, host start/resume, ForceUpdate, heartbeat, ordered delivery, large message chunking, send-failure handling, and lobby UI rollback. Use whenever Codex works on TH1 networking, multiplayer rooms, member civ/ready/team/AI slot config, multiplayer saves, deterministic sync, P2P queueing, MapData broadcasts, ActionConfirm/ActionExecute, reconnect, Timer-driven network callbacks, or any bug that may affect multiplayer reliability or ordering.
description: TH1 project-specific network synchronization guide for Unity C# multiplayer, Steam P2P, GameNetSender/GameNetReceiver, SteamLobbyManager, SimpleP2P, lobby MapConfig/MemberCiv player slot/team/AI/ready-state sync before game start, MapData/NetData recovery and diagnostics, action sync, host start/resume, ForceUpdate, heartbeat, ordered delivery, large message chunking, send-failure handling, and lobby UI rollback. Use whenever Codex works on TH1 networking, multiplayer rooms, member civ/ready/team/AI slot config, multiplayer saves, deterministic sync, P2P queueing, MapData broadcasts or MapDataDebug diff tools, ActionConfirm/ActionExecute, reconnect, Timer-driven network callbacks, or any bug that may affect multiplayer reliability or ordering.
---
# TH1 Network Sync
@ -24,7 +24,7 @@ Do not let one peer advance local game state unless the matching network send/re
- `Unity/Assets/Scripts/TH1_Logic/Core/Main.cs`
- `Unity/Assets/Scripts/TH1_Logic/Action/ActionLogic.cs`
- `Unity/Assets/Scripts/TH1_Instance/Timer.cs`
- `Unity/Assets/Scripts/TH1_Logic/Editor/NetworkStressEditorWindow.cs` when changing or interpreting network stress tests
- `Unity/Assets/Scripts/TH1_Logic/Editor/NetworkStressEditorWindow.cs` when changing or interpreting network stress tests or the `Tools/Steam MapData一致性诊断` editor window
For the current network contract summary, read `references/network-contract.md`.
For the one-click Steam P2P stress tool, report fields, and current healthy baseline, read `references/network-stress.md`.
@ -89,6 +89,12 @@ For the one-click Steam P2P stress tool, report fields, and current healthy base
- Keep per-run `RunId` isolation so stale packets or ACKs from a previous test cannot pollute a new report.
- The host should export after collecting client reports or after the configured report wait timeout; clients should retry report sends briefly after test completion.
10. Use `MapDataDebugMessage` for pre-reconnect divergence captures.
- `P2PMsgType.MapDataDebug` carries current `MapData` only for diagnostics: member-to-host or host-broadcast.
- The receiver compares the incoming map against local `Main.MapData` and logs `[MapDataDebug]`; it must not call `NetResumeMatch`, change `GameState`, request `ForceUpdate`, or mutate gameplay state.
- The editor entry is `Tools/Steam MapData一致性诊断`; use it when normal `ForceUpdate` diff logs are too late because the host has already advanced.
- Keep the send path on `GameNetSender` / lobby queues so large-map diagnostics still cover ordered P2P chunking.
## Checks Before Finishing
Run:
@ -113,6 +119,7 @@ For network-heavy changes, inspect these risks explicitly:
- Could code reintroduce the old assumption that `MultiCivs.Count == current lobby member count`?
- Could `MemberCiv.Index`, `PlayerId`, or `TeamId` diverge between host and clients before `GameStart`?
- Could `MapData` deserialize with missing core fields and still be used?
- Could `MapDataDebugMessage` accidentally trigger reconnect, ForceUpdate, or any map mutation?
- Could a timer callback fire after the target UI/object state is gone?
- Could a retry loop or ordered gap wait forever?
- If stress tooling or queue throughput changed, does the one-click two-machine report meet the baseline in `references/network-stress.md`?
@ -128,3 +135,4 @@ For network-heavy changes, inspect these risks explicitly:
- Do not derive player position from lobby member order after slots exist; use `MemberCiv.Index`.
- Do not start the host game from client-local optimistic ready state.
- Do not call `GC.Collect()` in match entry paths as a networking fix.
- Do not use `MapDataDebugMessage` as a repair/sync mechanism; it is logging-only diagnostics.

View File

@ -1,4 +1,4 @@
interface:
display_name: "TH1 Network Sync"
short_description: "TH1 multiplayer sync, P2P, and lobby slot rules"
default_prompt: "Use the TH1 Network Sync skill for TH1 multiplayer, P2P, GameNet, lobby player slot/team config, save resume, reconnect, or deterministic sync work."
short_description: "TH1 multiplayer sync, P2P, MapData diagnostics, and lobby slot rules"
default_prompt: "Use the TH1 Network Sync skill for TH1 multiplayer, P2P, GameNet, lobby player slot/team config, MapData diagnostics, save resume, reconnect, or deterministic sync work."

View File

@ -31,6 +31,7 @@ This reference summarizes the multiplayer contract after the May 2026 pre-releas
- `ActionConfirm` must return send success.
- `ActionExecute` must return broadcast success.
- `ForceUpdate` and full `MapData` sends must validate multiplayer map data before sending.
- `MapDataDebugMessage` sends current `MapData` for diagnostics only. Member-to-host and host-broadcast sends should use `GameNetSender` and the normal lobby queue path, but callers must not treat it as authoritative sync.
- Heartbeat send timestamps should only update after send acceptance.
## GameNetReceiver
@ -43,6 +44,7 @@ This reference summarizes the multiplayer contract after the May 2026 pre-releas
- `NetStartGame` and `NetResumeMatch` return `bool`; UI should only close/hide after success.
- `ForceUpdate` should restore previous game state if resume fails.
- `MapConfirm` must guard null maps, missing actions, and null action payloads.
- `MapDataDebugMessage` should compare incoming `MapData` with local `Main.MapData` and log `[MapDataDebug]` hash/action/diff details only. It must not call `NetResumeMatch`, change `GameState`, request `ForceUpdate`, or replace local map data.
## Network Stress Tool
@ -54,6 +56,19 @@ This reference summarizes the multiplayer contract after the May 2026 pre-releas
- Host export should contain one report per lobby member when clients are reachable. If a report is late, host may update the same export file after receipt.
- Current default test is a one-minute flow: 50 seconds of traffic and up to 10 seconds of report collection.
## MapData Debug Tool
- Tool path: `Unity/Assets/Scripts/TH1_Logic/Editor/NetworkStressEditorWindow.cs`.
- Menu: `Tools/Steam MapData一致性诊断`.
- Diagnostics message: `MapDataDebugMessage` / `P2PMsgType.MapDataDebug`.
- Use when `MapConfirm`/`ForceUpdate` logs are too late to reveal the original divergence. The tool captures the sender's current `MapData` at button click time.
- Buttons:
- Members send current `MapData` to host.
- Host broadcasts current `MapData` to all members.
- Receiver logs whether local and remote maps match, map hashes, action counts, first action mismatch, and diff details.
- The tool is read-only from a gameplay perspective. It must not mutate `Main.MapData`, request reconnect, enter `ForceUpdating`, or hide/show gameplay UI.
- It should continue to use `GameNetSender` / lobby P2P queues so full-map diagnostics exercise ordered delivery and large-message chunking.
## Lobby MapConfig / Ready State
- Pre-game room settings and member state live in `Main.Instance.MapConfig`.

View File

@ -5,9 +5,9 @@
## 使用
1. 运行 `启动玩家Bug查看器.bat`
2. 填写 OSS `AccessKey ID` / `AccessKey Secret`,确认 `Endpoint``Bucket`
2. 工具会自动读取 Unity OSS 编辑器保存的 `AccessKey ID` / `AccessKey Secret`,确认 `Endpoint``Bucket`
3. 点击「更新内容」拉取 `bugreport/` 下的 zip。
4. 选择条目查看玩家自述、版本、SteamID、CrashSight 设备 ID、设备信息、附带存档。
5. 点击「一键替换到本地存档」会删除目标目录下所有 `map_archive_*.dat` / `.bak`,再写入该汇报中的 `start + continue/end` 存档。
配置会保存到 `config.local.json`,该文件应只保留在本机。
也可以用 `config.local.json` 或环境变量覆盖 OSS 配置,该文件应只保留在本机。

View File

@ -0,0 +1,8 @@
{
"access_key_id": "LTAI5t7x5WJDtoFNpxDq4MWh",
"access_key_secret": "fBDtyz7Z38B7rvRXHNzqVflw6LThXX",
"endpoint": "oss-cn-shanghai.aliyuncs.com",
"bucket": "th1-oss",
"save_config_dir": "C:\\Users\\wuwenbo\\AppData\\LocalLow\\Remilia Command\\Config",
"skip_existing_downloads": true
}

View File

@ -5,6 +5,7 @@ import hmac
import json
import os
import shutil
import sys
import threading
import urllib.parse
import urllib.request
@ -17,6 +18,12 @@ from tkinter import ttk
APP_DIR = Path(__file__).resolve().parent
TOOLS_DIR = APP_DIR.parent
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
from oss_viewer_config import merge_default_oss_config, merge_local_oss_config
DATA_DIR = APP_DIR / "Data"
CONFIG_PATH = APP_DIR / "config.local.json"
OSS_PREFIX = "bugreport/"
@ -39,10 +46,10 @@ def default_config() -> dict:
def load_config() -> dict:
cfg = default_config()
cfg = merge_default_oss_config(default_config())
if CONFIG_PATH.exists():
try:
cfg.update(json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
cfg = merge_local_oss_config(cfg, json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
except Exception:
pass
return cfg

View File

@ -5,8 +5,8 @@
## 使用
1. 运行 `启动多语言汇报查看器.bat`
2. 填写 OSS `AccessKey ID` / `AccessKey Secret`,确认 `Endpoint``Bucket`
2. 工具会自动读取 Unity OSS 编辑器保存的 `AccessKey ID` / `AccessKey Secret`,确认 `Endpoint``Bucket`
3. 点击「更新内容」拉取 `multilingualreport/` 下的 zip。
4. 选择条目查看多语言 ID、玩家选择的字符串、玩家自述、当前解析文本、版本、SteamID、CrashSight 设备 ID 和设备信息。
配置会保存到 `config.local.json`本地下载缓存放在 `Data/`,两者都只应保留在本机。
也可以用 `config.local.json` 或环境变量覆盖 OSS 配置。本地下载缓存放在 `Data/`,两者都只应保留在本机。

View File

@ -0,0 +1,7 @@
{
"access_key_id": "LTAI5t7x5WJDtoFNpxDq4MWh",
"access_key_secret": "fBDtyz7Z38B7rvRXHNzqVflw6LThXX",
"endpoint": "oss-cn-shanghai.aliyuncs.com",
"bucket": "th1-oss",
"skip_existing_downloads": true
}

View File

@ -4,6 +4,7 @@ import hashlib
import hmac
import json
import os
import sys
import threading
import urllib.parse
import urllib.request
@ -16,6 +17,12 @@ from tkinter import ttk
APP_DIR = Path(__file__).resolve().parent
TOOLS_DIR = APP_DIR.parent
if str(TOOLS_DIR) not in sys.path:
sys.path.insert(0, str(TOOLS_DIR))
from oss_viewer_config import merge_default_oss_config, merge_local_oss_config
DATA_DIR = APP_DIR / "Data"
CONFIG_PATH = APP_DIR / "config.local.json"
OSS_PREFIX = "multilingualreport/"
@ -32,10 +39,10 @@ def default_config() -> dict:
def load_config() -> dict:
cfg = default_config()
cfg = merge_default_oss_config(default_config())
if CONFIG_PATH.exists():
try:
cfg.update(json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
cfg = merge_local_oss_config(cfg, json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
except Exception:
pass
return cfg

110
Tools/oss_viewer_config.py Normal file
View File

@ -0,0 +1,110 @@
from __future__ import annotations
import os
from typing import Any
UNITY_EDITOR_PREFS_REG_PATH = r"Software\Unity Technologies\Unity Editor 5.x"
UNITY_OSS_PREF_KEYS = {
"access_key_id": "OssDownload_AccessKeyId",
"access_key_secret": "OssDownload_AccessKeySecret",
"endpoint": "OssDownload_Endpoint",
"bucket": "OssDownload_Bucket",
}
ENV_OSS_KEYS = {
"access_key_id": (
"TH1_OSS_ACCESS_KEY_ID",
"OSS_ACCESS_KEY_ID",
"ALIYUN_ACCESS_KEY_ID",
"ALIBABA_CLOUD_ACCESS_KEY_ID",
),
"access_key_secret": (
"TH1_OSS_ACCESS_KEY_SECRET",
"OSS_ACCESS_KEY_SECRET",
"ALIYUN_ACCESS_KEY_SECRET",
"ALIBABA_CLOUD_ACCESS_KEY_SECRET",
),
"endpoint": ("TH1_OSS_ENDPOINT", "OSS_ENDPOINT"),
"bucket": ("TH1_OSS_BUCKET", "OSS_BUCKET"),
}
def _clean_string(value: str) -> str:
return value.replace("\x00", "").strip()
def _decode_editor_prefs_value(value: Any) -> str:
if isinstance(value, bytes):
raw = value.split(b"\x00", 1)[0]
for encoding in ("utf-8", "mbcs", "latin-1"):
try:
return _clean_string(raw.decode(encoding))
except Exception:
pass
return ""
if isinstance(value, str):
return _clean_string(value)
return ""
def _read_windows_unity_pref(pref_key: str) -> str:
try:
import winreg
except Exception:
return ""
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, UNITY_EDITOR_PREFS_REG_PATH) as key:
index = 0
while True:
try:
name, value, _value_type = winreg.EnumValue(key, index)
except OSError:
break
index += 1
if name == pref_key or name.startswith(f"{pref_key}_h"):
decoded = _decode_editor_prefs_value(value)
if decoded:
return decoded
except OSError:
return ""
return ""
def load_unity_oss_editor_prefs() -> dict[str, str]:
config: dict[str, str] = {}
for field, pref_key in UNITY_OSS_PREF_KEYS.items():
value = _read_windows_unity_pref(pref_key)
if value:
config[field] = value
return config
def load_env_oss_config() -> dict[str, str]:
config: dict[str, str] = {}
for field, env_names in ENV_OSS_KEYS.items():
for env_name in env_names:
value = os.environ.get(env_name, "").strip()
if value:
config[field] = value
break
return config
def merge_default_oss_config(config: dict[str, Any]) -> dict[str, Any]:
merged = dict(config)
merged.update(load_unity_oss_editor_prefs())
merged.update(load_env_oss_config())
return merged
def merge_local_oss_config(config: dict[str, Any], local_config: dict[str, Any]) -> dict[str, Any]:
merged = dict(config)
for key, value in local_config.items():
if key in UNITY_OSS_PREF_KEYS and isinstance(value, str) and not value.strip():
continue
merged[key] = value
return merged

View File

@ -244,7 +244,7 @@ namespace Logic.Editor
}
ExportMatchLevelData();
_asset.RefreshDict();
_asset.RefreshDict(true);
foreach (var kv in _zhStrDict)
{
if (_asset.ItemDict.ContainsKey(kv.Value)) continue;
@ -262,6 +262,7 @@ namespace Logic.Editor
}
_asset.Items = _asset.Items.OrderBy(i => i.ID).ToList();
_asset.RefreshDict(true);
string filePath = MultilingualTxtPath;
Directory.CreateDirectory(Path.GetDirectoryName(filePath));
@ -1326,7 +1327,7 @@ namespace Logic.Editor
// _zhStrDict[kv.Key] = kv.Value;
// }
_asset.RefreshDict();
_asset.RefreshDict(true);
foreach (var kv in _zhStrDict)
{
if (_asset.ItemDict.ContainsKey(kv.Value)) continue;
@ -1345,6 +1346,7 @@ namespace Logic.Editor
// 排序 asset.items 保证id从小到大
_asset.Items = _asset.Items.OrderBy(i => i.ID).ToList();
_asset.RefreshDict(true);
string filePath = MultilingualTxtPath;
Directory.CreateDirectory(Path.GetDirectoryName(filePath));

View File

@ -85,7 +85,11 @@ namespace Logic.Multilingual
{
if (_itemDict == null) RefreshDict();
if (_itemDict == null) return string.Empty;
if (!_itemDict.TryGetValue(id, out var item)) return string.Empty;
if (!_itemDict.TryGetValue(id, out var item))
{
RefreshDict(true);
if (!_itemDict.TryGetValue(id, out item)) return string.Empty;
}
var ret = item.GetStrByType(type);
// Fallback 链:
@ -163,10 +167,10 @@ namespace Logic.Multilingual
return 0;
}
public void RefreshDict()
public void RefreshDict(bool force = false)
{
if (_itemDict == null) _itemDict = new Dictionary<uint, MultilingualItem>();
if (_itemDict.Count == Items.Count) return;
if (!force && _itemDict.Count == Items.Count) return;
_itemDict.Clear();
foreach (var item in Items) _itemDict[item.ID] = item;
}
@ -182,7 +186,11 @@ namespace Logic.Multilingual
{
if (_itemDict == null) RefreshDict();
if (_itemDict == null) return string.Empty;
if (!_itemDict.TryGetValue(id, out var item)) return string.Empty;
if (!_itemDict.TryGetValue(id, out var item))
{
RefreshDict(true);
if (!_itemDict.TryGetValue(id, out item)) return string.Empty;
}
var ret = item.GetStrByType(type);
if (string.IsNullOrEmpty(ret)) return ret;