diff --git a/Assets/_Project/Prefabs/Player/Player.prefab b/Assets/_Project/Prefabs/Player/Player.prefab
index 08614f3..6e06dfd 100644
--- a/Assets/_Project/Prefabs/Player/Player.prefab
+++ b/Assets/_Project/Prefabs/Player/Player.prefab
@@ -18,6 +18,7 @@ GameObject:
- component: {fileID: 2806524246861401801}
- component: {fileID: 5683786710272852339}
- component: {fileID: 6856236869205671849}
+ - component: {fileID: -4438158474568445526}
m_Layer: 0
m_Name: Player
m_TagString: Untagged
@@ -173,3 +174,16 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::TD.Gameplay.PlayerSpellLoadout
ShowTopMostFoldoutHeaderGroup: 1
+--- !u!114 &-4438158474568445526
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 3493329038866903420}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: 65825b3189ae873948659b37b219f5e6, type: 3}
+ m_Name:
+ m_EditorClassIdentifier: Assembly-CSharp::TD.Dev.DebugCommandRelay
+ ShowTopMostFoldoutHeaderGroup: 1
diff --git a/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs b/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs
new file mode 100644
index 0000000..3af3076
--- /dev/null
+++ b/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs
@@ -0,0 +1,78 @@
+// Assets/_Project/Scripts/Dev/DebugCommandRelay.cs
+using System.Collections.Generic;
+using Unity.Netcode;
+using UnityEngine;
+using TD.Gameplay.Draft;
+
+namespace TD.Dev
+{
+ ///
+ /// Debug-only network relay: exposes one RPC that force-applies a
+ /// by its index, bypassing PlayerDraft's "must currently be
+ /// offered" check.
+ ///
+ ///
+ /// Lives entirely separate from PlayerDraft/DraftOption/DraftPool — none of those files
+ /// know this component exists. Removing this component (and TD.Dev.DebugConsole, which
+ /// calls it) leaves the shipping draft system completely untouched.
+ ///
+ /// Added to the Player prefab alongside PlayerDraft/PlayerGoldManager/etc. so it spawns
+ /// with a per-player OwnerClientId. Static per-client registry mirrors PlayerDraft's
+ /// GetForClient/Local pattern.
+ ///
+ public class DebugCommandRelay : NetworkBehaviour
+ {
+ private static readonly Dictionary s_byClientId
+ = new Dictionary();
+
+ public static DebugCommandRelay GetForClient(ulong clientId)
+ {
+ s_byClientId.TryGetValue(clientId, out var relay);
+ return relay;
+ }
+
+ public static DebugCommandRelay Local
+ {
+ get
+ {
+ var nm = NetworkManager.Singleton;
+ if (nm == null || !nm.IsClient) return null;
+ return GetForClient(nm.LocalClientId);
+ }
+ }
+
+ public override void OnNetworkSpawn()
+ {
+ s_byClientId[OwnerClientId] = this;
+ }
+
+ public override void OnNetworkDespawn()
+ {
+ if (s_byClientId.TryGetValue(OwnerClientId, out var registered) && registered == this)
+ s_byClientId.Remove(OwnerClientId);
+ }
+
+ ///
+ /// Owning client: force-apply the DraftOption at (its
+ /// DraftPool index) immediately, skipping the normal "must be currently offered" gate.
+ /// Debug/testing only — any connected client can grant themselves anything via this
+ /// RPC, same tradeoff DevWaveControls already accepts for its hotkeys.
+ ///
+ [Rpc(SendTo.Server, InvokePermission = RpcInvokePermission.Owner)]
+ public void DebugGrantDraftOptionRpc(int optionId)
+ {
+ var option = DraftPool.Instance != null ? DraftPool.Instance.Get(optionId) : null;
+ if (option == null)
+ {
+ Debug.LogWarning($"[DebugCommandRelay] Grant requested for unknown option id {optionId}.");
+ return;
+ }
+
+ if (option.ServerApply(OwnerClientId))
+ Debug.Log($"[DebugCommandRelay] Granted '{option.DisplayName}' to client {OwnerClientId}.");
+ else
+ Debug.LogWarning($"[DebugCommandRelay] '{option.DisplayName}' could not be applied " +
+ $"to client {OwnerClientId}.");
+ }
+ }
+}
diff --git a/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs.meta b/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs.meta
new file mode 100644
index 0000000..d7a2598
--- /dev/null
+++ b/Assets/_Project/Scripts/Dev/DebugCommandRelay.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 65825b3189ae873948659b37b219f5e6
\ No newline at end of file
diff --git a/Assets/_Project/Scripts/Dev/DebugConsole.cs b/Assets/_Project/Scripts/Dev/DebugConsole.cs
index d88ca20..869be74 100644
--- a/Assets/_Project/Scripts/Dev/DebugConsole.cs
+++ b/Assets/_Project/Scripts/Dev/DebugConsole.cs
@@ -1,9 +1,13 @@
// Assets/_Project/Scripts/Dev/DebugConsole.cs
+using System;
using System.Collections;
+using System.Collections.Generic;
+using System.Text;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.UIElements;
using TD.UI;
+using TD.Gameplay.Draft;
namespace TD.Dev
{
@@ -17,9 +21,9 @@ namespace TD.Dev
/// a build entirely by not including that GameObject, the same way
/// is kept separate from shipping systems.
///
- /// This first pass only wires up open/close/focus/text-capture. Submit
- /// currently just logs the typed text — command parsing/dispatch (e.g.
- /// unlocking spells) comes next.
+ /// Supports one command so far: "get <type> <name>" grants a
+ /// to the local player via
+ /// .
///
[RequireComponent(typeof(UIDocument))]
public class DebugConsole : MonoBehaviour
@@ -204,15 +208,93 @@ namespace TD.Dev
HUDController.SuppressChatOpenUntilFrame = Time.frameCount + 1;
}
- // Placeholder — command parsing/dispatch (unlocking spells, granting gold,
- // etc.) comes next. For now this just proves the text made it through.
private void SubmitCommand()
{
string text = commandInput?.value ?? string.Empty;
- if (!string.IsNullOrWhiteSpace(text))
- Debug.Log($"[DebugConsole] Command entered: \"{text}\"");
-
CloseConsole();
+
+ if (string.IsNullOrWhiteSpace(text)) return;
+ ExecuteCommand(text.Trim());
+ }
+
+ // Maps the type token in "get " to the DraftOption subclass it
+ // should match against. Add an entry here whenever a new DraftOption subclass
+ // should be gettable from the console.
+ private static readonly Dictionary DraftTypeAliases =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ { "spell", typeof(BuilderSpellDraftOption) },
+ { "tower", typeof(NewTowerDraftOption) },
+ { "buff", typeof(BuilderEffectDraftOption) },
+ { "effect", typeof(BuilderEffectDraftOption) },
+ };
+
+ private void ExecuteCommand(string text)
+ {
+ string[] tokens = text.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
+ if (tokens.Length < 3 || !string.Equals(tokens[0], "get", StringComparison.OrdinalIgnoreCase))
+ {
+ Debug.Log($"[DebugConsole] Unrecognized command: \"{text}\". Try: get ");
+ return;
+ }
+
+ string nameToken = string.Join(" ", tokens, 2, tokens.Length - 2);
+ ExecuteGet(tokens[1], nameToken);
+ }
+
+ // "get ": grants any DraftOption of the given type whose DisplayName
+ // matches , via the local player's DebugCommandRelay. Reuses DraftOption's
+ // existing type-agnostic ServerApply dispatch instead of hardcoding a grant path
+ // per option type.
+ private void ExecuteGet(string typeToken, string nameToken)
+ {
+ if (!DraftTypeAliases.TryGetValue(typeToken, out var optionType))
+ {
+ Debug.LogWarning($"[DebugConsole] Unknown draft option type \"{typeToken}\". " +
+ $"Valid types: {string.Join(", ", DraftTypeAliases.Keys)}");
+ return;
+ }
+
+ var pool = DraftPool.Instance;
+ if (pool == null)
+ {
+ Debug.LogWarning("[DebugConsole] No DraftPool in this scene.");
+ return;
+ }
+
+ string normalizedTarget = Normalize(nameToken);
+ for (int i = 0; i < pool.Count; i++)
+ {
+ var option = pool.Get(i);
+ if (option == null || !optionType.IsInstanceOfType(option)) continue;
+ if (Normalize(option.DisplayName) != normalizedTarget) continue;
+
+ var relay = DebugCommandRelay.Local;
+ if (relay == null)
+ {
+ Debug.LogWarning("[DebugConsole] No local DebugCommandRelay found — " +
+ "is it on the Player prefab?");
+ return;
+ }
+
+ relay.DebugGrantDraftOptionRpc(i);
+ return;
+ }
+
+ Debug.LogWarning($"[DebugConsole] No {typeToken} option named \"{nameToken}\" found.");
+ }
+
+ // DisplayName is player-facing draft card text (its spacing/casing is chosen for
+ // presentation, not for typing), so command matching strips everything but letters
+ // and digits before comparing — "Slow Area", "slow_area", and "SlowArea" all match
+ // the same option.
+ private static string Normalize(string s)
+ {
+ if (string.IsNullOrEmpty(s)) return string.Empty;
+ var sb = new StringBuilder(s.Length);
+ foreach (char c in s)
+ if (char.IsLetterOrDigit(c)) sb.Append(char.ToLowerInvariant(c));
+ return sb.ToString();
}
private void OnDestroy()