From ec3da03bba820f1e5abf805bc8cb61654429a8df Mon Sep 17 00:00:00 2001 From: sokol Date: Fri, 20 Feb 2026 21:51:06 +0300 Subject: [PATCH] Refactor products and buildings to support modding --- .gitignore | 1 + backend/src/MyBiz.Core/Building.cs | 190 ++++++++++++---- backend/src/MyBiz.Core/DefaultProducts.cs | 202 ++++++++++++++++++ backend/src/MyBiz.Core/Product.cs | 131 ++++++++---- backend/src/MyBiz.Core/ProductRegistry.cs | 116 ++++++++++ backend/src/MyBiz.Core/ProductType.cs | 105 +++++++++ backend/src/MyBiz.Core/ProductionChain.cs | 102 ++++++++- backend/tests/MyBiz.Tests/BuildingTests.cs | 81 +++++++ .../tests/MyBiz.Tests/DefaultProductsTests.cs | 67 ++++++ backend/tests/MyBiz.Tests/ProductTests.cs | 178 +++++++++++++-- frontend/icon.svg.import | 37 ++++ frontend/project.godot | 14 +- 12 files changed, 1109 insertions(+), 115 deletions(-) create mode 100644 backend/src/MyBiz.Core/DefaultProducts.cs create mode 100644 backend/src/MyBiz.Core/ProductRegistry.cs create mode 100644 backend/src/MyBiz.Core/ProductType.cs create mode 100644 backend/tests/MyBiz.Tests/BuildingTests.cs create mode 100644 backend/tests/MyBiz.Tests/DefaultProductsTests.cs create mode 100644 frontend/icon.svg.import diff --git a/.gitignore b/.gitignore index ac93b02..a998c86 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ export_presets.cfg mono/ *.csproj *.sln +*.slnx obj/ bin/ *.pidb diff --git a/backend/src/MyBiz.Core/Building.cs b/backend/src/MyBiz.Core/Building.cs index bcf2be1..949026a 100644 --- a/backend/src/MyBiz.Core/Building.cs +++ b/backend/src/MyBiz.Core/Building.cs @@ -1,51 +1,29 @@ namespace MyBiz.Core; /// -/// Тип здания +/// Конфигурация типа здания - для моддинга /// -public enum BuildingType +public class BuildingTypeConfig { - // Добыча сырья - Farm, // Ферма - CottonField, // Хлопковое поле - Mine, // Шахта - - // Производство - FoodFactory, // Пищекомбинат - TextileFactory, // Текстильная фабрика - SteelMill, // Сталелитейный завод - PlasticPlant, // Завод пластика - ElectronicsFactory, // Завод электроники - AutoFactory, // Автозавод - - // Торговля - GroceryStore, // Продуктовый магазин - ClothingStore, // Магазин одежды - ElectronicsStore, // Магазин электроники - AutoDealer, // Автосалон - Mall, // Торговый центр - - // Исследования - Laboratory, // Лаборатория - - // Склад - Warehouse // Склад -} - -/// -/// Здание -/// -public class Building -{ - public Guid Id { get; set; } = Guid.NewGuid(); - public BuildingType Type { get; set; } - public string Name { get; set; } = string.Empty; - public string CityId { get; set; } = string.Empty; + /// + /// Уникальный идентификатор типа здания + /// + public string Id { get; set; } = string.Empty; /// - /// Уровень здания (влияет на эффективность) + /// Отображаемое имя /// - public int Level { get; set; } = 1; + public string Name { get; set; } = string.Empty; + + /// + /// Описание + /// + public string Description { get; set; } = string.Empty; + + /// + /// Категория здания + /// + public BuildingCategory Category { get; set; } /// /// Стоимость постройки @@ -67,8 +45,140 @@ public class Building /// public int WorkerSlots { get; set; } + /// + /// Базовая эффективность (0-100) + /// + public int BaseEfficiency { get; set; } = 100; + + /// + /// Максимальный уровень здания + /// + public int MaxLevel { get; set; } = 10; + + /// + /// Иконка здания + /// + public string IconPath { get; set; } = string.Empty; + + /// + /// Требуемые технологии для разблокировки + /// + public List RequiredTechnologies { get; set; } = new(); + + /// + /// Год, когда здание становится доступным + /// + public int AvailableFromYear { get; set; } = 1900; + + /// + /// Производимые продукты (для фабрик) + /// + public List OutputProducts { get; set; } = new(); + + /// + /// Потребляемые продукты (для фабрик) + /// + public List InputProducts { get; set; } = new(); +} + +/// +/// Категория здания +/// +public enum BuildingCategory +{ + RawMaterial, // Добыча сырья + Production, // Производство + Trade, // Торговля + Research, // Исследования + Storage, // Склад + Office // Офис +} + +/// +/// Здание - экземпляр в игре +/// +public class Building +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Конфигурация типа здания + /// + public BuildingTypeConfig TypeConfig { get; set; } = null!; + + /// + /// Отображаемое имя (можно кастомизировать) + /// + public string Name { get; set; } = string.Empty; + + /// + /// ID города, где находится здание + /// + public string CityId { get; set; } = string.Empty; + + /// + /// Координаты на карте + /// + public int X { get; set; } + public int Y { get; set; } + + /// + /// Уровень здания (влияет на эффективность) + /// + public int Level { get; set; } = 1; + + /// + /// Текущая эффективность (зависит от уровня и рабочих) + /// + public int CurrentEfficiency { get; set; } + + /// + /// Количество рабочих мест + /// + public int WorkerSlots => TypeConfig.WorkerSlots; + /// /// Заполненность рабочими /// public int Workers { get; set; } + + /// + /// Вместимость склада + /// + public int StorageCapacity => TypeConfig.StorageCapacity; + + /// + /// Текущие запасы на складе + /// + public Dictionary Inventory { get; set; } = new(); + + /// + /// Здание активно/работает + /// + public bool IsActive { get; set; } = true; + + /// + /// Построено в тик + /// + public int BuiltAtTick { get; set; } + + /// + /// Рассчитать текущую эффективность + /// + public void CalculateEfficiency() + { + int baseEff = TypeConfig.BaseEfficiency; + int levelBonus = (Level - 1) * 5; // +5% за уровень + int workerPenalty = WorkerSlots > 0 ? ((WorkerSlots - Workers) * 100 / WorkerSlots) : 0; + + CurrentEfficiency = Math.Max(0, baseEff + levelBonus - workerPenalty); + } + + /// + /// Обновить состояние + /// + public void Update() + { + CalculateEfficiency(); + } } diff --git a/backend/src/MyBiz.Core/DefaultProducts.cs b/backend/src/MyBiz.Core/DefaultProducts.cs new file mode 100644 index 0000000..cf3d90d --- /dev/null +++ b/backend/src/MyBiz.Core/DefaultProducts.cs @@ -0,0 +1,202 @@ +using MyBiz.Core; + +namespace MyBiz.Core; + +/// +/// Дефолтные конфигурации продуктов +/// +public static class DefaultProducts +{ + public static IEnumerable GetAll() + { + return new List + { + // === СЫРЬЁ === + + new ProductType + { + Id = "raw_cotton", + Name = "Хлопок", + Description = "Сырьё для производства ткани", + Category = ProductCategory.RawMaterial, + BasePrice = 5m, + BaseDemand = 50, + DemandElasticity = 0.3f, + StackSize = 500, + AvailableFromYear = 1900, + IconPath = "res://assets/sprites/products/cotton.png" + }, + + new ProductType + { + Id = "raw_steel", + Name = "Сталь", + Description = "Основной металл для промышленности", + Category = ProductCategory.RawMaterial, + BasePrice = 15m, + BaseDemand = 80, + DemandElasticity = 0.4f, + StackSize = 200, + AvailableFromYear = 1900, + IconPath = "res://assets/sprites/products/steel.png" + }, + + new ProductType + { + Id = "raw_plastic", + Name = "Пластик", + Description = "Синтетический материал", + Category = ProductCategory.RawMaterial, + BasePrice = 8m, + BaseDemand = 60, + DemandElasticity = 0.5f, + StackSize = 300, + AvailableFromYear = 1950, + IconPath = "res://assets/sprites/products/plastic.png" + }, + + new ProductType + { + Id = "raw_food", + Name = "Сельхозпродукция", + Description = "Пшеница, овощи, фрукты", + Category = ProductCategory.RawMaterial, + BasePrice = 3m, + BaseDemand = 100, + DemandElasticity = 0.2f, + ShelfLife = 10, + StackSize = 500, + AvailableFromYear = 1900, + IconPath = "res://assets/sprites/products/food_raw.png" + }, + + // === КОМПОНЕНТЫ === + + new ProductType + { + Id = "comp_fabric", + Name = "Ткань", + Description = "Материал для пошива одежды", + Category = ProductCategory.Component, + BasePrice = 12m, + BaseDemand = 40, + DemandElasticity = 0.6f, + StackSize = 200, + AvailableFromYear = 1900, + IconPath = "res://assets/sprites/products/fabric.png" + }, + + new ProductType + { + Id = "comp_metal_parts", + Name = "Металлоизделия", + Description = "Детали и запчасти из металла", + Category = ProductCategory.Component, + BasePrice = 25m, + BaseDemand = 50, + DemandElasticity = 0.5f, + StackSize = 100, + AvailableFromYear = 1900, + IconPath = "res://assets/sprites/products/metal_parts.png" + }, + + new ProductType + { + Id = "comp_plastic_parts", + Name = "Пластиковые детали", + Description = "Компоненты из пластика", + Category = ProductCategory.Component, + BasePrice = 18m, + BaseDemand = 45, + DemandElasticity = 0.5f, + StackSize = 150, + AvailableFromYear = 1950, + IconPath = "res://assets/sprites/products/plastic_parts.png" + }, + + new ProductType + { + Id = "comp_electronics", + Name = "Электронные компоненты", + Description = "Микросхемы, транзисторы, платы", + Category = ProductCategory.Component, + BasePrice = 50m, + BaseDemand = 30, + DemandElasticity = 0.7f, + StackSize = 50, + AvailableFromYear = 1960, + IconPath = "res://assets/sprites/products/electronics.png" + }, + + // === ТОВАРЫ === + + new ProductType + { + Id = "goods_food", + Name = "Продукты питания", + Description = "Готовая еда для потребителей", + Category = ProductCategory.ConsumerGoods, + BasePrice = 8m, + BaseDemand = 150, + DemandElasticity = 0.3f, + ShelfLife = 5, + StackSize = 200, + AvailableFromYear = 1900, + IconPath = "res://assets/sprites/products/food.png" + }, + + new ProductType + { + Id = "goods_clothing", + Name = "Одежда", + Description = "Одежда и обувь", + Category = ProductCategory.ConsumerGoods, + BasePrice = 35m, + BaseDemand = 60, + DemandElasticity = 0.8f, + StackSize = 50, + AvailableFromYear = 1900, + IconPath = "res://assets/sprites/products/clothing.png" + }, + + new ProductType + { + Id = "goods_electronics", + Name = "Электроника", + Description = "Бытовая техника и гаджеты", + Category = ProductCategory.ConsumerGoods, + BasePrice = 150m, + BaseDemand = 25, + DemandElasticity = 0.9f, + StackSize = 20, + AvailableFromYear = 1960, + IconPath = "res://assets/sprites/products/electronics_consumer.png" + }, + + new ProductType + { + Id = "goods_automobile", + Name = "Автомобили", + Description = "Легковые автомобили", + Category = ProductCategory.ConsumerGoods, + BasePrice = 5000m, + BaseDemand = 10, + DemandElasticity = 1.0f, + StackSize = 5, + AvailableFromYear = 1920, + IconPath = "res://assets/sprites/products/automobile.png" + } + }; + } + + /// + /// Зарегистрировать все дефолтные продукты в реестре + /// + public static void RegisterAll(ProductRegistry registry) + { + foreach (var product in GetAll()) + { + registry.Register(product); + } + } +} diff --git a/backend/src/MyBiz.Core/Product.cs b/backend/src/MyBiz.Core/Product.cs index dbeff02..07326ae 100644 --- a/backend/src/MyBiz.Core/Product.cs +++ b/backend/src/MyBiz.Core/Product.cs @@ -1,65 +1,104 @@ namespace MyBiz.Core; /// -/// Категория продукта -/// -public enum ProductCategory -{ - RawMaterial, // Сырьё - Component, // Компоненты - ConsumerGoods // Товары народного потребления -} - -/// -/// Тип продукта -/// -public enum ProductType -{ - // Сырьё - Cotton, // Хлопок - Steel, // Сталь - Plastic, // Пластик - FoodRaw, // Сырьё для еды - - // Компоненты - Fabric, // Ткань - MetalParts, // Металлические детали - PlasticParts, // Пластиковые детали - ElectronicsComponents, // Электронные компоненты - - // Товары - Food, // Еда - Clothing, // Одежда - Electronics, // Электроника - Automobile // Автомобили -} - -/// -/// Продукт +/// Продукт - экземпляр товара в игре /// public class Product { - public ProductType Type { get; set; } - public string Name { get; set; } = string.Empty; - public ProductCategory Category { get; set; } - /// - /// Базовая цена продукта + /// Уникальный идентификатор экземпляра продукта /// - public decimal BasePrice { get; set; } + public Guid Id { get; set; } = Guid.NewGuid(); /// - /// Текущая цена (с учётом спроса/предложения) + /// Тип продукта (ссылка на конфигурацию) + /// + public ProductType Type { get; set; } = null!; + + /// + /// Количество продукта + /// + public int Quantity { get; set; } + + /// + /// Текущая цена за единицу /// public decimal CurrentPrice { get; set; } /// - /// Доступное количество на рынке + /// Дата создания (для отслеживания срока годности) /// - public int AvailableQuantity { get; set; } + public int CreatedAtTick { get; set; } /// - /// Спрос на продукт + /// Качество продукта (0-100, 100 = идеальное) /// - public int Demand { get; set; } + public int Quality { get; set; } = 100; + + /// + /// Проверка: испорчен ли продукт + /// + public bool IsSpoiled { get; set; } = false; + + /// + /// Общая стоимость продукта + /// + public decimal TotalValue => Quantity * CurrentPrice; + + /// + /// Проверка: может ли продукт быть использован/продан + /// + public bool IsUsable => Quantity > 0 && !IsSpoiled; + + /// + /// Добавить количество + /// + public void Add(int amount) + { + if (amount > 0) + { + Quantity += amount; + } + } + + /// + /// Удалить количество + /// + /// Фактически удалённое количество + public int Remove(int amount) + { + if (amount <= 0 || Quantity <= 0) + return 0; + + int removed = Math.Min(amount, Quantity); + Quantity -= removed; + + if (Quantity <= 0) + { + IsSpoiled = true; + } + + return removed; + } + + /// + /// Обновить состояние (проверка срока годности) + /// + public void Update(int currentTick) + { + if (Type.IsPerishable) + { + int age = currentTick - CreatedAtTick; + if (age >= Type.ShelfLife) + { + IsSpoiled = true; + } + + // Ухудшение качества со временем + if (age > 0 && Type.ShelfLife > 0) + { + Quality = Math.Max(0, 100 - (age * 100 / Type.ShelfLife)); + } + } + } } diff --git a/backend/src/MyBiz.Core/ProductRegistry.cs b/backend/src/MyBiz.Core/ProductRegistry.cs new file mode 100644 index 0000000..2163b27 --- /dev/null +++ b/backend/src/MyBiz.Core/ProductRegistry.cs @@ -0,0 +1,116 @@ +namespace MyBiz.Core; + +/// +/// Реестр типов продуктов - центрлизованное хранилище конфигураций +/// Поддерживает моддинг через загрузку внешних конфигураций +/// +public class ProductRegistry +{ + private readonly Dictionary _productTypes = new(); + + /// + /// Все зарегистрированные типы продуктов + /// + public IEnumerable AllProductTypes => _productTypes.Values; + + /// + /// Количество зарегистрированных типов + /// + public int Count => _productTypes.Count; + + /// + /// Событие: добавлен новый тип продукта (для моддинга) + /// + public event Action? ProductTypeAdded; + + /// + /// Событие: изменён тип продукта + /// + public event Action? ProductTypeModified; + + /// + /// Зарегистрировать тип продукта + /// + public void Register(ProductType productType) + { + if (string.IsNullOrEmpty(productType.Id)) + throw new ArgumentException("Product type must have an Id", nameof(productType)); + + if (_productTypes.ContainsKey(productType.Id)) + throw new InvalidOperationException($"Product type '{productType.Id}' is already registered"); + + _productTypes[productType.Id] = productType; + ProductTypeAdded?.Invoke(productType); + } + + /// + /// Получить тип продукта по ID + /// + public ProductType? GetById(string id) + { + return _productTypes.TryGetValue(id, out var type) ? type : null; + } + + /// + /// Получить тип продукта или выбросить исключение + /// + public ProductType GetOrThrow(string id) + { + return GetById(id) ?? throw new KeyNotFoundException($"Product type '{id}' not found"); + } + + /// + /// Проверка: существует ли тип продукта + /// + public bool Exists(string id) + { + return _productTypes.ContainsKey(id); + } + + /// + /// Получить продукты по категории + /// + public IEnumerable GetByCategory(ProductCategory category) + { + return _productTypes.Values.Where(p => p.Category == category); + } + + /// + /// Получить продукты, доступные в указанном году + /// + public IEnumerable GetAvailableInYear(int year) + { + return _productTypes.Values.Where(p => p.AvailableFromYear <= year); + } + + /// + /// Удалить тип продукта (для моддинга) + /// + public bool Remove(string id) + { + if (_productTypes.Remove(id)) + { + ProductTypeModified?.Invoke(new ProductType { Id = id }); + return true; + } + return false; + } + + /// + /// Загрузить типы продуктов из конфигурации (JSON и т.п.) + /// + public void LoadFromConfig(string jsonConfig) + { + // TODO: Реализовать загрузку из JSON + // Это позволит модам добавлять свои продукты + } + + /// + /// Экспортировать все типы в JSON (для моддинга) + /// + public string ExportToJson() + { + // TODO: Реализовать экспорт в JSON + return "{}"; + } +} diff --git a/backend/src/MyBiz.Core/ProductType.cs b/backend/src/MyBiz.Core/ProductType.cs new file mode 100644 index 0000000..cc392a2 --- /dev/null +++ b/backend/src/MyBiz.Core/ProductType.cs @@ -0,0 +1,105 @@ +namespace MyBiz.Core; + +/// +/// Категория продукта +/// +public enum ProductCategory +{ + RawMaterial, // Сырьё + Component, // Компоненты + ConsumerGoods, // Товары народного потребления + Luxury // Предметы роскоши (для будущего) +} + +/// +/// Тип продукта - конфигурация для моддинга +/// +public class ProductType +{ + /// + /// Уникальный идентификатор типа продукта (для моддинга) + /// + public string Id { get; set; } = string.Empty; + + /// + /// Отображаемое имя + /// + public string Name { get; set; } = string.Empty; + + /// + /// Описание + /// + public string Description { get; set; } = string.Empty; + + /// + /// Категория продукта + /// + public ProductCategory Category { get; set; } + + /// + /// Базовая цена продукта + /// + public decimal BasePrice { get; set; } + + /// + /// Базовый спрос (единиц в тик) + /// + public int BaseDemand { get; set; } + + /// + /// Эластичность спроса по цене (0-1, где 1 - высокая эластичность) + /// + public float DemandElasticity { get; set; } = 0.5f; + + /// + /// Срок хранения (тиков), 0 = бессрочно + /// + public int ShelfLife { get; set; } = 0; + + /// + /// Размер стека (для инвентаря) + /// + public int StackSize { get; set; } = 100; + + /// + /// Иконка продукта (путь к ресурсу) + /// + public string IconPath { get; set; } = string.Empty; + + /// + /// Требуемые технологии для разблокировки (пусто = доступно сразу) + /// + public List RequiredTechnologies { get; set; } = new(); + + /// + /// Год, когда продукт становится доступным (для исторического режима) + /// + public int AvailableFromYear { get; set; } = 1900; + + /// + /// Может ли продукт быть испорчен + /// + public bool IsPerishable => ShelfLife > 0; + + /// + /// Создать копию типа продукта + /// + public ProductType Clone() + { + return new ProductType + { + Id = this.Id, + Name = this.Name, + Description = this.Description, + Category = this.Category, + BasePrice = this.BasePrice, + BaseDemand = this.BaseDemand, + DemandElasticity = this.DemandElasticity, + ShelfLife = this.ShelfLife, + StackSize = this.StackSize, + IconPath = this.IconPath, + RequiredTechnologies = new List(this.RequiredTechnologies), + AvailableFromYear = this.AvailableFromYear + }; + } +} diff --git a/backend/src/MyBiz.Core/ProductionChain.cs b/backend/src/MyBiz.Core/ProductionChain.cs index 8a80870..db93e33 100644 --- a/backend/src/MyBiz.Core/ProductionChain.cs +++ b/backend/src/MyBiz.Core/ProductionChain.cs @@ -6,9 +6,9 @@ namespace MyBiz.Core; public class ProductionStep { /// - /// Требуемый продукт + /// ID требуемого продукта /// - public ProductType InputProduct { get; set; } + public string InputProductId { get; set; } = string.Empty; /// /// Количество требуемого продукта @@ -22,16 +22,104 @@ public class ProductionStep } /// -/// Производственная цепочка +/// Конфигурация производственной цепочки - для моддинга /// -public class ProductionChain +public class ProductionChainConfig { - public ProductType OutputProduct { get; set; } - public BuildingType RequiredBuilding { get; set; } - public List Steps { get; set; } = new(); + /// + /// Уникальный идентификатор цепочки + /// + public string Id { get; set; } = string.Empty; + + /// + /// Название цепочки + /// + public string Name { get; set; } = string.Empty; + + /// + /// ID выходного продукта + /// + public string OutputProductId { get; set; } = string.Empty; /// /// Количество выходного продукта за цикл /// public int OutputQuantity { get; set; } + + /// + /// ID требуемого здания + /// + public string RequiredBuildingId { get; set; } = string.Empty; + + /// + /// Шаги производства + /// + public List Steps { get; set; } = new(); + + /// + /// Требуемые технологии + /// + public List RequiredTechnologies { get; set; } = new(); + + /// + /// Год, когда цепочка становится доступной + /// + public int AvailableFromYear { get; set; } = 1900; +} + +/// +/// Активная производственная цепочка на здании +/// +public class ActiveProductionChain +{ + public ProductionChainConfig Config { get; set; } = null!; + public Building Building { get; set; } = null!; + + /// + /// Текущий шаг производства + /// + public int CurrentStep { get; set; } = 0; + + /// + /// Прогресс текущего шага (в тиках) + /// + public int Progress { get; set; } = 0; + + /// + /// Цепочка активна + /// + public bool IsActive { get; set; } = true; + + /// + /// Запущена в тик + /// + public int StartedAtTick { get; set; } + + /// + /// Проверка: завершён ли текущий шаг + /// + public bool IsStepComplete => CurrentStep < Config.Steps.Count && + Progress >= Config.Steps[CurrentStep].ProductionTime; + + /// + /// Проверка: завершено ли всё производство + /// + public bool IsComplete => CurrentStep >= Config.Steps.Count; + + /// + /// Продвинуть производство на 1 тик + /// + public void Tick() + { + if (!IsActive || IsComplete) + return; + + Progress++; + + if (IsStepComplete) + { + CurrentStep++; + Progress = 0; + } + } } diff --git a/backend/tests/MyBiz.Tests/BuildingTests.cs b/backend/tests/MyBiz.Tests/BuildingTests.cs new file mode 100644 index 0000000..8326bed --- /dev/null +++ b/backend/tests/MyBiz.Tests/BuildingTests.cs @@ -0,0 +1,81 @@ +using MyBiz.Core; + +namespace MyBiz.Tests; + +public class BuildingTests +{ + [Fact] + public void Building_CalculateEfficiency_FullWorkers_ShouldBeMax() + { + // Arrange + var config = new BuildingTypeConfig + { + Id = "factory", + BaseEfficiency = 100, + WorkerSlots = 10 + }; + + var building = new Building + { + TypeConfig = config, + Workers = 10, + Level = 1 + }; + + // Act + building.CalculateEfficiency(); + + // Assert + Assert.Equal(100, building.CurrentEfficiency); + } + + [Fact] + public void Building_CalculateEfficiency_NoWorkers_ShouldBeZero() + { + // Arrange + var config = new BuildingTypeConfig + { + Id = "factory", + BaseEfficiency = 100, + WorkerSlots = 10 + }; + + var building = new Building + { + TypeConfig = config, + Workers = 0, + Level = 1 + }; + + // Act + building.CalculateEfficiency(); + + // Assert + Assert.Equal(0, building.CurrentEfficiency); + } + + [Fact] + public void Building_LevelBonus_ShouldIncreaseEfficiency() + { + // Arrange + var config = new BuildingTypeConfig + { + Id = "factory", + BaseEfficiency = 100, + WorkerSlots = 10 + }; + + var building = new Building + { + TypeConfig = config, + Workers = 10, + Level = 5 + }; + + // Act + building.CalculateEfficiency(); + + // Assert + Assert.Equal(120, building.CurrentEfficiency); // 100 + (4 * 5) + } +} diff --git a/backend/tests/MyBiz.Tests/DefaultProductsTests.cs b/backend/tests/MyBiz.Tests/DefaultProductsTests.cs new file mode 100644 index 0000000..7313a55 --- /dev/null +++ b/backend/tests/MyBiz.Tests/DefaultProductsTests.cs @@ -0,0 +1,67 @@ +using MyBiz.Core; + +namespace MyBiz.Tests; + +public class DefaultProductsTests +{ + [Fact] + public void DefaultProducts_GetAll_ShouldReturn12Products() + { + // Act + var products = DefaultProducts.GetAll().ToList(); + + // Assert + Assert.Equal(12, products.Count); + } + + [Fact] + public void DefaultProducts_ShouldHaveAllCategories() + { + // Act + var products = DefaultProducts.GetAll().ToList(); + + // Assert + Assert.Contains(products, p => p.Category == ProductCategory.RawMaterial); + Assert.Contains(products, p => p.Category == ProductCategory.Component); + Assert.Contains(products, p => p.Category == ProductCategory.ConsumerGoods); + } + + [Fact] + public void DefaultProducts_RegisterAll_ShouldAddToRegistry() + { + // Arrange + var registry = new ProductRegistry(); + + // Act + DefaultProducts.RegisterAll(registry); + + // Assert + Assert.Equal(12, registry.Count); + Assert.NotNull(registry.GetById("goods_food")); + Assert.NotNull(registry.GetById("goods_automobile")); + } + + [Fact] + public void DefaultProducts_Automobile_ShouldHaveCorrectProperties() + { + // Act + var automobile = DefaultProducts.GetAll().First(p => p.Id == "goods_automobile"); + + // Assert + Assert.Equal("Автомобили", automobile.Name); + Assert.Equal(5000m, automobile.BasePrice); + Assert.Equal(1920, automobile.AvailableFromYear); + Assert.False(automobile.IsPerishable); + } + + [Fact] + public void DefaultProducts_Food_ShouldBePerishable() + { + // Act + var food = DefaultProducts.GetAll().First(p => p.Id == "goods_food"); + + // Assert + Assert.True(food.IsPerishable); + Assert.Equal(5, food.ShelfLife); + } +} diff --git a/backend/tests/MyBiz.Tests/ProductTests.cs b/backend/tests/MyBiz.Tests/ProductTests.cs index 5d9cdf2..605fc5b 100644 --- a/backend/tests/MyBiz.Tests/ProductTests.cs +++ b/backend/tests/MyBiz.Tests/ProductTests.cs @@ -2,40 +2,192 @@ using MyBiz.Core; namespace MyBiz.Tests; -public class ProductTests +public class ProductTypeTests { [Fact] - public void Product_Creation_ShouldInitializeProperties() + public void ProductType_Creation_ShouldInitializeProperties() { // Arrange & Act - var product = new Product + var productType = new ProductType { - Type = ProductType.Food, + Id = "food_bread", Name = "Bread", + Description = "Fresh baked bread", Category = ProductCategory.ConsumerGoods, - BasePrice = 10m + BasePrice = 10m, + BaseDemand = 100, + AvailableFromYear = 1950 }; // Assert - Assert.Equal(ProductType.Food, product.Type); - Assert.Equal("Bread", product.Name); - Assert.Equal(ProductCategory.ConsumerGoods, product.Category); - Assert.Equal(10m, product.BasePrice); + Assert.Equal("food_bread", productType.Id); + Assert.Equal("Bread", productType.Name); + Assert.Equal(ProductCategory.ConsumerGoods, productType.Category); + Assert.Equal(10m, productType.BasePrice); + Assert.Equal(1950, productType.AvailableFromYear); + Assert.False(productType.IsPerishable); } [Fact] - public void Product_CurrentPrice_ShouldDefaultToBasePrice() + public void ProductType_Clone_ShouldCreateIndependentCopy() + { + // Arrange + var original = new ProductType + { + Id = "test", + Name = "Test Product", + BasePrice = 50m + }; + original.RequiredTechnologies.Add("tech1"); + + // Act + var clone = original.Clone(); + clone.Name = "Modified"; + clone.RequiredTechnologies.Add("tech2"); + + // Assert + Assert.Equal("Test Product", original.Name); + Assert.Equal("Modified", clone.Name); + Assert.Single(original.RequiredTechnologies); + Assert.Equal(2, clone.RequiredTechnologies.Count); + } +} + +public class ProductTests +{ + [Fact] + public void Product_Add_ShouldIncreaseQuantity() { // Arrange var product = new Product { - BasePrice = 25m + Type = new ProductType { Id = "test", BasePrice = 10m }, + Quantity = 5 }; // Act - product.CurrentPrice = product.BasePrice; + product.Add(10); // Assert - Assert.Equal(25m, product.CurrentPrice); + Assert.Equal(15, product.Quantity); + } + + [Fact] + public void Product_Remove_ShouldDecreaseQuantity() + { + // Arrange + var product = new Product + { + Type = new ProductType { Id = "test", BasePrice = 10m }, + Quantity = 20 + }; + + // Act + var removed = product.Remove(15); + + // Assert + Assert.Equal(15, removed); + Assert.Equal(5, product.Quantity); + } + + [Fact] + public void Product_Remove_MoreThanAvailable_ShouldRemoveAll() + { + // Arrange + var product = new Product + { + Type = new ProductType { Id = "test", BasePrice = 10m }, + Quantity = 10 + }; + + // Act + var removed = product.Remove(50); + + // Assert + Assert.Equal(10, removed); + Assert.Equal(0, product.Quantity); + Assert.True(product.IsSpoiled); + } + + [Fact] + public void Product_Perishable_ShouldSpoilAfterShelfLife() + { + // Arrange + var product = new Product + { + Type = new ProductType { Id = "food", BasePrice = 10m, ShelfLife = 5 }, + Quantity = 10, + CreatedAtTick = 0 + }; + + // Act + product.Update(6); // 6 ticks later + + // Assert + Assert.True(product.IsSpoiled); + Assert.False(product.IsUsable); + } +} + +public class ProductRegistryTests +{ + [Fact] + public void Registry_Register_ShouldAddProductType() + { + // Arrange + var registry = new ProductRegistry(); + var productType = new ProductType { Id = "test", Name = "Test" }; + + // Act + registry.Register(productType); + + // Assert + Assert.Equal(1, registry.Count); + Assert.Same(productType, registry.GetById("test")); + } + + [Fact] + public void Registry_GetById_UnknownId_ShouldReturnNull() + { + // Arrange + var registry = new ProductRegistry(); + + // Act + var result = registry.GetById("unknown"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Registry_GetByCategory_ShouldFilterProducts() + { + // Arrange + var registry = new ProductRegistry(); + registry.Register(new ProductType { Id = "raw1", Category = ProductCategory.RawMaterial }); + registry.Register(new ProductType { Id = "raw2", Category = ProductCategory.RawMaterial }); + registry.Register(new ProductType { Id = "consumer1", Category = ProductCategory.ConsumerGoods }); + + // Act + var rawMaterials = registry.GetByCategory(ProductCategory.RawMaterial); + + // Assert + Assert.Equal(2, rawMaterials.Count()); + } + + [Fact] + public void Registry_GetAvailableInYear_ShouldFilterByYear() + { + // Arrange + var registry = new ProductRegistry(); + registry.Register(new ProductType { Id = "old", AvailableFromYear = 1950 }); + registry.Register(new ProductType { Id = "new", AvailableFromYear = 2000 }); + + // Act + var availableIn1970 = registry.GetAvailableInYear(1970); + + // Assert + Assert.Single(availableIn1970); + Assert.Equal("old", availableIn1970.First().Id); } } diff --git a/frontend/icon.svg.import b/frontend/icon.svg.import new file mode 100644 index 0000000..09d32b0 --- /dev/null +++ b/frontend/icon.svg.import @@ -0,0 +1,37 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://csoebibtcgpn2" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/frontend/project.godot b/frontend/project.godot index 6dc8886..5e9d343 100644 --- a/frontend/project.godot +++ b/frontend/project.godot @@ -12,7 +12,7 @@ config_version=5 config/name="MyBiz - Economic Simulator" run/main_scene="res://scenes/main.tscn" -config/features=PackedStringArray("4.0", "Forward Plus") +config/features=PackedStringArray("4.3", "Forward Plus") config/icon="res://icon.svg" [display] @@ -25,22 +25,22 @@ window/stretch/mode="canvas_items" move_up={ "deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"echo":false,"script":null) +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null) ] } move_down={ "deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"echo":false,"script":null) +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":83,"key_label":0,"unicode":115,"location":0,"echo":false,"script":null) ] } move_left={ "deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"echo":false,"script":null) +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":97,"location":0,"echo":false,"script":null) ] } move_right={ "deadzone": 0.5, -"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"echo":false,"script":null) +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":100,"location":0,"echo":false,"script":null) ] } @@ -49,7 +49,3 @@ move_right={ 2d_physics/layer_1="Default" 2d_physics/layer_2="Buildings" 2d_physics/layer_3="UI" - -[rendering] - -renderer/rendering_method="forward_plus"