diff --git a/Scripts/Auth/AuthApiClient.cs b/Scripts/Auth/AuthApiClient.cs index 6aadade..42e0ae3 100644 --- a/Scripts/Auth/AuthApiClient.cs +++ b/Scripts/Auth/AuthApiClient.cs @@ -248,7 +248,9 @@ private enum AuthRequestKind FetchMarketplaceListings, CreateMarketplaceListing, CancelMarketplaceListing, - BuyMarketplaceListing + BuyMarketplaceListing, + GenerateDrops, + ClearDrops } [Export] public string BackendBaseUrl = BackendDefaults.BackendBaseUrl; @@ -450,6 +452,39 @@ public bool BuyMarketplaceListing(string token, int listingId, Action callback) + { + var payload = new Godot.Collections.Dictionary + { + ["action"] = "generate", + ["player_level"] = playerLevel, + ["drop_source"] = dropSource, + ["difficulty"] = difficulty + }; + return SendRequest( + AuthRequestKind.GenerateDrops, + "/api/player/drop/", + HTTPClient.Method.Post, + payload, + token, + callback); + } + + public bool ClearDrops(string token, Action callback) + { + var payload = new Godot.Collections.Dictionary + { + ["action"] = "clear" + }; + return SendRequest( + AuthRequestKind.ClearDrops, + "/api/player/drop/", + HTTPClient.Method.Post, + payload, + token, + callback); + } + private bool SendRequest( AuthRequestKind kind, string endpointPath, @@ -602,6 +637,41 @@ private AuthApiResult CreateMockResult(AuthRequestKind kind, Godot.Collections.D } break; + case AuthRequestKind.GenerateDrops: + result.ArrayData = new Godot.Collections.Array(); + // Mock Gold Drop + result.ArrayData.Add(new Godot.Collections.Dictionary + { + ["instance_id"] = Guid.NewGuid().ToString("N"), + ["item_type"] = "gold", + ["gold_amount"] = 35, + ["item_data"] = null + }); + // Mock Item Drop (Hp Potion S) + result.ArrayData.Add(new Godot.Collections.Dictionary + { + ["instance_id"] = Guid.NewGuid().ToString("N"), + ["item_type"] = "potion", + ["gold_amount"] = 0, + ["item_data"] = new Godot.Collections.Dictionary + { + ["item_id"] = "hp_potion_s", + ["name"] = "Small HP Potion", + ["description"] = "Restores 5 HP.", + ["item_type"] = "potion", + ["quantity"] = 1, + ["price"] = 10, + ["sprite_path"] = "res://Assets/items/hp_potion_S.png", + ["heal_amount"] = 5, + ["removes_burn"] = false + } + }); + break; + + case AuthRequestKind.ClearDrops: + // ResponseCode 200 OK, empty Data/ArrayData is fine + break; + case AuthRequestKind.Logout: result.ResponseCode = 204; break; diff --git a/Scripts/Characters/Monster.cs b/Scripts/Characters/Monster.cs index ed41177..33d615b 100644 --- a/Scripts/Characters/Monster.cs +++ b/Scripts/Characters/Monster.cs @@ -769,20 +769,85 @@ private void TrySpawnDrops() parent.AddChild(expPickup); GD.PrintS($"[Monster] Spawned EXP drop at {expPickup.Position}"); + DifficultyLevel mapDiff = _map != null ? _map.Difficulty : DifficultyLevel.Normal; + int playerLevel = _player != null ? (int)_player.Level : (int)Level; + var manager = FindEquipmentManager(); + + if (Main.Instance != null) + { + string token = Main.Instance.GetAuthToken(); + if (Main.Instance.PlayerDataApiClient != null && !string.IsNullOrEmpty(token)) + { + Main.Instance.PlayerDataApiClient.GenerateDrops(token, playerLevel, "monster", mapDiff.ToString(), result => + { + if (result.NetworkOk && result.ResponseCode == 200 && result.ArrayData != null) + { + SpawnServerDrops(parent, GlobalPosition, manager, result.ArrayData); + } + else + { + GD.PrintErr("[Monster] Failed to generate secure drops from server. Falling back to local generation..."); + SpawnLocalDrops(parent, GlobalPosition, manager, mapDiff); + } + }); + return; + } + } + + SpawnLocalDrops(parent, GlobalPosition, manager, mapDiff); + } + + private void SpawnServerDrops(Node parent, Vector2 centerPosition, EquipmentManager manager, Godot.Collections.Array drops) + { + var rng = new RandomNumberGenerator(); + rng.Randomize(); + + for (int i = 0; i < drops.Count; i++) + { + if (!(drops[i] is Godot.Collections.Dictionary drop)) + { + continue; + } + + string instanceId = drop.Contains("instance_id") ? drop["instance_id"]?.ToString() : string.Empty; + string itemType = drop.Contains("item_type") ? drop["item_type"]?.ToString() : string.Empty; + + if (itemType == "gold") + { + int goldAmount = drop.Contains("gold_amount") ? Convert.ToInt32(drop["gold_amount"]) : 0; + var coinDrop = new QuestFantasy.Items.CoinDrop(); + coinDrop.InitializeSecure(instanceId, goldAmount, _player); + coinDrop.Position = centerPosition + new Vector2(rng.Randf() * 40f - 20f, rng.Randf() * 40f - 20f); + parent.AddChild(coinDrop); + GD.PrintS($"[Monster] Spawned Secure Coin drop of value {goldAmount} at {coinDrop.Position}"); + } + else if (drop.Contains("item_data") && drop["item_data"] is Godot.Collections.Dictionary itemDataDict) + { + itemDataDict["instance_id"] = instanceId; + Item item = PlayerItemSnapshotCodec.Decode(itemDataDict); + if (item != null) + { + float pscale = manager != null ? manager.PickupSpriteScale : 0.5f; + var itemPos = centerPosition + new Vector2(rng.Randf() * 100f - 50f, rng.Randf() * 100f - 50f); + LootItemFactory.SpawnPickup(parent, item, itemPos, pscale, "secure_monster"); + GD.PrintS($"[Monster] Spawned secure pickup: {item.Name} at {itemPos}"); + } + } + } + } + + private void SpawnLocalDrops(Node parent, Vector2 centerPosition, EquipmentManager manager, DifficultyLevel mapDiff) + { var coinDrop = new QuestFantasy.Items.CoinDrop(); int pLevel = _player != null ? (int)_player.Level : (int)Level; - DifficultyLevel mapDiff = _map != null ? _map.Difficulty : DifficultyLevel.Normal; coinDrop.Initialize(pLevel, mapDiff, 0.3f, _player); - coinDrop.Position = GlobalPosition + new Vector2((float)_random.NextDouble() * 40f - 20f, (float)_random.NextDouble() * 40f - 20f); + coinDrop.Position = centerPosition + new Vector2((float)_random.NextDouble() * 40f - 20f, (float)_random.NextDouble() * 40f - 20f); parent.AddChild(coinDrop); GD.PrintS($"[Monster] Spawned Coin drop at {coinDrop.Position}"); var rng = new RandomNumberGenerator(); rng.Randomize(); - // Find EquipmentManager early so consumable/ticket drops can match equipment scale - var manager = FindEquipmentManager(); - float itemDropChance = Mathf.Clamp(ItemDropChance, 0f, 1f); if (rng.Randf() >= itemDropChance) { @@ -791,7 +856,6 @@ private void TrySpawnDrops() } Item itemDrop = RollSingleItemDrop(rng, manager, mapDiff); - if (itemDrop == null) { GD.PrintS("[Monster] Item drop roll passed, but no item was available."); @@ -799,7 +863,7 @@ private void TrySpawnDrops() } float itemScale = manager != null ? manager.PickupSpriteScale : 0.5f; - var itemPos = GlobalPosition + new Vector2(rng.Randf() * 100f - 50f, rng.Randf() * 100f - 50f); + var itemPos = centerPosition + new Vector2(rng.Randf() * 100f - 50f, rng.Randf() * 100f - 50f); LootItemFactory.SpawnPickup(parent, itemDrop, itemPos, itemScale, "monster_item"); GD.PrintS($"[Monster] Spawned item drop: {itemDrop.Name} at {itemPos}"); } diff --git a/Scripts/Environment/TreasureChest.cs b/Scripts/Environment/TreasureChest.cs index 3c00c38..fa7811e 100644 --- a/Scripts/Environment/TreasureChest.cs +++ b/Scripts/Environment/TreasureChest.cs @@ -111,33 +111,91 @@ public object OpenAndGetEquipment(int playerLevel = 1) // Returns the list of spawned EquipmentPickup nodes. public Godot.Collections.Array OpenChest(Node parent, Vector2 centerPosition, EquipmentManager manager, int playerLevel) { - var spawned = new Godot.Collections.Array(); - if (manager == null || parent == null) - return spawned; + DifficultyLevel mapDiff = DifficultyLevel.Normal; + if (parent is Map parentMap) mapDiff = parentMap.Difficulty; + + if (Main.Instance != null) + { + string token = Main.Instance.GetAuthToken(); + if (Main.Instance.PlayerDataApiClient != null && !string.IsNullOrEmpty(token)) + { + Main.Instance.PlayerDataApiClient.GenerateDrops(token, playerLevel, "chest", mapDiff.ToString(), result => + { + if (result.NetworkOk && result.ResponseCode == 200 && result.ArrayData != null) + { + SpawnServerDrops(parent, centerPosition, manager, result.ArrayData); + } + else + { + GD.PrintErr("[TreasureChest] Failed to generate secure drops from server. Falling back to local generation..."); + SpawnLocalDrops(parent, centerPosition, manager, playerLevel, mapDiff); + } + }); + return new Godot.Collections.Array(); + } + } + + SpawnLocalDrops(parent, centerPosition, manager, playerLevel, mapDiff); + return new Godot.Collections.Array(); + } + + private void SpawnServerDrops(Node parent, Vector2 centerPosition, EquipmentManager manager, Godot.Collections.Array drops) + { + var rng = new RandomNumberGenerator(); + rng.Randomize(); + + for (int i = 0; i < drops.Count; i++) + { + if (!(drops[i] is Godot.Collections.Dictionary drop)) + { + continue; + } + string instanceId = drop.Contains("instance_id") ? drop["instance_id"]?.ToString() : string.Empty; + string itemType = drop.Contains("item_type") ? drop["item_type"]?.ToString() : string.Empty; + + if (itemType == "gold") + { + int goldAmount = drop.Contains("gold_amount") ? Convert.ToInt32(drop["gold_amount"]) : 0; + var player = FindPlayerRecursive(parent); + var coinDrop = new QuestFantasy.Items.CoinDrop(); + coinDrop.InitializeSecure(instanceId, goldAmount, player); + coinDrop.Position = centerPosition + new Vector2(rng.Randf() * 40f - 20f, rng.Randf() * 40f - 20f); + parent.AddChild(coinDrop); + GD.PrintS($"[TreasureChest] Spawned Secure Coin drop of value {goldAmount} at {coinDrop.Position}"); + } + else if (drop.Contains("item_data") && drop["item_data"] is Godot.Collections.Dictionary itemDataDict) + { + itemDataDict["instance_id"] = instanceId; + Item item = PlayerItemSnapshotCodec.Decode(itemDataDict); + if (item != null) + { + float pscale = manager != null ? manager.PickupSpriteScale : 0.5f; + var offset = new Vector2(rng.Randf() * 200f - 100f, rng.Randf() * 200f - 100f); + var itemPos = centerPosition + offset; + LootItemFactory.SpawnPickup(parent, item, itemPos, pscale, "secure_chest"); + GD.PrintS($"[TreasureChest] Spawned secure pickup: {item.Name} at {itemPos}"); + } + } + } + } + + private void SpawnLocalDrops(Node parent, Vector2 centerPosition, EquipmentManager manager, int playerLevel, DifficultyLevel mapDiff) + { int minD = Math.Max(0, MinDrops); int maxD = Math.Max(minD, MaxDrops); - // Use RandomNumberGenerator to avoid casting overflow from GD.Randi var rng = new RandomNumberGenerator(); rng.Randomize(); int drops = rng.RandiRange(minD, maxD); - GD.PrintS($"[TreasureChest] drop range min={minD} max={maxD} -> drops={drops}"); - - // Use the provided manager to get equipment options (avoid relying on _manager field) - var options = manager.GetEquipmentSet(OptionCount, playerLevel, LevelOffset); - GD.PrintS($"[TreasureChest] Opening chest: drops={drops}, options={options.Count}"); - - // Convert options to a typed list + var options = manager != null ? manager.GetEquipmentSet(OptionCount, playerLevel, LevelOffset) : new System.Collections.Generic.List(); var optList = new System.Collections.Generic.List(); foreach (var o in options) { optList.Add(o); } - // Shuffle optList and take unique items up to available count var shuffled = new System.Collections.Generic.List(optList); - // Fisher-Yates shuffle for (int s = shuffled.Count - 1; s > 0; s--) { int j = rng.RandiRange(0, s); @@ -155,11 +213,10 @@ public Godot.Collections.Array OpenChest(Node parent, Vector2 centerPosition, Eq var pickup = new EquipmentPickup(); pickup.ItemData = it; - pickup.SpriteScale = manager.PickupSpriteScale; + pickup.SpriteScale = manager != null ? manager.PickupSpriteScale : 0.1f; var offset = new Vector2(rng.Randf() * 200f - 100f, rng.Randf() * 200f - 100f); pickup.Position = centerPosition + offset; - // deterministic node name based on sprite/resource name string baseName = "equipment"; var spriteTex = (pickup.ItemData is QuestFantasy.Core.Data.Items.Equipment pe) ? pe.Sprite : (pickup.ItemData is QuestFantasy.Core.Data.Items.Weapon pw ? pw.Sprite : null); if (spriteTex != null) @@ -172,24 +229,14 @@ public Godot.Collections.Array OpenChest(Node parent, Vector2 centerPosition, Eq } pickup.Name = $"Pickup_{baseName}_{i}"; parent.AddChild(pickup); - spawned.Add(pickup); - GD.PrintS($"[TreasureChest] Spawned pickup: {pickup.Name} at {pickup.Position}"); - if (pickup.ItemData == null || (pickup.ItemData is QuestFantasy.Core.Data.Items.Equipment e2 && e2.Sprite == null) || (pickup.ItemData is QuestFantasy.Core.Data.Items.Weapon w2 && w2.Sprite == null)) - { - GD.PrintS($"[TreasureChest] WARNING: pickup {pickup.Name} has no sprite or item is null"); - } } - DifficultyLevel mapDiff = DifficultyLevel.Normal; - if (parent is Map parentMap) mapDiff = parentMap.Difficulty; - Item potionDrop = LootItemFactory.RollPotion(rng, 0.18f); if (potionDrop != null) { var potionPos = centerPosition + new Vector2(rng.Randf() * 180f - 90f, rng.Randf() * 180f - 90f); float pscale = manager != null ? manager.PickupSpriteScale : 0.5f; LootItemFactory.SpawnPickup(parent, potionDrop, potionPos, pscale, "chest_potion"); - GD.PrintS($"[TreasureChest] Spawned potion drop: {potionDrop.Name} at {potionPos}"); } Item ticketDrop = LootItemFactory.RollTicket(rng, mapDiff, 2f); @@ -198,17 +245,12 @@ public Godot.Collections.Array OpenChest(Node parent, Vector2 centerPosition, Eq var ticketPos = centerPosition + new Vector2(rng.Randf() * 180f - 90f, rng.Randf() * 180f - 90f); float tscale = manager != null ? manager.PickupSpriteScale : 0.5f; LootItemFactory.SpawnPickup(parent, ticketDrop, ticketPos, tscale, "chest_ticket"); - GD.PrintS($"[TreasureChest] Spawned ticket drop: {ticketDrop.Name} at {ticketPos}"); } - // Spawn CoinDrop var player = FindPlayerRecursive(parent); var coinDrop = new QuestFantasy.Items.CoinDrop(); coinDrop.Initialize(playerLevel, mapDiff, 1.0f, player); coinDrop.Position = centerPosition + new Vector2(rng.Randf() * 40f - 20f, rng.Randf() * 40f - 20f); parent.AddChild(coinDrop); - GD.PrintS($"[TreasureChest] Spawned Coin drop at {coinDrop.Position}"); - - return spawned; } } \ No newline at end of file diff --git a/Scripts/Items/CoinDrop.cs b/Scripts/Items/CoinDrop.cs index 8639601..ddfadf7 100644 --- a/Scripts/Items/CoinDrop.cs +++ b/Scripts/Items/CoinDrop.cs @@ -24,6 +24,9 @@ public class CoinDrop : Node2D private const float FrameDuration = 0.05f; private int _currentFrame = 0; + public string InstanceId { get; set; } = string.Empty; + public int Value => _value; + public void Initialize(int playerLevel, DifficultyLevel difficulty, float entityMultiplier, Player player, float spawnDelay = 0f) { _player = player; @@ -31,6 +34,15 @@ public void Initialize(int playerLevel, DifficultyLevel difficulty, float entity CalculateValue(playerLevel, difficulty, entityMultiplier); } + public void InitializeSecure(string instanceId, int value, Player player, float spawnDelay = 0f) + { + _player = player; + _spawnTimer = spawnDelay; + InstanceId = instanceId; + _value = value; + GD.Print($"[CoinDrop] Securely initialized coin value: {_value} (ID: {InstanceId})"); + } + private void CalculateValue(int playerLevel, DifficultyLevel difficulty, float entityMultiplier) { // Base value at level 1 is 5~10 @@ -112,6 +124,11 @@ public override void _Process(float delta) { // Needs AddGold method on player or inventory system _player.AddGold(_value); + if (Main.Instance != null && !string.IsNullOrEmpty(InstanceId)) + { + Main.Instance.ClaimDrop(InstanceId); + Main.Instance.TransmitPlayerProfile("pickup_gold"); + } QueueFree(); return; } diff --git a/Scripts/Main.cs b/Scripts/Main.cs index 045e74e..189c901 100644 --- a/Scripts/Main.cs +++ b/Scripts/Main.cs @@ -27,6 +27,24 @@ public class Main : Node2D private readonly EquipmentManager _equipManagerRef = new EquipmentManager(); private readonly TreasureChest _chestRef = new TreasureChest(); private readonly Godot.Collections.Array _spawnedMonsters = new Godot.Collections.Array(); + public static Main Instance { get; private set; } + public AuthApiClient PlayerDataApiClient => _playerDataApiClient; + private readonly System.Collections.Generic.List _pendingClaimIds = new System.Collections.Generic.List(); + + public string GetAuthToken() + { + return _authFlowController?.CurrentSession?.Token; + } + + public void ClaimDrop(string instanceId) + { + if (string.IsNullOrEmpty(instanceId)) return; + if (!_pendingClaimIds.Contains(instanceId)) + { + _pendingClaimIds.Add(instanceId); + } + } + private readonly string _syncSessionId = Guid.NewGuid().ToString("N"); private int _syncSequence = 0; private int _localMutationVersion = 0; @@ -52,6 +70,7 @@ public class Main : Node2D public override void _Ready() { + Instance = this; GD.Print("遊戲開始了,正在讀取登入畫面..."); SetProcess(true); GetTree().Connect("node_added", this, nameof(OnSceneNodeAdded)); @@ -331,6 +350,12 @@ private void OnPlayerEnteredRoom(Vector2 roomIndex, string reason) private void HandleLoggedOut() { + string token = _authFlowController?.CurrentSession?.Token; + if (!string.IsNullOrEmpty(token) && _playerDataApiClient != null) + { + _playerDataApiClient.ClearDrops(token, null); + } + _isProfileFetchPending = false; _profileFetchTimeoutTimer?.Stop(); _progressIndicator?.SetState(ProgressSyncIndicator.SyncState.Hidden); @@ -352,7 +377,27 @@ private void HandleLoggedOut() GD.Print("[Main] Logged out - all game states cleaned"); } - private void TransmitPlayerProfile(string reason) + private void FetchProfileAndRollback() + { + string token = _authFlowController?.CurrentSession?.Token; + if (string.IsNullOrWhiteSpace(token) || _playerDataApiClient == null) + { + return; + } + GD.Print("[ProgressSync] Cheated state or sync error detected. Force-fetching authoritative server profile to rollback..."); + _playerDataApiClient.FetchPlayerProfile(token, res => + { + if (res.NetworkOk && res.IsSuccessStatus(200)) + { + var snapshot = PlayerProfileSnapshot.FromDictionary(res.Data); + GD.Print($"[ProgressSync] Authoritative profile loaded. Level={snapshot.Level}, HP={snapshot.HpCurrent}/{snapshot.HpMax}, EXP={snapshot.Experience}, Gold={snapshot.Gold}. Applying rollback..."); + _player?.ApplyProfile(snapshot); + _pendingClaimIds.Clear(); + } + }); + } + + public void TransmitPlayerProfile(string reason) { if (_player == null || _playerDataApiClient == null || _playerDataApiClient.IsBusy) { @@ -372,7 +417,15 @@ private void TransmitPlayerProfile(string reason) _activeSyncMutationVersion = _localMutationVersion; var payload = snapshot.ToUpdatePayload(_syncSessionId, _syncSequence); payload["reason"] = reason; - GD.Print($"[ProgressSync] Sending profile. reason={reason}, seq={_syncSequence}, level={snapshot.Level}, hp={snapshot.HpCurrent}/{snapshot.HpMax}, exp={snapshot.Experience}, gold={snapshot.Gold}."); + + var claimedArray = new Godot.Collections.Array(); + foreach (var claimId in _pendingClaimIds) + { + claimedArray.Add(claimId); + } + payload["claimed_drops"] = claimedArray; + + GD.Print($"[ProgressSync] Sending profile. reason={reason}, seq={_syncSequence}, level={snapshot.Level}, hp={snapshot.HpCurrent}/{snapshot.HpMax}, exp={snapshot.Experience}, gold={snapshot.Gold}. Claimed drops count: {claimedArray.Count}"); _progressIndicator?.SetState(ProgressSyncIndicator.SyncState.Saving); if (!_playerDataApiClient.UpdatePlayerProfile(token, payload, OnPlayerProfileSynced)) @@ -388,7 +441,8 @@ private void OnPlayerProfileSynced(AuthApiResult result) _progressIndicator?.SetState(ProgressSyncIndicator.SyncState.Hidden); if (!result.NetworkOk || !result.IsSuccessStatus(200) || _player == null) { - GD.PrintErr($"[ProgressSync] Sync failed. NetworkOk={result.NetworkOk}, Code={result.ResponseCode}, playerReady={_player != null}."); + GD.PrintErr($"[ProgressSync] Sync failed. NetworkOk={result.NetworkOk}, Code={result.ResponseCode}, playerReady={_player != null}. Force-rolling back local state..."); + FetchProfileAndRollback(); return; } @@ -403,11 +457,15 @@ private void OnPlayerProfileSynced(AuthApiResult result) } GD.Print($"[ProgressSync] Sync applied. Level={snapshot.Level}, HP={snapshot.HpCurrent}/{snapshot.HpMax}, EXP={snapshot.Experience}, Gold={snapshot.Gold}."); + _pendingClaimIds.Clear(); // Clear claims on successful sync _player.ApplyProfile(snapshot); return; } GD.Print($"[ProgressSync] Sync ignored by server. reason={snapshot.IgnoreReason}."); + // Force-rollback local cheated state to the valid server snapshot + _pendingClaimIds.Clear(); // Clear claims since we roll back + _player.ApplyProfile(snapshot); } private void DestroyPlayableWorld() @@ -657,6 +715,12 @@ private void ReturnToLobby() // Sync to backend so the server has the latest state TransmitPlayerProfile("return_to_lobby"); + string token = _authFlowController?.CurrentSession?.Token; + if (!string.IsNullOrEmpty(token) && _playerDataApiClient != null) + { + _playerDataApiClient.ClearDrops(token, null); + } + DestroyPlayableWorld(); // Rebuild the lobby for another session @@ -736,6 +800,7 @@ private void OnPickupRequested(EquipmentPickup pickup) return; } + ClaimDrop(item.InstanceId); MarkLocalMutation(); EquipmentPreview.Instance?.HidePreview(); targetPickup.QueueFree();