feat(economy): add Bestellsystem mit Lieferzeit
- SupplierService: Bestellungen mit Lieferzeit-Berechnung - OrderTerminal: Spieler-Interaktion zum Aufgeben von Bestellungen - StorageCrateRegistry + StorageCrate.Restock: Server registriert Kisten und füllt sie bei Lieferung wieder auf - GameClock.TotalHours: durchgehende Spielzeit für Lieferzeit-Berechnung - ROADMAP: Bestellsystem-Punkt als erledigt markiert Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,9 @@ namespace AfterHours.Core
|
|||||||
public int Day => _day.Value;
|
public int Day => _day.Value;
|
||||||
public bool IsOpen => _isOpen.Value;
|
public bool IsOpen => _isOpen.Value;
|
||||||
|
|
||||||
|
/// <summary>Durchgehende Spielzeit in Stunden seit Tag 1, 0 Uhr. Für Lieferzeiten o. ä.</summary>
|
||||||
|
public float TotalHours => (_day.Value - 1) * 24f + _hourOfDay.Value;
|
||||||
|
|
||||||
/// <summary>Feuert serverseitig beim Tageswechsel. Für Abrechnung, Lieferungen, Miete.</summary>
|
/// <summary>Feuert serverseitig beim Tageswechsel. Für Abrechnung, Lieferungen, Miete.</summary>
|
||||||
public event Action<int> DayEnded;
|
public event Action<int> DayEnded;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bestellungen beim Großhandel: Ware wird sofort bezahlt, trifft aber
|
||||||
|
/// erst nach einer Lieferzeit in der passenden Lagerkiste ein. Nur Server.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SupplierService : NetworkBehaviour
|
||||||
|
{
|
||||||
|
public static SupplierService Instance { get; private set; }
|
||||||
|
|
||||||
|
[SerializeField] private ProductCatalog _catalog;
|
||||||
|
[SerializeField] private float _deliveryLeadHours = 4f;
|
||||||
|
|
||||||
|
private readonly List<PurchaseOrder> _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Server: Bestellung aufgeben. False, wenn Produkt unbekannt oder Geld nicht reicht.</summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a5e238d8d78eca84e8dec7321b910cb2
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using AfterHours.Data;
|
||||||
|
using AfterHours.Economy;
|
||||||
|
using AfterHours.Interaction;
|
||||||
|
using Unity.Netcode;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace AfterHours.Shop
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Backroom-Terminal zum Nachbestellen eines festen Produkts beim
|
||||||
|
/// Großhandel. Produkt/Menge sind pro Instanz im Inspector konfiguriert.
|
||||||
|
/// </summary>
|
||||||
|
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} €)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Server: löst eine Bestellung beim SupplierService aus.</summary>
|
||||||
|
public void Interact(NetworkObject actor)
|
||||||
|
{
|
||||||
|
if (!IsServer) return;
|
||||||
|
SupplierService.Instance?.PlaceOrder(_productId, _orderQuantity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 045aa93f3792d4847ab62d54134471e5
|
||||||
@@ -25,7 +25,15 @@ namespace AfterHours.Shop
|
|||||||
|
|
||||||
public override void OnNetworkSpawn()
|
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)
|
public string GetPrompt(ulong clientId)
|
||||||
@@ -45,5 +53,12 @@ namespace AfterHours.Shop
|
|||||||
int taken = hands.TryTake(_productId, want);
|
int taken = hands.TryTake(_productId, want);
|
||||||
_quantity.Value -= taken;
|
_quantity.Value -= taken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Server: Lieferung trifft ein.</summary>
|
||||||
|
public void Restock(int quantity)
|
||||||
|
{
|
||||||
|
if (!IsServer || quantity <= 0) return;
|
||||||
|
_quantity.Value += quantity;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AfterHours.Shop
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Serverseitige Liste aller aktiven Lagerkisten, damit der SupplierService
|
||||||
|
/// eine Lieferung der richtigen Kiste zuordnen kann.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StorageCrateRegistry
|
||||||
|
{
|
||||||
|
public static readonly StorageCrateRegistry Instance = new();
|
||||||
|
|
||||||
|
private readonly List<StorageCrate> _crates = new();
|
||||||
|
|
||||||
|
public void Register(StorageCrate crate) => _crates.Add(crate);
|
||||||
|
|
||||||
|
public void Unregister(StorageCrate crate) => _crates.Remove(crate);
|
||||||
|
|
||||||
|
/// <summary>Erste Kiste, die dieses Produkt führt.</summary>
|
||||||
|
public StorageCrate FindFor(int productId)
|
||||||
|
{
|
||||||
|
foreach (var crate in _crates)
|
||||||
|
if (crate != null && crate.ProductId == productId)
|
||||||
|
return crate;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2d67201bcc28c49428bc6c0ead7f1ff7
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3635a8816e4d1744cb4d319118ae2768
|
||||||
+1
-1
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
## Meilenstein 3 — Substanz
|
## Meilenstein 3 — Substanz
|
||||||
- [ ] Beratungs-Minispiel (Kundenandeutung → passende Kategorie)
|
- [ ] Beratungs-Minispiel (Kundenandeutung → passende Kategorie)
|
||||||
- [ ] Bestellsystem mit Lieferzeit (`SupplierService`, noch nicht angelegt)
|
- [x] Bestellsystem mit Lieferzeit (`SupplierService`, `OrderTerminal`)
|
||||||
- [ ] Tagesabrechnung, Miete, Personalkosten
|
- [ ] Tagesabrechnung, Miete, Personalkosten
|
||||||
- [ ] Laden-Upgrades: Umkleide, Vorhang, zweite Kasse, Regalreihen
|
- [ ] Laden-Upgrades: Umkleide, Vorhang, zweite Kasse, Regalreihen
|
||||||
- [ ] Sichtachsen-System (`ExposedToStreet` automatisch aus Geometrie ableiten)
|
- [ ] Sichtachsen-System (`ExposedToStreet` automatisch aus Geometrie ableiten)
|
||||||
|
|||||||
Reference in New Issue
Block a user