Compare commits
3 Commits
7237c94785
...
4df2c044db
| Author | SHA1 | Date | |
|---|---|---|---|
| 4df2c044db | |||
| 699daf23ed | |||
| 0254e40afe |
@ -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.
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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`.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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 配置,该文件应只保留在本机。
|
||||
|
||||
8
Tools/PlayerBugViewer/config.local.json
Normal file
8
Tools/PlayerBugViewer/config.local.json
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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/`,两者都只应保留在本机。
|
||||
|
||||
7
Tools/PlayerMultilingualReportViewer/config.local.json
Normal file
7
Tools/PlayerMultilingualReportViewer/config.local.json
Normal 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
|
||||
}
|
||||
@ -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
110
Tools/oss_viewer_config.py
Normal 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
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user