This commit is contained in:
daixiawu 2026-06-08 19:47:06 +08:00
commit c7cea8600e
7 changed files with 123 additions and 124 deletions

View File

@ -104,6 +104,7 @@ AI 的最终执行仍然走 action 层:
多人行为不要绕过 `CompleteExecute`
- 判断本机是否能执行当前回合 action 时,优先使用 `MapData.CanLocalControlPlayer(playerId)`,不要只比较 `CurPlayer == PlayerMap.SelfPlayerData`。单机/房主可以控制多个真实 player 时,`SelfPlayerId` 会为旧 UI/input 临时切到当前本机可控玩家;非本机可控回合应恢复默认本机 player避免资源、投降、存活等待等 UI 看错对象。
- 改 `CheckIsRealPlayer` / `CheckIsAI` 相关逻辑时,不要只看 `Net.Players` 或当前 `SelfPlayerId`。联机额外 host-controlled player 是真实玩家但不会进入 `Net.Players`,单机 slot 0 也不是 `IsHostControlled` 但仍是真实本机玩家。
- Host 本地 action`CompleteExecute``ActionExecute` 广播,广播失败则不执行本地 mutation。
- Client 本地 action`CompleteExecute` 发送 `ActionConfirm` 给 host发送失败不执行。本地 `TurnEnd` 发送后直接返回 false等待 host 广播。
- Host 收到 `ActionConfirm`:刷新 params校验当前玩家调用 `CompleteExecute`,再广播 `ActionExcuteMessage` 并执行。

View File

@ -44,33 +44,37 @@ Archive files are grouped by folder:
`Quick` uses fixed record id and file id `quick`. Manual/end/begin ids are GUID-like strings from `GameArchiveManager`.
Record persistence:
Record archive discard index:
- `Manual` and `Quick` records are persistent by default and should stay persistent after old data migration.
- `Ended` records are non-persistent by default; only explicit user/UI action should persist them.
- Append new `GameRecord` fields at the end only. `IsPersistent` belongs after archive id fields.
- All `MapData` files are stored by default when the normal begin/quick_continue/continue/end flow writes them.
- `GameRecord.DiscardArchiveIndex` is used by cleaned `Ended` records so they stop protecting begin/end archive files during map cleanup.
- `Manual` cleanup deletes the record; it does not need a discard flag.
- `Quick` cleanup is a no-op through the user-facing cleanup interface.
- Append new `GameRecord` fields at the end only. If an old serialized field is removed from business logic, keep a serialization placeholder so later fields do not shift.
## Write Flow
- New single-player game: write begin after map generation, before first turn refresh.
- New multiplayer host game: write begin only after `GameStart` broadcast succeeds.
- New multiplayer member game: write a local begin after `NetStartGame` succeeds. This supports local `Ended` records and replay for players who were present from game start.
- Ended game: `FinishState.Enter` calls `GameArchiveManager.SaveEndRecord(Main.MapData)`.
- Per-turn quick save: `MapData.RefreshTurn` calls `SaveQuickContinueRecord`.
- Manual save: UI or upper layer should call `GameArchiveManager.SaveManualGameRecord(recordName)`.
- Record delete: call `GameArchiveManager.DeleteGameRecord(record)` for any non-`Quick` record. This must only remove the record index, not archive files.
- Record cleanup: call `GameArchiveManager.CleanupGameRecord(record)`. This must only update/delete the record, not archive files.
Do not save archives directly from surrender action. Surrender should mutate game state through the action flow; `AfterExecute` refreshes settlement, `GameLogic.Update` enters `Finished`, and `FinishState.Enter` writes end and clears quick.
`SaveEndRecord` writes end, creates an `Ended` record except for Tutor, and deletes the current begin's quick record/file. Manual saves survive game end until the player deletes them.
## Record Cleanup
## Record And Map Cleanup
Keep record operations and archive-file operations separate:
- `GameArchiveManager.SetGameRecordPersistent(record, bool)` may only change `Ended` records.
- `GameArchiveManager.DeleteGameRecord(record)` may delete `Manual` or `Ended` records, never `Quick`, and must not delete `MapData` files.
- `GameArchiveManager.CleanupNonPersistentGameRecords()` removes non-persistent records only. With defaults, this mostly cleans unpinned `Ended` records.
- `GameArchiveManager.CleanupUnlinkedLocalMapData()` removes local `MapData` files not referenced by any remaining record.
- `GameArchiveManager.CleanupGameRecord(record)` is the only user-facing record cleanup flow:
- `Quick`: no-op, return false.
- `Manual`: delete the record only; do not delete `MapData` files here.
- `Ended`: keep the record but set `DiscardArchiveIndex = true`; do not delete `MapData` files here.
- `GameArchiveManager.CleanupUnlinkedLocalMapData()` is the only map-file cleanup flow. It builds the valid archive index set from records, skips `Ended` records with `DiscardArchiveIndex == true`, and deletes local `MapData` files not referenced by the valid index.
Archive cleanup should cover both current and legacy local data:

View File

@ -63,9 +63,10 @@ For the one-click Steam P2P stress tool, report fields, and current healthy base
- Pre-game room settings, `MemberCiv`, player slot/team/AI flags, and ready state live in `Main.Instance.MapConfig`.
- `MapConfig.MultiCivs` is now the full player-slot list and should be sized to `PlayerCount`; do not treat it as only current lobby members.
- Each `MemberCiv` slot uses `Index` as the stable player position. `MemberId != 0` means a real member is bound, `MemberId == 0 && IsAI` means an AI slot, `MemberId == 0 && !IsAI && IsHostControlled` means a host/local-controlled real player slot, and `TeamId == 0` means no team.
- Host-controlled slots are real players, not AI and not lobby members. Keep `IsReady=false`, do not add them to `Net.Players`, skip them when assigning/moving real lobby members, and allow them in `AreAllLobbyMembersReady()`.
- Host-controlled slots are extra real players controlled by the local player/host; the singleplayer main slot and multiplayer lobby owner slot themselves are not `IsHostControlled`. Keep host-controlled slots `IsReady=false`, do not add them to `Net.Players`, skip them when assigning/moving real lobby members, and allow them in `AreAllLobbyMembersReady()`.
- `Net.RefreshPlayerNet(mapData)` maps only current lobby `MemberId` values to `PlayerId`; host-controlled slots must not require a Steam member mapping or duplicate the host's mapping.
- Local control should go through `MapData.CanLocalControlPlayer(...)`: in singleplayer all real local slots are controllable; in multiplayer the member's mapped slot is controllable, and only the lobby owner may also control `IsHostControlled` slots. When switching turn identity for UI/input, restore the default local member slot on non-local turns.
- `MapData.CheckIsRealPlayer(...)` must treat `MapConfig.IsRealPlayerSlot(...)` as meaningful before relying on `Net.Players`: normal lobby players are covered by `Net.Players`, but host-controlled extra real players are intentionally absent from `Net.Players` and would otherwise be misclassified as AI.
- Local control should go through `MapData.CanLocalControlPlayer(...)`: in singleplayer slot 0 plus `IsHostControlled` extra slots are controllable; in multiplayer the member's mapped slot is controllable, and only the lobby owner may also control `IsHostControlled` slots. When switching turn identity for UI/input, restore the default local member slot on non-local turns.
- Call `MapConfig.EnsurePlayerSlots(NetMode.Multi)` before reading or mutating lobby slots, especially after changing `PlayerCount` or receiving host config.
- Clients may optimistically update their own `MemberCiv` only after `ChangeCiv` send succeeds, then still accept host `UpdateLobbyData` as authority.
- Clients entering a room should request host lobby data until current lobby members match `MapConfig.MultiCivs`.

View File

@ -251,15 +251,15 @@ namespace RuntimeData
if (MultiCivs.Count == 0) return;
var selfSlot = MultiCivs[0];
PrepareSinglePlayerSlot(selfSlot, index: 0, isAI: false);
selfSlot.IsHostControlled = true;
PrepareSinglePlayerSlot(selfSlot, index: 0, isAI: false, isHostControlled: false);
selfSlot.IsCivFixed = true;
var usedCivs = new HashSet<uint> { selfSlot.CivId };
for (int i = 1; i < MultiCivs.Count; i++)
{
var slot = MultiCivs[i];
PrepareSinglePlayerSlot(slot, i, isAI: true);
var isHostControlled = slot.MemberId == 0 && !slot.IsAI && slot.IsHostControlled;
PrepareSinglePlayerSlot(slot, i, isAI: !isHostControlled, isHostControlled: isHostControlled);
if (!slot.IsCivFixed || usedCivs.Contains(slot.CivId))
{
var civId = PickDefaultCivId(i, usedCivs);
@ -271,7 +271,7 @@ namespace RuntimeData
}
}
private static void PrepareSinglePlayerSlot(MemberCiv slot, int index, bool isAI)
private static void PrepareSinglePlayerSlot(MemberCiv slot, int index, bool isAI, bool isHostControlled = false)
{
slot.Index = index;
slot.MemberId = 0;
@ -279,7 +279,7 @@ namespace RuntimeData
slot.PlayerId = 0;
slot.TeamId = NoTeamId;
slot.IsAI = isAI;
slot.IsHostControlled = !isAI;
slot.IsHostControlled = !isAI && isHostControlled;
}
private static uint PickDefaultCivId(int preferredIndex, HashSet<uint> usedCivs)
@ -469,6 +469,7 @@ namespace RuntimeData
return false;
var slot = GetPlayerSlot(index, netMode);
if (slot == null) return false;
if (netMode == NetMode.Single && slot.Index == 0 && isHostControlled) return false;
if (isHostControlled)
{
if (slot.MemberId != 0) return false;
@ -483,6 +484,7 @@ namespace RuntimeData
if (!slot.IsHostControlled) return false;
slot.IsHostControlled = false;
if (netMode == NetMode.Single && slot.MemberId == 0 && slot.Index != 0) slot.IsAI = true;
return true;
}
@ -502,14 +504,16 @@ namespace RuntimeData
var slot = GetPlayerSlot(index, NetMode.Single);
if (slot == null) return false;
var isAI = index != 0;
var isHostControlled = index != 0 && slot.MemberId == 0 && !slot.IsAI && slot.IsHostControlled;
var isAI = index != 0 && !isHostControlled;
var changed = slot.CivId != civId
|| slot.ForceId != forceId
|| slot.IsAI != isAI
|| slot.IsHostControlled != isHostControlled
|| !slot.IsCivFixed
|| slot.MemberId != 0;
PrepareSinglePlayerSlot(slot, index, isAI);
PrepareSinglePlayerSlot(slot, index, isAI, isHostControlled);
slot.CivId = civId;
slot.ForceId = forceId;
slot.IsCivFixed = true;
@ -574,6 +578,30 @@ namespace RuntimeData
return false;
}
public bool HasHostControlledPlayer()
{
if (MultiCivs == null) return false;
foreach (var slot in MultiCivs)
{
if (slot == null) continue;
if (slot.MemberId == 0 && !slot.IsAI && slot.IsHostControlled) return true;
}
return false;
}
public bool IsSingleLocalControlledPlayer(uint playerId)
{
if (playerId == 0 || MultiCivs == null) return false;
foreach (var slot in MultiCivs)
{
if (slot == null || slot.PlayerId != playerId) continue;
return slot.Index == 0 || slot.IsHostControlled;
}
return false;
}
public bool IsRealPlayerSlot(uint playerId)
{
if (playerId == 0 || MultiCivs == null) return false;
@ -2367,6 +2395,7 @@ namespace RuntimeData
var versionInfo = ConfigManager.Instance.VersionCfg?.CurVersionInfo;
gameRecord.GameVersion = versionInfo?.FullVersion ?? Application.version;
gameRecord.GameVersionId = versionInfo?.VersionId ?? 0;
gameRecord.HasHostControlledPlayer = MapConfig?.HasHostControlledPlayer() ?? false;
return gameRecord;
}
@ -2636,8 +2665,14 @@ namespace RuntimeData
public bool CheckIsRealPlayer(uint playerId)
{
if (playerId == 0) return false;
if (Net?.Mode == NetMode.Single)
{
if (MapConfig != null && MapConfig.IsSingleLocalControlledPlayer(playerId)) return true;
return playerId == PlayerMap.SelfPlayerId;
}
if (MapConfig != null && MapConfig.IsRealPlayerSlot(playerId)) return true;
if (Net.Mode == NetMode.Multi)
if (Net?.Mode == NetMode.Multi)
{
foreach (var kv in Net.Players)
{
@ -2668,7 +2703,7 @@ namespace RuntimeData
}
if (Net?.Mode == NetMode.Single)
return MapConfig?.IsRealPlayerSlot(playerId) ?? playerId == PlayerMap.SelfPlayerId;
return MapConfig?.IsSingleLocalControlledPlayer(playerId) ?? playerId == PlayerMap.SelfPlayerId;
return playerId == PlayerMap.SelfPlayerId;
}

View File

@ -579,6 +579,14 @@ namespace TH1_Logic.Core
EventManager.Publish(announcement);
//UIManager.Instance.CenterMessageUI.SetCenterMessageShow(UICenterMessageID.StartGame,MapData.PlayerMap.SelfPlayerData);
},1.5f,"Main_CenterMessage_Anim");
// 成员端也要保存本机 begin。之后本机游戏结束会写 end + Ended record
// 回放需要这条 record 同时索引到开局快照和结束快照。
if (!GameArchiveManager.Instance.SaveBeginArchive(MapData))
{
LogSystem.LogWarning("NetStartGame: 成员端保存 begin 存档失败,之后本机 Ended record 可能无法回放");
}
LogSystem.LogInfo($"NetStartGame : {NetData.GetMapDataHash(MapData)}");
return true;
}

View File

@ -112,7 +112,7 @@ namespace TH1_Logic.GameArchive
// 通用存档规则:
// - 可以有任意多条,所以每条 continue 文件都使用新的 archiveId。
// - recordName 是玩家自定义名字;空名字会归一成“手动存档”。
// - 这里只创建,不自动删除;删除必须走 DeleteManualContinueRecord。
// - 这里只创建,不自动删除;清理必须走 CleanupGameRecord。
public bool SaveManualContinueRecord(MapData map, string recordName)
{
if (!EnsureCurrentBeginArchive(map)) return false;
@ -207,6 +207,8 @@ namespace TH1_Logic.GameArchive
{
mapData = null;
if (record == null) return false;
// Ended 清理后 record 仍可作为历史展示行存在,但不再拥有 begin/end 文件索引。
if (record.RecordKind == GameRecordKind.Ended && record.DiscardArchiveIndex) return false;
if (record.NetMode != NetMode.Single && record.NetMode != NetMode.Multi) return false;
if (string.IsNullOrEmpty(record.BeginArchiveId)) return false;
@ -219,6 +221,8 @@ namespace TH1_Logic.GameArchive
{
mapData = null;
if (record == null) return false;
// 与 map 清理规则保持一致:丢弃索引的 Ended record 不能再读取 end 文件。
if (record.RecordKind == GameRecordKind.Ended && record.DiscardArchiveIndex) return false;
if (record.NetMode != NetMode.Single && record.NetMode != NetMode.Multi) return false;
if (string.IsNullOrEmpty(record.EndArchiveId)) return false;
@ -267,31 +271,6 @@ namespace TH1_Logic.GameArchive
_currentBeginArchiveId = beginArchiveId;
}
// 玩家主动删除通用存档的上层接口。
// 这里只允许删除 Manual recordQuick 和 Ended 不通过这个接口删除。
public bool DeleteManualGameRecord(GameRecord record)
{
return DeleteManualContinueRecord(record);
}
// 删除玩家手动通用存档。
//
// 删除顺序刻意是先删 record再删文件
// - 如果传进来的 record 不在 GameRecordData 里,不会误删磁盘文件。
// - 如果文件删除失败record 已经移除,之后 UI 不会继续显示一条坏记录。
public bool DeleteManualContinueRecord(GameRecord record)
{
if (record == null || record.RecordKind != GameRecordKind.Manual) return false;
if (!GameRecordManager.Instance.RemoveRecord(record)) return false;
if (!string.IsNullOrEmpty(record.ContinueArchiveId))
{
DeleteArchive(GameArchiveFileKind.Continue, record.ContinueArchiveId);
}
return true;
}
// 删除快速存档 record 和 quick.dat。
//
// beginArchiveId 不为空时,只允许删除同一局的 quick。
@ -310,27 +289,35 @@ namespace TH1_Logic.GameArchive
return GameRecordManager.Instance.RemoveQuickRecord(beginArchiveId);
}
// 设置 Ended record 是否在清理时保留。Manual/Quick 默认持久化,不通过这个接口改。
public bool SetGameRecordPersistent(GameRecord record, bool isPersistent)
// 用户主动清理指定 record。这里只动 record 状态/索引,不清理 MapData 文件。
//
// - Quick自动快速存档由回合保存和游戏结束流程维护这个接口不动它。
// - Manual直接删除 record对应 continue/begin 文件等下一阶段无引用清理再回收。
// - Ended保留历史展示行只丢弃 begin/end 索引,等待 map 清理回收文件。
public bool CleanupGameRecord(GameRecord record)
{
if (record == null || record.RecordKind != GameRecordKind.Ended) return false;
return GameRecordManager.Instance.SetRecordPersistent(record, isPersistent);
if (record == null) return false;
switch (record.RecordKind)
{
case GameRecordKind.Quick:
// Quick 是唯一自动存档,用户清理 record 时不删除、不标记。
return false;
case GameRecordKind.Manual:
// Manual 本身就是可继续入口;删除 record 后它自然不再保护任何 map 文件。
return GameRecordManager.Instance.RemoveRecord(record);
case GameRecordKind.Ended:
// Ended 需要继续展示战绩,所以只让它停止保护 begin/end 文件。
return GameRecordManager.Instance.DiscardEndedRecordArchiveIndex(record);
default:
return false;
}
}
// 删除非 Quick record。这里只动 record不清理 MapData 文件。
public bool DeleteGameRecord(GameRecord record)
{
if (record == null || record.RecordKind == GameRecordKind.Quick) return false;
return GameRecordManager.Instance.RemoveRecord(record);
}
// 清理所有未持久化 record。这里只动 record不清理 MapData 文件。
public int CleanupNonPersistentGameRecords()
{
return GameRecordManager.Instance.RemoveNonPersistentRecords();
}
// 删除所有没有 record 链接的本地 MapData 文件。
// 第二阶段 map 清理:删除所有没有有效 record 索引保护的本地 MapData 文件。
//
// Manual 清理会先删除 record所以这里天然不会再看到它
// Ended 清理只标记 DiscardArchiveIndex所以 BuildReferencedArchiveKeySet 会显式跳过它。
// 覆盖新版 GameArchives以及旧版 Config/map_archive_* 和早期 persistentDataPath/map_archive_*。
public int CleanupUnlinkedLocalMapData()
{
@ -508,6 +495,10 @@ namespace TH1_Logic.GameArchive
}
}
// 建立“仍被 record 保护”的 archive 集合。
//
// 只有进入这个集合的 begin/quick_continue/continue/end 文件才会被保留;
// 被清理过的 Ended record 虽然还在 Records 里,但已经主动放弃索引保护,必须跳过。
private HashSet<string> BuildReferencedArchiveKeySet()
{
var archiveKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
@ -517,8 +508,10 @@ namespace TH1_Logic.GameArchive
foreach (var record in records)
{
if (record == null) continue;
if (record.RecordKind == GameRecordKind.Ended && record.DiscardArchiveIndex) continue;
AddArchiveKey(archiveKeys, GameArchiveFileKind.Begin, record.BeginArchiveId);
// Quick 的 continue 文件固定在 quick_continue/quick.datManual 的 continue 文件在 continue/{id}.dat。
if (record.RecordKind == GameRecordKind.Quick)
AddArchiveKey(archiveKeys, GameArchiveFileKind.QuickContinue, record.ContinueArchiveId);
else

View File

@ -131,7 +131,6 @@ namespace RuntimeData
if (record == null) return;
RefreshGameRecord();
EnsureRecordId(record);
ApplyRecordPersistenceDefault(record);
_gameRecord.Records.Add(record);
SaveGameRecordData();
}
@ -144,7 +143,6 @@ namespace RuntimeData
RefreshGameRecord();
record.RecordKind = GameRecordKind.Quick;
record.RecordId = GameArchiveManager.QuickArchiveId;
record.IsPersistent = true;
_gameRecord.Records.RemoveAll(existing => existing.RecordKind == GameRecordKind.Quick);
_gameRecord.Records.Add(record);
SaveGameRecordData();
@ -172,7 +170,8 @@ namespace RuntimeData
return true;
}
// 删除某条 record。主要用于删除玩家手动通用存档。
// 删除某条 record。当前用户清理流程只让 Manual 走这里。
// Quick 不走用户清理Ended 只丢弃索引,不直接删除历史展示行。
// UI 传回来的 record 可能不是同一个对象引用,所以 IsSameRecord 会用 RecordId 和索引字段兜底匹配。
public bool RemoveRecord(GameRecord record)
{
@ -185,45 +184,27 @@ namespace RuntimeData
return true;
}
// 设置 Ended record 是否参与持久保留。
// Manual/Quick 是普通可继续记录,默认并始终持久化,不通过这个接口改。
public bool SetRecordPersistent(GameRecord record, bool isPersistent)
// 标记 Ended record 不再参与 MapData 索引。
//
// Ended 清理后仍保留 record 作为历史战绩展示;
// 这里只写 DiscardArchiveIndex不删除 begin/end 文件,文件回收交给 CleanupUnlinkedLocalMapData。
public bool DiscardEndedRecordArchiveIndex(GameRecord record)
{
if (record == null) return false;
RefreshGameRecord();
var storedRecord = FindRecord(record);
if (storedRecord == null) return false;
if (storedRecord.RecordKind != GameRecordKind.Ended) return false;
if (storedRecord.RecordKind != GameRecordKind.Ended)
{
record.IsPersistent = true;
if (!storedRecord.IsPersistent)
{
storedRecord.IsPersistent = true;
SaveGameRecordData();
}
return false;
}
// 同步传入对象,避免 UI 手里的 record 在下一次刷新前仍显示为未清理状态。
record.DiscardArchiveIndex = true;
if (storedRecord.DiscardArchiveIndex) return false;
record.IsPersistent = isPersistent;
if (storedRecord.IsPersistent == isPersistent) return false;
storedRecord.IsPersistent = isPersistent;
storedRecord.DiscardArchiveIndex = true;
SaveGameRecordData();
return true;
}
// 删除所有未标记持久化的 record返回删除数量。
public int RemoveNonPersistentRecords()
{
RefreshGameRecord();
var removed = _gameRecord.Records.RemoveAll(record => record == null || !record.IsPersistent);
if (removed <= 0) return 0;
SaveGameRecordData();
return removed;
}
// 老数据没有 RecordId新写入前补一个。
// Quick record 使用固定 quick id会在 UpsertQuickRecord 里覆盖。
private void EnsureRecordId(GameRecord record)
@ -232,13 +213,6 @@ namespace RuntimeData
record.RecordId = Guid.NewGuid().ToString("N");
}
private void ApplyRecordPersistenceDefault(GameRecord record)
{
if (record == null) return;
if (record.RecordKind == GameRecordKind.Quick || record.RecordKind == GameRecordKind.Manual)
record.IsPersistent = true;
}
private GameRecord FindRecord(GameRecord record)
{
if (record == null) return null;
@ -304,25 +278,6 @@ namespace RuntimeData
_gameRecord ??= new GameRecordData();
_gameRecord.Records ??= new List<GameRecord>();
if (NormalizeRecordPersistenceDefaults()) SaveGameRecordData();
}
private bool NormalizeRecordPersistenceDefaults()
{
if (_gameRecord?.Records == null) return false;
var changed = false;
foreach (var record in _gameRecord.Records)
{
if (record == null) continue;
if (record.RecordKind != GameRecordKind.Quick && record.RecordKind != GameRecordKind.Manual) continue;
if (record.IsPersistent) continue;
record.IsPersistent = true;
changed = true;
}
return changed;
}
}
@ -385,7 +340,9 @@ namespace RuntimeData
public string ContinueArchiveId;
// 结束快照文件 id。只有 Ended 需要它Manual/Quick 通常为空。
public string EndArchiveId;
// 是否在存档清理中保留本条 record。新增字段必须继续追加在末尾。
public bool IsPersistent;
// 本局是否存在额外由本机/房主控制的真实玩家。房主/本机主玩家本身不算。新增字段必须继续追加在末尾。
public bool HasHostControlledPlayer;
// 只对 Ended 有业务意义true 表示这条历史记录不再保护 begin/end 文件。新增字段必须继续追加在末尾。
public bool DiscardArchiveIndex;
}
}