Compare commits

...

15 Commits

Author SHA1 Message Date
b02af4d0c7 asd 2025-12-14 12:59:18 +01:00
5dba8137c3 Fix: Remove AddBuilding from ProcessBuildingHook
The original PlayerSaveData.Unpack calls AddBuilding, PlaceFromLoad,
and UnpackStage2 after ProcessBuilding returns. The hook should only
create and initialize the building, not call these methods.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 11:58:00 +01:00
b3d7108574 Debug: Add detailed logging to ProcessBuildingHook
Added step-by-step logging to identify where building load fails:
- GetPlaceableByUniqueName result
- Each initialization step
- Exception details if any step fails

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 11:50:31 +01:00
ce1c067fca Fix: Add PlaceFromLoad and UnpackStage2 in ProcessBuildingHook
The hook was missing the critical World.inst.PlaceFromLoad() call which:
- Places building in world cells
- Sets up pathing data for villager navigation
- Registers building properly

Also added UnpackStage2() for complete building initialization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 11:45:59 +01:00
5d086776cf Fix: Add missing BakePathing() call in PlayerAddBuildingHook
The hook was skipping the original AddBuilding method but not calling
BakePathing(), which is required for villager pathfinding to work.
Without this, villagers cannot find paths to buildings.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 11:42:20 +01:00
89586ad8df Fix: Remove TeleportTo calls that break villager AI/movement
- Remove post-load villager TeleportTo refresh (breaks pathfinding)
- Remove periodic villager position sync (TeleportTo interrupts movement)
- Keep ClearVillagerPositionCache for API compatibility

The TeleportTo calls were resetting villager AI state and preventing
them from continuing their movement/work.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 11:39:03 +01:00
8f3d83e807 Fix: Compile errors - variable naming and missing using
- Rename lambda variable 'v' to 'w' to avoid conflict with local 'v'
- Rename local Villager 'v' to 'newVillager' for clarity
- Add missing 'using Assets.Interface' for IResourceStorage

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 11:02:01 +01:00
dd17030e56 Fix: Add periodic villager position sync from server
- Server syncs villager positions every ~3 seconds to clients
- Only syncs villagers that moved more than 0.5 units (bandwidth optimization)
- Maintains position cache to detect movement
- Clears cache on lobby leave to prevent stale data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 10:58:37 +01:00
8d599e13ad Fix: Add post-load fixes for resources and villagers
- Re-register all resource storages after load to fix missing resources
- Refresh building pathing for all players
- Teleport villagers to their position to reset stuck pathfinding/AI
- Add comprehensive error handling and logging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 10:56:51 +01:00
eab7931f52 Fix: Add position sync for villagers and duplicate check
- Add position property to AddVillagerPacket
- Teleport villager to correct position on client
- Add duplicate guid check to prevent double villager creation
- Send position from Main.cs hook

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 10:52:39 +01:00
7d6c915b49 Fix: Prevent duplicate building placement via guid check
- Check if building with same guid already exists before placing
- Prevents buildings overlapping from network packet retries
- Logs skip when duplicate detected

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 10:51:53 +01:00
f7fc5a3969 Fix: Proper session cleanup in LeaveLobby
- Clear clientSteamIds dictionary on disconnect
- Reset loadingSave flag to false
- Prevents stale data when rejoining servers

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 10:51:21 +01:00
2f42cf9366 Fix: Server event handler duplication and null safety
- Remove static constructor that registered MessageReceived handler
- Add proper cleanup in StartServer() before creating new instance
- Add null checks in IsRunning, Update(), and OnApplicationQuit()
- Prevents double event handler registration on reconnect

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 10:50:57 +01:00
888c807b96 nah 2025-12-14 10:50:05 +01:00
bd12485112 Add CLAUDE.md documentation for Claude Code
Provides architecture overview, packet system docs, and common patterns
for future development assistance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 10:49:52 +01:00
9 changed files with 248 additions and 145 deletions

3
.gitignore vendored
View File

@@ -23,4 +23,5 @@ Desktop.ini
**/*.mdb
**/*.pdb
/.claude
/.claude
/*.png

104
CLAUDE.md Normal file
View File

@@ -0,0 +1,104 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a **Kingdoms and Castles Multiplayer Mod** that adds multiplayer functionality to the game using:
- **Riptide Networking** library for low-level networking
- **Steam P2P** transport for NAT traversal
- **Harmony** for non-invasive game modification via patches/hooks
## Architecture
### Core Components
| File | Purpose |
|------|---------|
| `Main.cs` | Entry point, Harmony patches, all game event hooks |
| `KCClient.cs` | Client-side networking wrapper around Riptide.Client |
| `KCServer.cs` | Server-side networking, client management |
| `KCPlayer.cs` | Player data container (id, steamId, inst, kingdomName) |
### Networking Layer
```
Riptide.Client/Server
└── SteamClient/SteamServer (Steam P2P transport)
└── KCClient/KCServer wrappers
└── PacketHandler (serialization/routing)
```
- Port: 7777, Max clients: 25
- Team ID formula: `clientId * 10 + 2`
### Packet System
Located in `/Packets/`:
- Base class: `Packet.cs` with `Send()`, `SendToAll()`, `HandlePacketClient()`, `HandlePacketServer()`
- `PacketHandler.cs` uses reflection for automatic serialization based on property names (alphabetical order)
- Packet IDs defined in `Enums/Packets.cs`
Key packet ranges:
- 25-34: Lobby (chat, player list, settings)
- 70-79: World/building updates
- 85: Save transfer (chunked)
- 87-90: Building state, villagers
### State Synchronization
- **Buildings**: Observer pattern in `StateManagement/BuildingState/` - monitors field changes every 100ms, sends updates every 300ms
- **Villagers**: Event-based sync via Harmony hooks on `VillagerSystem.AddVillager`, `Villager.TeleportTo`
- **Save/Load**: Custom `MultiplayerSaveContainer` extends `LoadSaveContainer`, stores per-player data
### Harmony Hooks Pattern
All hooks check call stack to prevent infinite loops:
```csharp
if (new StackFrame(3).GetMethod().Name.Contains("HandlePacket"))
return; // Skip if called by network handler
```
### Key Dictionaries
```csharp
Main.kCPlayers // Dictionary<steamId, KCPlayer>
Main.clientSteamIds // Dictionary<clientId, steamId>
```
## Common Issues & Patterns
### Player Resolution
```csharp
Main.GetPlayerByClientID(clientId) // clientId -> KCPlayer
Main.GetPlayerByTeamID(teamId) // teamId -> Player.inst
Main.GetPlayerByBuilding(building) // building -> owner Player
```
### Building Ownership
Buildings are associated with players via `LandmassOwner.teamId`. Use `building.TeamID()` to determine owner.
### Save Directory
Multiplayer saves go to: `Application.persistentDataPath + "/Saves/Multiplayer"`
## Directory Structure
```
/Attributes - Custom packet attributes
/Enums - Packet types, menu states
/LoadSaveOverrides - MultiplayerSaveContainer
/Packets - All network packets
/Riptide - Networking library
/RiptideSteamTransport - Steam P2P adapter, LobbyManager
/StateManagement - Observer pattern for sync
/ServerLobby - Lobby UI
/ServerBrowser - Server discovery
/UI - Custom UI elements
```
## Known Architecture Limitations
1. Static `Client`/`Server` instances can cause issues on reconnect
2. Call stack checking for loop prevention is fragile
3. No conflict resolution - last-write-wins
4. Villager sync is event-based only, no continuous state updates

View File

@@ -18,18 +18,22 @@ namespace KCM
{
public class KCServer : MonoBehaviour
{
public static Server server = new Server(Main.steamServer);
public static Server server = null;
public static bool started = false;
static KCServer()
{
//server.registerMessageHandler(typeof(KCServer).GetMethod("ClientJoined"));
server.MessageReceived += PacketHandler.HandlePacketServer;
}
public static void StartServer()
{
// Stop and cleanup existing server if running
if (server != null)
{
if (server.IsRunning)
{
server.Stop();
}
// Unsubscribe old event handlers to prevent memory leaks
server.MessageReceived -= PacketHandler.HandlePacketServer;
}
server = new Server(Main.steamServer);
server.MessageReceived += PacketHandler.HandlePacketServer;
@@ -95,16 +99,18 @@ namespace KCM
}
}*/
public static bool IsRunning { get { return server.IsRunning; } }
public static bool IsRunning { get { return server != null && server.IsRunning; } }
private void Update()
{
server.Update();
if (server != null)
server.Update();
}
private void OnApplicationQuit()
{
server.Stop();
if (server != null && server.IsRunning)
server.Stop();
}
private void Preload(KCModHelper helper)

View File

@@ -1,4 +1,5 @@
using Assets.Code;
using Assets.Interface;
using Riptide;
using Riptide.Transports;
using Steamworks;
@@ -255,6 +256,47 @@ namespace KCM.LoadSaveOverrides
Main.helper.Log($"Setting kingdom name to: {kingdomNames[Main.PlayerSteamID]}");
TownNameUI.inst.SetTownName(kingdomNames[Main.PlayerSteamID]);
// Post-load fixes for multiplayer
Main.helper.Log("Running post-load multiplayer fixes...");
// Fix 1: Re-register all resource storages for all players
Main.helper.Log("Re-registering resource storages...");
foreach (var kcPlayer in Main.kCPlayers.Values)
{
if (kcPlayer.inst == null) continue;
foreach (var building in kcPlayer.inst.Buildings.data)
{
if (building == null) continue;
// Re-register resource storages
var storages = building.GetComponents<IResourceStorage>();
foreach (var storage in storages)
{
if (storage != null && !storage.IsPrivate())
{
try
{
FreeResourceManager.inst.AddResourceStorage(storage);
}
catch (Exception e)
{
Main.helper.Log($"Error re-registering storage for {building.UniqueName}: {e.Message}");
}
}
}
// Refresh building pathing
try
{
building.BakePathing();
}
catch { }
}
}
Main.helper.Log("Post-load multiplayer fixes complete.");
return obj;
}
}

102
Main.cs
View File

@@ -183,39 +183,13 @@ namespace KCM
public static int FixedUpdateInterval = 0;
public static void ClearVillagerPositionCache()
{
// Kept for API compatibility with LobbyManager
}
private void FixedUpdate()
{
// send batched building placement info
/*if (PlaceHook.QueuedBuildings.Count > 0 && (FixedUpdateInterval % 25 == 0))
{
foreach (Building building in PlaceHook.QueuedBuildings)
{
new WorldPlace()
{
uniqueName = building.UniqueName,
customName = building.customName,
guid = building.guid,
rotation = building.transform.GetChild(0).rotation,
globalPosition = building.transform.position,
localPosition = building.transform.GetChild(0).localPosition,
built = building.IsBuilt(),
placed = building.IsPlaced(),
open = building.Open,
doBuildAnimation = building.doBuildAnimation,
constructionPaused = building.constructionPaused,
constructionProgress = building.constructionProgress,
life = building.Life,
ModifiedMaxLife = building.ModifiedMaxLife,
//CollectForBuild = CollectForBuild,
yearBuilt = building.YearBuilt,
decayProtection = building.decayProtection,
seenByPlayer = building.seenByPlayer
}.Send();
}
PlaceHook.QueuedBuildings.Clear();
}*/
FixedUpdateInterval++;
}
@@ -727,6 +701,8 @@ namespace KCM
}
LogStep();
// CRITICAL: Bake pathing for villager movement!
b.BakePathing();
return false;
}
@@ -780,6 +756,7 @@ namespace KCM
new AddVillagerPacket()
{
guid = __result.guid,
position = pos, // Include villager spawn position
}.Send();
}
catch (Exception e)
@@ -1417,34 +1394,55 @@ namespace KCM
{
if (KCClient.client.IsConnected)
{
Main.helper.Log($"[ProcessBuilding] START - {structureData.uniqueName}");
Building Building = GameState.inst.GetPlaceableByUniqueName(structureData.uniqueName);
bool flag = Building;
if (flag)
try
{
Building building = UnityEngine.Object.Instantiate<Building>(Building);
building.transform.position = structureData.globalPosition;
building.Init();
building.transform.SetParent(p.buildingContainer.transform, true);
structureData.Unpack(building);
p.AddBuilding(building);
Building Building = GameState.inst.GetPlaceableByUniqueName(structureData.uniqueName);
Main.helper.Log($"[ProcessBuilding] GetPlaceable: {(Building != null ? "OK" : "NULL")}");
Main.helper.Log($"Loading player id: {p.PlayerLandmassOwner.teamId}");
Main.helper.Log($"loading building: {building.FriendlyName}");
Main.helper.Log($" (teamid: {building.TeamID()})");
Main.helper.Log(p.ToString());
bool flag2 = building.GetComponent<Keep>() != null && building.TeamID() == p.PlayerLandmassOwner.teamId;
Main.helper.Log("Set keep? " + flag2);
if (flag2)
if (Building)
{
p.keep = building.GetComponent<Keep>();
Main.helper.Log(p.keep.ToString());
Building building = UnityEngine.Object.Instantiate<Building>(Building);
Main.helper.Log($"[ProcessBuilding] Instantiated");
building.transform.position = structureData.globalPosition;
Main.helper.Log($"[ProcessBuilding] Position set: {structureData.globalPosition}");
building.Init();
Main.helper.Log($"[ProcessBuilding] Init done");
building.transform.SetParent(p.buildingContainer.transform, true);
Main.helper.Log($"[ProcessBuilding] SetParent done");
structureData.Unpack(building);
Main.helper.Log($"[ProcessBuilding] Unpack done");
// NOTE: AddBuilding, PlaceFromLoad and UnpackStage2 are called by the original
// PlayerSaveData.Unpack method after ProcessBuilding returns
Main.helper.Log($"[ProcessBuilding] Returning building to Unpack");
bool isKeep = building.GetComponent<Keep>() != null && building.TeamID() == p.PlayerLandmassOwner.teamId;
if (isKeep)
{
p.keep = building.GetComponent<Keep>();
Main.helper.Log($"[ProcessBuilding] Keep set");
}
__result = building;
Main.helper.Log($"[ProcessBuilding] SUCCESS - {structureData.uniqueName}");
}
else
{
Main.helper.Log($"[ProcessBuilding] FAILED - {structureData.uniqueName} not found in GameState");
__result = null;
}
__result = building;
}
else
catch (Exception e)
{
Main.helper.Log(structureData.uniqueName + " failed to load correctly");
Main.helper.Log($"[ProcessBuilding] EXCEPTION: {structureData.uniqueName}");
Main.helper.Log($"[ProcessBuilding] Error: {e.Message}");
Main.helper.Log($"[ProcessBuilding] Stack: {e.StackTrace}");
__result = null;
}

View File

@@ -12,6 +12,7 @@ namespace KCM.Packets.Game.GamePlayer
public override ushort packetId => (ushort)Enums.Packets.AddVillager;
public Guid guid { get; set; }
public Vector3 position { get; set; }
public override void HandlePacketClient()
{
@@ -19,18 +20,33 @@ namespace KCM.Packets.Game.GamePlayer
{
if (KCClient.client.Id == clientId) return;
// Check for duplicate villager by guid
var existingVillager = player.inst.Workers.data.FirstOrDefault(w => w != null && w.guid == guid);
if (existingVillager != null)
{
Main.helper.Log($"Villager with guid {guid} already exists, skipping duplicate");
return;
}
Main.helper.Log("Received add villager packet from " + player.name + $"({player.id})");
Villager v = Villager.CreateVillager();
v.guid = guid;
Villager newVillager = Villager.CreateVillager();
newVillager.guid = guid;
player.inst.Workers.Add(v);
player.inst.Homeless.Add(v);
// Set villager position
if (position != Vector3.zero)
{
newVillager.TeleportTo(position);
}
player.inst.Workers.Add(newVillager);
player.inst.Homeless.Add(newVillager);
}
catch (Exception e)
{
Main.helper.Log("Error handling add villager packet: " + e.Message);
Main.helper.Log(e.StackTrace);
}
}

View File

@@ -48,6 +48,14 @@ namespace KCM.Packets.Game.GameWorld
{
Main.helper.Log("Received place building packet for " + uniqueName + " from " + player.name + $"({player.id})");
// Check for duplicate building by guid to prevent double placement from network retries
var existingBuilding = player.inst.Buildings.data.FirstOrDefault(b => b != null && b.guid == guid);
if (existingBuilding != null)
{
Main.helper.Log($"Building with guid {guid} already exists for player {player.name}, skipping duplicate placement");
return;
}
//var originalPlayer = Player.inst;
//Player.inst = player.inst;

View File

@@ -1,75 +0,0 @@
# KCM (Kingdoms and Castles Multiplayer)
Ez a repó egy *Kingdoms and Castles* multiplayer mod forrása. A mod Steam lobby + Riptide alapú hálózattal próbálja a világot és a játékosok akcióit több kliens között szinkronban tartani.
Ha a `output.txt` logban `Compilation failed` szerepel, akkor a mod **nem töltődött be**, és semmi nem fog szinkronizálódni (ilyenkor tipikusan C# szintaxis / runtime-kompatibilitási hiba van a forrásban).
## Mit szinkronizál a mod? (jelenlegi állapot)
**Lobby / kapcsolat**
- Játékos csatlakozás/leválás, player lista, ready állapot.
- Szerver beállítások (név, max players, seed, world opciók).
- Chat és rendszerüzenetek.
**Világ indítás**
- World seed szétküldése és world generálás a klienseken.
- (Beállítástól függően) keep elhelyezés csomagból.
**Gameplay alap**
- Épület lerakás események (alap meta: `uniqueName`, `guid`, pozíció/rotáció).
- Épület állapot frissítések “snapshot” jelleggel (`BuildingStatePacket`): built/placed, constructionProgress, life, stb.
- Néhány globális esemény: idősebesség változtatás, időjárás váltás, fa kivágás (repo verziótól függően).
- Host oldalon periodikus *resource snapshot* korrigálás (ha drift/desync van, visszahúzza a klienst).
**Mentés betöltés (host → kliens)**
- Host oldalon a mentés byte-ok chunkolva kerülnek kiküldésre (`SaveTransferPacket`).
- Kliens oldalon érkezés után `LoadSave.Load()` + `MultiplayerSaveContainer.Unpack()` fut.
- Ha a kiválasztott mentés nem multiplayer container (vanilla mentés), a host fallback-ként átadja a normál betöltést.
## Mi nincs (még) rendesen szinkronizálva? (gyakori desync okok)
Ezek okozzák a tipikus “farm termel, de nem látszik” / “resource nem frissül” / “animáció hiányzik” jelenségeket:
- **Erőforrás-logika és szállítás**: raktárkészletek, haul/cart routing, villager “viszem/lerakom” animációk nincsenek teljes állapotban szinkronizálva.
- **Villager/job részletek**: current task, target, carried resource, pathing cache, részfeladat-állapot.
- **Field/Farm belső állapot**: growth stage, harvest queue, field regisztráció edge case-ek.
- **UI / kliens oldali state**: beragadt menük, promptok (pl. “rakd le a kezdő épületet”), lokális UI state nem hálózati adat.
- **AI brains / nem-player rendszerek**: részben vagy egyáltalán nincs “szerver az igazság” modell.
## Mit érdemes még hozzáadni? (roadmap)
Ha cél a stabil “load utáni sync” és kevesebb vizuális desync:
- **Resource szinkron**: raktárak készlete, termelés/fogyasztás tick eredménye, szállítási queue események (event-based vagy periodikus snapshot).
- **Villager szinkron**: villager state machine + carried resource + célpont; vagy determinisztikus szerver oldali szimuláció és kliens “replay”.
- **Farm/Field szinkron**: field állapot (growth/ready/harvest), aratás események explicit hálózati üzenetként.
- **Robusztus reconnect**: kilépés egy sessionből → másik lobby csatlakozás restart nélkül (minden statikus állapot, observer, transfer state, player cache teljes resetje).
- **Debug eszközök**: desync detektor (hash/snapshot összehasonlítás), több log a load/sync pontokra.
## Telepítés
- Hostnak és **minden kliensnek ugyanaz a mod verzió** kell.
- Workshop verzió frissítés felülírhatja a módosításokat. Ajánlott:
- kimásolni a modot a játék `...\\KingdomsAndCastles_Data\\mods\\` mappájába egy külön névvel,
- és a mod menüben kikapcsolni a Workshop verziót.
- Változtatások után **teljes játék újraindítás** javasolt.
## Hibaelhárítás
**Log helye:** a mod mappájában gyakran van `output.txt`.
Nézd ezeket a kulcssorokat:
- `Compilation failed` → a mod nem fordult le, nincs multiplayer.
- `Save Transfer started/complete` → mentés átküldés/betöltés állapota.
- `Error loading save` / `LoadError` → sérült/rossz típusú mentés, vagy verzió eltérés.
Bug reporthoz küldd el:
- a hiba környéki 50100 sort a `output.txt`-ből,
- host/kliens szerep, játék verzió, mod verzió,
- új világban vagy mentés betöltés után jelentkezik-e.
## Fejlesztés
Repo-szabályok és szerkezet: `AGENTS.md`.
### Gyors resync
A lobby chatben írd be: `/resync` a kliens kér egy resync-et a hosttól (resource + building + villager “teleport” snapshot).

View File

@@ -159,15 +159,18 @@ namespace Riptide.Demos.Steam.PlayerHosted
Main.helper.Log("clear players");
Main.kCPlayers.Clear();
Main.clientSteamIds.Clear(); // Clear client-to-steam ID mapping
Main.ClearVillagerPositionCache(); // Clear villager sync cache
LobbyHandler.ClearPlayerList();
LobbyHandler.ClearChatEntries();
Main.helper.Log("end clear players");
// Reset loading state
loadingSave = false;
if (KCServer.IsRunning)
KCServer.server.Stop();
Main.TransitionTo(MenuState.ServerBrowser);
ServerBrowser.registerServer = false;
}