using Newtonsoft.Json; using System; using System.Collections.Generic; using System.Linq; namespace Nox.Game { public enum CharacterRole { Protagonist, Companion } [Serializable] public sealed class CharacterAttributes { public int might; public int reflex; public int knowledge; public int Total => might + reflex + knowledge; } [Serializable] public sealed class CharacterStats { public int maxHealth; public int maxStamina; public float dodgeStaminaLossMultiplier; } [Serializable] public sealed class PerkDefinition { public string id; public string name; public string mechanicalBonus; public string thematicPenalty; } [Serializable] public sealed class CharacterData { public string id; public string displayName; public CharacterRole role; public int level; public int experience; public CharacterAttributes attributes; public CharacterStats stats; public List perks = new List(); public CharacterData Clone() { return new CharacterData { id = id, displayName = displayName, role = role, level = level, experience = experience, attributes = new CharacterAttributes { might = attributes?.might ?? 0, reflex = attributes?.reflex ?? 0, knowledge = attributes?.knowledge ?? 0 }, stats = new CharacterStats { maxHealth = stats?.maxHealth ?? 0, maxStamina = stats?.maxStamina ?? 0, dodgeStaminaLossMultiplier = stats?.dodgeStaminaLossMultiplier ?? 1f }, perks = perks.Select(p => new PerkDefinition { id = p.id, name = p.name, mechanicalBonus = p.mechanicalBonus, thematicPenalty = p.thematicPenalty }).ToList() }; } } [Serializable] public sealed class CharacterTemplate { public string id; public string displayName; public CharacterAttributes attributes; public int level = 1; public int experience; public List startingPerkIds = new List(); } [Serializable] public sealed class CustomCharacterCreationRequest { public string id; public string displayName; public int mightPoints; public int reflexPoints; public int knowledgePoints; public List startingPerkIds = new List(); } [Serializable] public sealed class PartyData { public List members = new List(); public int maxPartySize; [JsonIgnore] public CharacterData Protagonist => members.FirstOrDefault(m => m.role == CharacterRole.Protagonist); [JsonIgnore] public IReadOnlyList Companions => members.Where(m => m.role == CharacterRole.Companion).ToList(); } public interface ICharacterAttributesFactory { CharacterAttributes Create(int might, int reflex, int knowledge); CharacterAttributes CreateFromPointAllocation(int mightPoints, int reflexPoints, int knowledgePoints); } public interface ICharacterStatsFactory { CharacterStats Create(CharacterAttributes attributes); } public interface IPerkFactory { IReadOnlyCollection GetAll(); PerkDefinition GetById(string perkId); IReadOnlyCollection GetAvailableFor(CharacterData character); bool TryAddPerk(CharacterData character, string perkId); } public interface ICharacterFactory { CharacterData CreateCustomProtagonist(CustomCharacterCreationRequest request); CharacterData CreateFromTemplate(CharacterTemplate template, CharacterRole role = CharacterRole.Companion); } public interface IPartyFactory { PartyData Create(CharacterData protagonist, IEnumerable companions = null); } public interface ICharacterSystems { IPerkFactory PerkFactory { get; } ICharacterFactory CharacterFactory { get; } IPartyFactory PartyFactory { get; } } public sealed class CharacterFactoryOptions { public int baseMight = 1; public int baseReflex = 1; public int baseKnowledge = 1; public int customAttributePointBudget = 10; public int startingLevel = 1; } public sealed class CharacterStatsFactoryOptions { public int baseHealth = 10; public int baseStamina = 5; public int mightHealthBonus = 3; public int mightStaminaBonus = 1; public int knowledgeStaminaBonus = 2; public float baseDodgeStaminaLossMultiplier = 1f; public float reflexDodgeStaminaLossReduction = 0.03f; public float minDodgeStaminaLossMultiplier = 0.4f; } public sealed class PartyFactoryOptions { public int minPartySize = 1; public int maxPartySize = 4; public bool enforceUniqueCharacterIds = true; } public sealed class CharacterSystems : ICharacterSystems { public CharacterSystems(IPerkFactory perkFactory, ICharacterFactory characterFactory, IPartyFactory partyFactory) { PerkFactory = perkFactory; CharacterFactory = characterFactory; PartyFactory = partyFactory; } public IPerkFactory PerkFactory { get; } public ICharacterFactory CharacterFactory { get; } public IPartyFactory PartyFactory { get; } } public static class DefaultCharacterSystemsFactory { public static ICharacterSystems Create(int maxPartySize = 8) { IPerkFactory perkFactory = new PerkFactory(CreateDefaultPerks()); ICharacterAttributesFactory attributesFactory = new CharacterAttributesFactory(new CharacterFactoryOptions { baseMight = 1, baseReflex = 1, baseKnowledge = 1, customAttributePointBudget = 10, startingLevel = 1 }); ICharacterStatsFactory statsFactory = new CharacterStatsFactory(); ICharacterFactory characterFactory = new CharacterFactory(attributesFactory, statsFactory, perkFactory); IPartyFactory partyFactory = new PartyFactory(new PartyFactoryOptions { minPartySize = 1, maxPartySize = maxPartySize, enforceUniqueCharacterIds = true }); return new CharacterSystems(perkFactory, characterFactory, partyFactory); } private static IEnumerable CreateDefaultPerks() { return new[] { new PerkDefinition { id = "iron-will", name = "Iron Will", mechanicalBonus = "+1 max health per level", thematicPenalty = "-1 social flexibility in dialogue checks" }, new PerkDefinition { id = "steadfast", name = "Steadfast", mechanicalBonus = "-10% stamina loss when bracing", thematicPenalty = "-10% movement speed in retreat events" }, new PerkDefinition { id = "nimble-step", name = "Nimble Step", mechanicalBonus = "-15% dodge stamina loss", thematicPenalty = "+10% stamina loss on heavy actions" }, new PerkDefinition { id = "lorekeeper", name = "Lorekeeper", mechanicalBonus = "+15% knowledge event success", thematicPenalty = "-10% intimidation success chance" }, new PerkDefinition { id = "bulwark", name = "Bulwark", mechanicalBonus = "+2 base defense checks", thematicPenalty = "-1 reflex in stealth checks" }, new PerkDefinition { id = "pathfinder", name = "Pathfinder", mechanicalBonus = "+15% scouting event success", thematicPenalty = "-1 max health during ambush events" } }; } } public sealed class CharacterAttributesFactory : ICharacterAttributesFactory { private readonly CharacterFactoryOptions options; public CharacterAttributesFactory(CharacterFactoryOptions options = null) { this.options = options ?? new CharacterFactoryOptions(); } public CharacterAttributes Create(int might, int reflex, int knowledge) { if(might < 0 || reflex < 0 || knowledge < 0) { throw new ArgumentOutOfRangeException(nameof(might), "attributes cannot be negative."); } return new CharacterAttributes { might = might, reflex = reflex, knowledge = knowledge }; } public CharacterAttributes CreateFromPointAllocation(int mightPoints, int reflexPoints, int knowledgePoints) { if(mightPoints < 0 || reflexPoints < 0 || knowledgePoints < 0) { throw new ArgumentOutOfRangeException(nameof(mightPoints), "Point allocation cannot be negative."); } int allocated = mightPoints + reflexPoints + knowledgePoints; if(allocated > options.customAttributePointBudget) { throw new ArgumentException($"Allocated {allocated} points but budget is {options.customAttributePointBudget}."); } return new CharacterAttributes { might = options.baseMight + mightPoints, reflex = options.baseReflex + reflexPoints, knowledge = options.baseKnowledge + knowledgePoints }; } } public sealed class CharacterStatsFactory : ICharacterStatsFactory { private readonly CharacterStatsFactoryOptions options; public CharacterStatsFactory(CharacterStatsFactoryOptions options = null) { this.options = options ?? new CharacterStatsFactoryOptions(); } public CharacterStats Create(CharacterAttributes attributes) { if(attributes == null) { throw new ArgumentNullException(nameof(attributes)); } int maxHealth = options.baseHealth + attributes.might * options.mightHealthBonus; int maxStamina = options.baseStamina + attributes.might * options.mightStaminaBonus + attributes.knowledge * options.knowledgeStaminaBonus; float dodgeMultiplier = options.baseDodgeStaminaLossMultiplier - attributes.reflex * options.reflexDodgeStaminaLossReduction; dodgeMultiplier = Math.Max(options.minDodgeStaminaLossMultiplier, dodgeMultiplier); return new CharacterStats { maxHealth = maxHealth, maxStamina = maxStamina, dodgeStaminaLossMultiplier = dodgeMultiplier }; } } public sealed class PerkFactory : IPerkFactory { private readonly Dictionary perkPool; public PerkFactory(IEnumerable perkPool) { if(perkPool == null) { throw new ArgumentNullException(nameof(perkPool)); } this.perkPool = perkPool .Where(p => p != null && !string.IsNullOrWhiteSpace(p.id)) .GroupBy(p => p.id) .ToDictionary(g => g.Key, g => g.First()); } public IReadOnlyCollection GetAll() { return perkPool.Values.ToList(); } public PerkDefinition GetById(string perkId) { if(string.IsNullOrWhiteSpace(perkId)) { return null; } perkPool.TryGetValue(perkId, out PerkDefinition perk); return perk; } public IReadOnlyCollection GetAvailableFor(CharacterData character) { if(character == null) { return perkPool.Values.ToList(); } HashSet ownedPerkIds = character.perks .Where(p => p != null && !string.IsNullOrWhiteSpace(p.id)) .Select(p => p.id) .ToHashSet(); return perkPool.Values.Where(p => !ownedPerkIds.Contains(p.id)).ToList(); } public bool TryAddPerk(CharacterData character, string perkId) { if(character == null || string.IsNullOrWhiteSpace(perkId)) { return false; } if(character.perks.Any(p => p != null && p.id == perkId)) { return false; } if(!perkPool.TryGetValue(perkId, out PerkDefinition perk)) { return false; } character.perks.Add(new PerkDefinition { id = perk.id, name = perk.name, mechanicalBonus = perk.mechanicalBonus, thematicPenalty = perk.thematicPenalty }); return true; } } public sealed class CharacterFactory : ICharacterFactory { private readonly CharacterFactoryOptions options; private readonly ICharacterAttributesFactory attributesFactory; private readonly ICharacterStatsFactory statsFactory; private readonly IPerkFactory perkFactory; public CharacterFactory( ICharacterAttributesFactory attributesFactory, ICharacterStatsFactory statsFactory, IPerkFactory perkFactory, CharacterFactoryOptions options = null) { this.attributesFactory = attributesFactory ?? throw new ArgumentNullException(nameof(attributesFactory)); this.statsFactory = statsFactory ?? throw new ArgumentNullException(nameof(statsFactory)); this.perkFactory = perkFactory ?? throw new ArgumentNullException(nameof(perkFactory)); this.options = options ?? new CharacterFactoryOptions(); } public CharacterData CreateCustomProtagonist(CustomCharacterCreationRequest request) { if(request == null) { throw new ArgumentNullException(nameof(request)); } CharacterAttributes attributes = attributesFactory.CreateFromPointAllocation( request.mightPoints, request.reflexPoints, request.knowledgePoints); CharacterData character = new CharacterData { id = string.IsNullOrWhiteSpace(request.id) ? Guid.NewGuid().ToString("N") : request.id, displayName = request.displayName, role = CharacterRole.Protagonist, level = options.startingLevel, experience = 0, attributes = attributes, stats = statsFactory.Create(attributes) }; AddStartingPerks(character, request.startingPerkIds); return character; } public CharacterData CreateFromTemplate(CharacterTemplate template, CharacterRole role = CharacterRole.Companion) { if(template == null) { throw new ArgumentNullException(nameof(template)); } CharacterAttributes sourceAttributes = template.attributes ?? attributesFactory.Create(0, 0, 0); CharacterAttributes attributes = attributesFactory.Create( sourceAttributes.might, sourceAttributes.reflex, sourceAttributes.knowledge); CharacterData character = new CharacterData { id = string.IsNullOrWhiteSpace(template.id) ? Guid.NewGuid().ToString("N") : template.id, displayName = template.displayName, role = role, level = template.level <= 0 ? options.startingLevel : template.level, experience = template.experience, attributes = attributes, stats = statsFactory.Create(attributes) }; AddStartingPerks(character, template.startingPerkIds); return character; } private void AddStartingPerks(CharacterData character, IEnumerable perkIds) { if(perkIds == null) { return; } foreach(string perkId in perkIds.Distinct()) { perkFactory.TryAddPerk(character, perkId); } } } public sealed class PartyFactory : IPartyFactory { private readonly PartyFactoryOptions options; public PartyFactory(PartyFactoryOptions options = null) { this.options = options ?? new PartyFactoryOptions(); } public PartyData Create(CharacterData protagonist, IEnumerable companions = null) { if(protagonist == null) { throw new ArgumentNullException(nameof(protagonist)); } PartyData party = new PartyData { maxPartySize = options.maxPartySize <= 0 ? int.MaxValue : options.maxPartySize }; CharacterData protagonistClone = protagonist.Clone(); protagonistClone.role = CharacterRole.Protagonist; party.members.Add(protagonistClone); if(companions != null) { foreach(CharacterData companion in companions.Where(c => c != null)) { CharacterData companionClone = companion.Clone(); companionClone.role = CharacterRole.Companion; party.members.Add(companionClone); } } ValidateParty(party); return party; } private void ValidateParty(PartyData party) { if(party.members.Count < options.minPartySize) { throw new ArgumentException($"Party size {party.members.Count} is below minimum {options.minPartySize}."); } if(party.members.Count > party.maxPartySize) { throw new ArgumentException($"Party size {party.members.Count} exceeds max {party.maxPartySize}."); } int protagonistCount = party.members.Count(m => m.role == CharacterRole.Protagonist); if(protagonistCount != 1) { throw new ArgumentException($"Party must contain exactly one protagonist, found {protagonistCount}."); } if(options.enforceUniqueCharacterIds) { int uniqueIds = party.members .Where(m => !string.IsNullOrWhiteSpace(m.id)) .Select(m => m.id) .Distinct() .Count(); if(uniqueIds != party.members.Count) { throw new ArgumentException("Party contains duplicate or missing character ids."); } } } } }