diff --git a/Assets/Scripts/Core/GameClock.cs b/Assets/Scripts/Core/GameClock.cs index 1e62411..eaa846b 100644 --- a/Assets/Scripts/Core/GameClock.cs +++ b/Assets/Scripts/Core/GameClock.cs @@ -24,6 +24,9 @@ namespace AfterHours.Core public int Day => _day.Value; public bool IsOpen => _isOpen.Value; + /// Durchgehende Spielzeit in Stunden seit Tag 1, 0 Uhr. Für Lieferzeiten o. ä. + public float TotalHours => (_day.Value - 1) * 24f + _hourOfDay.Value; + /// Feuert serverseitig beim Tageswechsel. Für Abrechnung, Lieferungen, Miete. public event Action DayEnded; diff --git a/Assets/Scripts/Economy/SupplierService.cs b/Assets/Scripts/Economy/SupplierService.cs new file mode 100644 index 0000000..f4e2b5e --- /dev/null +++ b/Assets/Scripts/Economy/SupplierService.cs @@ -0,0 +1,75 @@ +using System.Collections.Generic; +using AfterHours.Core; +using AfterHours.Data; +using AfterHours.Shop; +using Unity.Netcode; +using UnityEngine; + +namespace AfterHours.Economy +{ + /// + /// Bestellungen beim Großhandel: Ware wird sofort bezahlt, trifft aber + /// erst nach einer Lieferzeit in der passenden Lagerkiste ein. Nur Server. + /// + public sealed class SupplierService : NetworkBehaviour + { + public static SupplierService Instance { get; private set; } + + [SerializeField] private ProductCatalog _catalog; + [SerializeField] private float _deliveryLeadHours = 4f; + + private readonly List _pending = new(); + + private void Awake() => Instance = this; + + public override void OnNetworkDespawn() + { + if (Instance == this) Instance = null; + } + + private void Update() + { + if (!IsServer || GameClock.Instance == null) return; + + float now = GameClock.Instance.TotalHours; + for (int i = _pending.Count - 1; i >= 0; i--) + { + if (now < _pending[i].DeliveryTime) continue; + + Deliver(_pending[i]); + _pending.RemoveAt(i); + } + } + + /// Server: Bestellung aufgeben. False, wenn Produkt unbekannt oder Geld nicht reicht. + public bool PlaceOrder(int productId, int quantity) + { + if (!IsServer || quantity <= 0) return false; + if (!_catalog.TryGet(productId, out var product)) return false; + + float cost = product.WholesalePrice * quantity; + if (!ShopEconomy.Instance.TrySpend(cost)) return false; + + var order = new PurchaseOrder { DeliveryTime = GameClock.Instance.TotalHours + _deliveryLeadHours }; + order.Add(productId, quantity, product.WholesalePrice); + _pending.Add(order); + return true; + } + + private void Deliver(PurchaseOrder order) + { + order.Delivered = true; + + foreach (var line in order.Lines) + { + var crate = StorageCrateRegistry.Instance.FindFor(line.ProductId); + if (crate == null) + { + Debug.LogWarning($"[SupplierService] Keine Lagerkiste für ProductId {line.ProductId} — Lieferung verworfen."); + continue; + } + crate.Restock(line.Quantity); + } + } + } +} diff --git a/Assets/Scripts/Economy/SupplierService.cs.meta b/Assets/Scripts/Economy/SupplierService.cs.meta new file mode 100644 index 0000000..8fadc12 --- /dev/null +++ b/Assets/Scripts/Economy/SupplierService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a5e238d8d78eca84e8dec7321b910cb2 \ No newline at end of file diff --git a/Assets/Scripts/Shop/OrderTerminal.cs b/Assets/Scripts/Shop/OrderTerminal.cs new file mode 100644 index 0000000..fc3a7b6 --- /dev/null +++ b/Assets/Scripts/Shop/OrderTerminal.cs @@ -0,0 +1,34 @@ +using AfterHours.Data; +using AfterHours.Economy; +using AfterHours.Interaction; +using Unity.Netcode; +using UnityEngine; + +namespace AfterHours.Shop +{ + /// + /// Backroom-Terminal zum Nachbestellen eines festen Produkts beim + /// Großhandel. Produkt/Menge sind pro Instanz im Inspector konfiguriert. + /// + public sealed class OrderTerminal : NetworkBehaviour, IInteractable + { + [SerializeField] private ProductCatalog _catalog; + [SerializeField] private int _productId = -1; + [SerializeField] private int _orderQuantity = 50; + + public string GetPrompt(ulong clientId) + { + var product = _catalog.Get(_productId); + string name = product != null ? product.DisplayName : "?"; + float cost = product != null ? product.WholesalePrice * _orderQuantity : 0f; + return $"{name} nachbestellen ({_orderQuantity} Stk. — {cost:0.00} €)"; + } + + /// Server: löst eine Bestellung beim SupplierService aus. + public void Interact(NetworkObject actor) + { + if (!IsServer) return; + SupplierService.Instance?.PlaceOrder(_productId, _orderQuantity); + } + } +} diff --git a/Assets/Scripts/Shop/OrderTerminal.cs.meta b/Assets/Scripts/Shop/OrderTerminal.cs.meta new file mode 100644 index 0000000..7bb75db --- /dev/null +++ b/Assets/Scripts/Shop/OrderTerminal.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 045aa93f3792d4847ab62d54134471e5 \ No newline at end of file diff --git a/Assets/Scripts/Shop/StorageCrate.cs b/Assets/Scripts/Shop/StorageCrate.cs index 5abde2c..e730dc7 100644 --- a/Assets/Scripts/Shop/StorageCrate.cs +++ b/Assets/Scripts/Shop/StorageCrate.cs @@ -25,7 +25,15 @@ namespace AfterHours.Shop public override void OnNetworkSpawn() { - if (IsServer) _quantity.Value = _startingQuantity; + if (!IsServer) return; + + _quantity.Value = _startingQuantity; + StorageCrateRegistry.Instance.Register(this); + } + + public override void OnNetworkDespawn() + { + if (IsServer) StorageCrateRegistry.Instance.Unregister(this); } public string GetPrompt(ulong clientId) @@ -45,5 +53,12 @@ namespace AfterHours.Shop int taken = hands.TryTake(_productId, want); _quantity.Value -= taken; } + + /// Server: Lieferung trifft ein. + public void Restock(int quantity) + { + if (!IsServer || quantity <= 0) return; + _quantity.Value += quantity; + } } } diff --git a/Assets/Scripts/Shop/StorageCrateRegistry.cs b/Assets/Scripts/Shop/StorageCrateRegistry.cs new file mode 100644 index 0000000..63c3f03 --- /dev/null +++ b/Assets/Scripts/Shop/StorageCrateRegistry.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; + +namespace AfterHours.Shop +{ + /// + /// Serverseitige Liste aller aktiven Lagerkisten, damit der SupplierService + /// eine Lieferung der richtigen Kiste zuordnen kann. + /// + public sealed class StorageCrateRegistry + { + public static readonly StorageCrateRegistry Instance = new(); + + private readonly List _crates = new(); + + public void Register(StorageCrate crate) => _crates.Add(crate); + + public void Unregister(StorageCrate crate) => _crates.Remove(crate); + + /// Erste Kiste, die dieses Produkt führt. + public StorageCrate FindFor(int productId) + { + foreach (var crate in _crates) + if (crate != null && crate.ProductId == productId) + return crate; + return null; + } + } +} diff --git a/Assets/Scripts/Shop/StorageCrateRegistry.cs.meta b/Assets/Scripts/Shop/StorageCrateRegistry.cs.meta new file mode 100644 index 0000000..f0f248a --- /dev/null +++ b/Assets/Scripts/Shop/StorageCrateRegistry.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2d67201bcc28c49428bc6c0ead7f1ff7 \ No newline at end of file diff --git a/Assets/Scripts/Tests/EditMode/PurchaseOrderTests.cs b/Assets/Scripts/Tests/EditMode/PurchaseOrderTests.cs new file mode 100644 index 0000000..ce72105 --- /dev/null +++ b/Assets/Scripts/Tests/EditMode/PurchaseOrderTests.cs @@ -0,0 +1,37 @@ +using AfterHours.Economy; +using NUnit.Framework; + +namespace AfterHours.Tests +{ + public sealed class PurchaseOrderTests + { + [Test] + public void Total_SumsAllLines() + { + var order = new PurchaseOrder(); + order.Add(1, 10, 2f); + order.Add(2, 5, 4f); + + Assert.AreEqual(40f, order.Total(), 0.0001f); + } + + [Test] + public void Add_IgnoresNonPositiveQuantity() + { + var order = new PurchaseOrder(); + order.Add(1, 0, 5f); + order.Add(1, -3, 5f); + + Assert.AreEqual(0, order.Lines.Count); + } + + [Test] + public void LineTotal_IsQuantityTimesUnitPrice() + { + var order = new PurchaseOrder(); + order.Add(7, 3, 2.5f); + + Assert.AreEqual(7.5f, order.Lines[0].LineTotal, 0.0001f); + } + } +} diff --git a/Assets/Scripts/Tests/EditMode/PurchaseOrderTests.cs.meta b/Assets/Scripts/Tests/EditMode/PurchaseOrderTests.cs.meta new file mode 100644 index 0000000..dee18d0 --- /dev/null +++ b/Assets/Scripts/Tests/EditMode/PurchaseOrderTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3635a8816e4d1744cb4d319118ae2768 \ No newline at end of file diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f83d213..e949277 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -21,7 +21,7 @@ ## Meilenstein 3 — Substanz - [ ] Beratungs-Minispiel (Kundenandeutung → passende Kategorie) -- [ ] Bestellsystem mit Lieferzeit (`SupplierService`, noch nicht angelegt) +- [x] Bestellsystem mit Lieferzeit (`SupplierService`, `OrderTerminal`) - [ ] Tagesabrechnung, Miete, Personalkosten - [ ] Laden-Upgrades: Umkleide, Vorhang, zweite Kasse, Regalreihen - [ ] Sichtachsen-System (`ExposedToStreet` automatisch aus Geometrie ableiten)