From 937e85dee4405eee62c37172dc2c5b2fe2578f48 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 4 Sep 2026 22:13:14 +0200 Subject: [PATCH] feat(vf): add PriceModifier and TradeOffer --- core/include/vf/Trade.h | 155 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 core/include/vf/Trade.h diff --git a/core/include/vf/Trade.h b/core/include/vf/Trade.h new file mode 100644 index 0000000..d90a982 --- /dev/null +++ b/core/include/vf/Trade.h @@ -0,0 +1,155 @@ +#pragma once + +#include +#include +#include "Container.h" +#include "Gossip.h" +#include "Item.h" +#include "TimeOfDay.h" +#include "Villager.h" + +namespace vf { + +// Trade unlock band. A villager of level N has tiers 0..N-1 unlocked. +enum class TradeTier : uint8_t { + Novice = 0, + Apprentice, + Journeyman, + Expert, + Master, + Count, +}; + +const char* tradeTierName(TradeTier tier); +TradeTier tierForLevel(int level); +int levelForTier(TradeTier tier); + +constexpr int heroDiscountPercentPerLevel = 30; +constexpr int hitSurchargePercent = 20; + +// Everything outside the offer itself that bends its price: how the villager +// feels about the customer, the hero of the village effect, and how many +// unforgiven hits the customer landed on villagers. +struct PriceModifier { + int reputationPercent = 0; + int heroLevel = 0; + int hitCount = 0; + + // Net discount in percent. Positive makes goods cheaper. + int totalPercent() const; +}; + +PriceModifier priceModifierFor(const ReputationView& reputation, int heroLevel, int hitCount); +int villagerHitCount(const GossipLedger& gossip); + +constexpr int tradeDemandCap = 64; +constexpr int tradeDemandPerUse = 1; +constexpr int tradeDemandDecayPerRestock = 2; +constexpr int tradeRestocksPerDay = 2; + +// One entry in a villager's trade list: up to two inputs for one output, a use +// budget that a restock refills, and a demand counter that raises the price the +// more the offer is taken. +struct TradeOffer { + ItemId inputA = ItemId::None; + int inputACount = 1; + ItemId inputB = ItemId::None; + int inputBCount = 0; + ItemId output = ItemId::None; + int outputCount = 1; + int maxUses = 12; + int uses = 0; + int demand = 0; + int experienceReward = villagerExperiencePerTrade; + float priceMultiplier = 0.05f; + TradeTier tier = TradeTier::Novice; + + bool exhausted() const { return uses >= maxUses; } + int basePrice() const { return inputACount; } + bool hasSecondaryInput() const { return inputB != ItemId::None && inputBCount > 0; } +}; + +// Base price plus demand, then the modifier percentage. Never drops below one. +int offerPrice(const TradeOffer& offer, const PriceModifier& modifier); + +// A villager's whole trade table across all five tiers. Which entries are +// reachable depends on the villager's current level. +struct TradeOfferList { + std::vector offers; + VillagerProfession profession = VillagerProfession::Unemployed; + + int size() const { return static_cast(offers.size()); } + const TradeOffer& at(int index) const { return offers[index]; } + TradeOffer& at(int index) { return offers[index]; } + bool unlocked(int index, int level) const; + int unlockedCount(int level) const; + int countForTier(TradeTier tier) const; + bool allExhausted(int level) const; +}; + +// Deterministic per profession and seed: the same pair always yields the same +// table, with per-offer count jitter drawn from the seed. +TradeOfferList buildTradeOffers(VillagerProfession profession, uint32_t seed); + +// Refills use budgets and lets demand fade. Returns true when anything changed. +bool restockOffers(TradeOfferList& offers); + +// Restocks at most twice per day, and only while the villager is standing at +// its own workstation. Returns true on the tick a restock fires. +bool tickVillagerRestock(Villager& villager, TradeOfferList& offers, const TimeOfDay& clock); + +struct TradeResult { + enum class Reason : uint8_t { + Ok = 0, + NoSuchOffer, + VillagerUnavailable, + LevelLocked, + OfferExhausted, + MissingInputA, + MissingInputB, + NoRoom, + Count, + }; + + Reason reason = Reason::Ok; + bool success = false; + int pricePaid = 0; + int secondaryPaid = 0; + ItemId received = ItemId::None; + int receivedCount = 0; + int experienceGained = 0; + bool leveledUp = false; +}; + +const char* tradeResultReasonName(TradeResult::Reason reason); + +// Binds a villager, its offer table and the customer's inventory for the +// duration of a trading screen. Every completed trade moves items, raises +// demand, feeds the villager experience and leaves a Trading gossip entry. +class TradeSession { +public: + TradeSession(Villager& villager, TradeOfferList& offers, Container& customerInventory); + + void setPriceModifier(const PriceModifier& modifier); + void refreshPrices(const ReputationView& reputation, int heroLevel); + const PriceModifier& priceModifier() const { return modifier; } + + int priceOf(int index) const; + bool available(int index) const; + bool canAfford(int index) const; + + TradeResult trade(int index); + + int tradesCompleted() const { return completed; } + int emeraldsSpent() const { return spent; } + +private: + Villager& villager; + TradeOfferList& offers; + Container& inventory; + PriceModifier modifier; + int completed = 0; + int spent = 0; +}; + +} // namespace vf