diff --git a/com.zerogamestudio.zeroengine.extraction/README.md b/com.zerogamestudio.zeroengine.extraction/README.md new file mode 100644 index 0000000..f680627 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/README.md @@ -0,0 +1,33 @@ +# ZeroEngine Extraction Core + +面向撤离搜打玩法的通用纯逻辑包,负责物品网格、所有权、Raid 会话、掉落、回收、经济和结算等领域规则。 + +## 边界 + +- 包内可以保存通用 DTO、确定性规则、事务合同与纯逻辑服务。 +- 包内不得引用 POB 的 ScriptableObject、玩家组件、存档槽、场景 actor、UI、Addressables 或 Console。 +- POB 的配置解析、角色投影、共享钱包、场景绑定和表现留在 POB adapter。 +- 现有 `POB.Extraction.Core` assembly 名在迁移阶段保留,避免无关的引用破坏;若后续改名,必须单独提供兼容迁移。 + +## 安装 + +消费者使用经过测试的 Git commit pin,不使用本机 `file:` 路径: + +```json +"com.zerogamestudio.zeroengine.extraction": "https://github.com/liuzqk/zeroengine.git?path=com.zerogamestudio.zeroengine.extraction#" +``` + +## 验证 + +- 包级合同测试位于 `Tests/Editor`。 +- 通用行为先在包内测试;消费者项目随后运行自己的 adapter 回归测试。 +- 发布前检查包内不存在 `POB` 项目路径、Unity 场景/Prefab 或本机绝对路径依赖。 + +## 迁移基线 + +- 来源:POB Plastic `cs:16334` 的 `Packages/com.zerogamestudio.zeroengine.extraction`。 +- 原始文件数:183。 +- 原始总字节数:213273。 +- 原始稳定树 SHA-256:`21db143662621b18e255ae3d9e5cdf1b944605b5d7a63072d7783d022f41e838`。 +- 树哈希输入按相对路径排序,每项为 `relative/path + NUL + lowercase(file SHA-256) + LF` 的 UTF-8 拼接。 +- 上游首提交只允许在这份原样基线上补 package metadata 与本 README;领域代码和测试不得静默改写。 diff --git a/com.zerogamestudio.zeroengine.extraction/README.md.meta b/com.zerogamestudio.zeroengine.extraction/README.md.meta new file mode 100644 index 0000000..fc03159 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: ab2e7309545046b38337a8c1ebae450e +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime.meta b/com.zerogamestudio.zeroengine.extraction/Runtime.meta new file mode 100644 index 0000000..27085ee --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: dc787c172554c164a86db02981db3f14 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionActiveRaidContentState.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionActiveRaidContentState.cs new file mode 100644 index 0000000..62d64e5 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionActiveRaidContentState.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + [Serializable] + public class ExtractionActiveRaidContentState + { + public List OpenedContainerIds = new(); + public List OpenedLockIds = new(); + public List ActivatedLeverIds = new(); + public List UsedExtractionPointIds = new(); + public List WorldPickupItemInstanceIds = new(); + public List AppliedReceiptIds = new(); + public int DeadlineExtensionSeconds; + public int ThreatLevelDelta; + public List UnlockedBonusContainerGroupIds = new(); + public List ExtractionPointStates = new(); + public ExtractionRaidLootManifest LootManifest; + + internal void EnsureInitialized() + { + OpenedContainerIds ??= new List(); + OpenedLockIds ??= new List(); + ActivatedLeverIds ??= new List(); + UsedExtractionPointIds ??= new List(); + WorldPickupItemInstanceIds ??= new List(); + AppliedReceiptIds ??= new List(); + UnlockedBonusContainerGroupIds ??= new List(); + ExtractionPointStates ??= new List(); + LootManifest?.EnsureInitialized(); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionActiveRaidContentState.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionActiveRaidContentState.cs.meta new file mode 100644 index 0000000..88067a8 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionActiveRaidContentState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2a24aa6879de442dbee32f0b7447e8d6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintDefinition.cs new file mode 100644 index 0000000..f0916c2 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintDefinition.cs @@ -0,0 +1,29 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionBlueprintDefinition + { + public string BlueprintId; + public string CostItemDefinitionId; + public int CostQuantity; + + public bool IsValid => + !string.IsNullOrEmpty(BlueprintId) + && !string.IsNullOrEmpty(CostItemDefinitionId) + && CostQuantity > 0; + + public ExtractionBlueprintDefinition( + string blueprintId, + string costItemDefinitionId, + int costQuantity) + { + BlueprintId = blueprintId; + CostItemDefinitionId = costItemDefinitionId; + CostQuantity = costQuantity; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintDefinition.cs.meta new file mode 100644 index 0000000..9d2b9e2 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 148adf8a0e0ef6741b19c5ab76b6298f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintService.cs new file mode 100644 index 0000000..9ebc836 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintService.cs @@ -0,0 +1,33 @@ +namespace POB.Extraction +{ + public static class ExtractionBlueprintService + { + public static bool IsBlueprintUnlocked(ExtractionProfileSaveData profile, string blueprintId) + { + if (profile == null || string.IsNullOrEmpty(blueprintId)) return false; + profile.EnsureInitialized(); + foreach (var unlockedBlueprintId in profile.UnlockedBlueprintIds) + { + if (unlockedBlueprintId == blueprintId) return true; + } + + return false; + } + + public static bool TryUnlockBlueprint( + ExtractionProfileSaveData profile, + ExtractionPlayableConfig config, + string blueprintId) + { + if (profile == null || config == null || string.IsNullOrEmpty(blueprintId)) return false; + if (!config.TryGetBlueprintDefinition(blueprintId, out var blueprint) || blueprint == null || !blueprint.IsValid) + return false; + + profile.EnsureInitialized(); + if (IsBlueprintUnlocked(profile, blueprintId)) return true; + + profile.UnlockedBlueprintIds.Add(blueprintId); + return true; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintService.cs.meta new file mode 100644 index 0000000..16ecd7b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 57787310bac026646a39a0dd83e653b0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintTransactionService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintTransactionService.cs new file mode 100644 index 0000000..5670212 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintTransactionService.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionBlueprintTransactionService + { + public static bool TryLearnBlueprint( + ExtractionProfileSaveData profile, + ExtractionPlayableConfig config, + ExtractionBlueprintDefinition blueprint, + IExtractionItemCatalog itemCatalog, + List costSourceContainers) + { + if (!CanLearnBlueprint(profile, blueprint, itemCatalog, out var costs)) return false; + if (!ExtractionItemCostService.HasEnoughItems(profile, costs, costSourceContainers)) return false; + if (!ExtractionItemCostService.TryConsumeCosts(profile, costs, costSourceContainers, out var costReceipt)) return false; + + if (ExtractionBlueprintService.TryUnlockBlueprint(profile, config, blueprint.BlueprintId)) return true; + + ExtractionItemCostService.RestoreCosts(profile, costReceipt, itemCatalog); + return false; + } + + private static bool CanLearnBlueprint( + ExtractionProfileSaveData profile, + ExtractionBlueprintDefinition blueprint, + IExtractionItemCatalog itemCatalog, + out List costs) + { + costs = null; + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || blueprint == null || !blueprint.IsValid || itemCatalog == null) return false; + + profile.EnsureInitialized(); + if (!itemCatalog.TryGetItemDefinition(blueprint.CostItemDefinitionId, out _)) return false; + if (ExtractionBlueprintService.IsBlueprintUnlocked(profile, blueprint.BlueprintId)) return false; + + costs = new List + { + new ExtractionItemCost(blueprint.CostItemDefinitionId, blueprint.CostQuantity) + }; + return true; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintTransactionService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintTransactionService.cs.meta new file mode 100644 index 0000000..9fce45d --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBlueprintTransactionService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: da09f40a0cd87c849ae42cafbebd9b2a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackQuoteDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackQuoteDefinition.cs new file mode 100644 index 0000000..2028dcc --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackQuoteDefinition.cs @@ -0,0 +1,80 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionBuybackQuoteDefinition + { + public const string DefaultCostItemDefinitionId = "buyback-token"; + + public string ItemDefinitionId; + public string CostItemDefinitionId; + public int CostPerUnit; + public int MinimumCost; + + public bool IsValid => + !string.IsNullOrEmpty(ItemDefinitionId) + && !string.IsNullOrEmpty(CostItemDefinitionId) + && CostPerUnit > 0 + && MinimumCost >= 0; + + public ExtractionBuybackQuoteDefinition( + string itemDefinitionId, + int costPerUnit, + int minimumCost) + : this(itemDefinitionId, DefaultCostItemDefinitionId, costPerUnit, minimumCost) + { + } + + public ExtractionBuybackQuoteDefinition( + string itemDefinitionId, + string costItemDefinitionId, + int costPerUnit, + int minimumCost) + { + ItemDefinitionId = itemDefinitionId; + CostItemDefinitionId = costItemDefinitionId; + CostPerUnit = costPerUnit; + MinimumCost = minimumCost; + } + } + + public class ExtractionBuybackQuote + { + public string ItemInstanceId; + public string ItemDefinitionId; + public int Quantity; + public string CostItemDefinitionId; + public int Cost; + + public ExtractionBuybackQuote( + string itemInstanceId, + string itemDefinitionId, + int quantity, + int cost) + : this( + itemInstanceId, + itemDefinitionId, + quantity, + ExtractionBuybackQuoteDefinition.DefaultCostItemDefinitionId, + cost) + { + } + + public ExtractionBuybackQuote( + string itemInstanceId, + string itemDefinitionId, + int quantity, + string costItemDefinitionId, + int cost) + { + ItemInstanceId = itemInstanceId; + ItemDefinitionId = itemDefinitionId; + Quantity = quantity; + CostItemDefinitionId = costItemDefinitionId; + Cost = cost; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackQuoteDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackQuoteDefinition.cs.meta new file mode 100644 index 0000000..b6f0756 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackQuoteDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f6807bc8423595f449d003e5a7265976 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackQuoteService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackQuoteService.cs new file mode 100644 index 0000000..53dc3da --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackQuoteService.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionBuybackQuoteService + { + public static bool TryGetQuote( + ExtractionProfileSaveData profile, + List quoteDefinitions, + string itemInstanceId, + out ExtractionBuybackQuote quote) + { + quote = null; + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || quoteDefinitions == null || string.IsNullOrEmpty(itemInstanceId)) return false; + if (!profile.Items.TryGet(itemInstanceId, out var item)) return false; + if (item == null || item.Quantity <= 0) return false; + if (!profile.Ownership.TryGetContainer(itemInstanceId, out var container)) return false; + if (container != ExtractionInventoryContainerType.Lost) return false; + if (!profile.Recovery.TryGetState(itemInstanceId, out var state)) return false; + if (state != ExtractionRecoveryItemState.Recoverable) return false; + if (!TryGetQuoteDefinition(quoteDefinitions, item.DefinitionId, out var definition)) return false; + + long raw = (long)definition.CostPerUnit * item.Quantity; + if (raw < definition.MinimumCost) raw = definition.MinimumCost; + if (raw > int.MaxValue) raw = int.MaxValue; + int cost = (int)raw; + + quote = new ExtractionBuybackQuote( + item.InstanceId, + item.DefinitionId, + item.Quantity, + definition.CostItemDefinitionId, + cost); + return true; + } + + private static bool TryGetQuoteDefinition( + List quoteDefinitions, + string itemDefinitionId, + out ExtractionBuybackQuoteDefinition definition) + { + definition = null; + if (string.IsNullOrEmpty(itemDefinitionId)) return false; + + foreach (var candidate in quoteDefinitions) + { + if (candidate == null || !candidate.IsValid) continue; + if (candidate.ItemDefinitionId != itemDefinitionId) continue; + + definition = candidate; + return true; + } + + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackQuoteService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackQuoteService.cs.meta new file mode 100644 index 0000000..b987260 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackQuoteService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ccc21f9d1b7422046823f438183d0530 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackTransactionService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackTransactionService.cs new file mode 100644 index 0000000..31080fe --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackTransactionService.cs @@ -0,0 +1,41 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionBuybackTransactionService + { + public static bool TryCompleteBuyback( + ExtractionProfileSaveData profile, + List quoteDefinitions, + string itemInstanceId, + IExtractionItemCatalog itemCatalog, + List costSourceContainers) + { + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || itemCatalog == null) return false; + if (!ExtractionBuybackQuoteService.TryGetQuote( + profile, + quoteDefinitions, + itemInstanceId, + out var quote)) + { + return false; + } + + if (string.IsNullOrEmpty(quote.CostItemDefinitionId)) return false; + if (!itemCatalog.TryGetItemDefinition(quote.CostItemDefinitionId, out _)) return false; + + var costs = new List + { + new ExtractionItemCost(quote.CostItemDefinitionId, quote.Cost) + }; + if (!ExtractionItemCostService.HasEnoughItems(profile, costs, costSourceContainers)) return false; + if (!ExtractionItemCostService.TryConsumeCosts(profile, costs, costSourceContainers, out var costReceipt)) return false; + + if (ExtractionRecoveryService.TryRecoverBuybackItem(profile, itemInstanceId)) return true; + + ExtractionItemCostService.RestoreCosts(profile, costReceipt, itemCatalog); + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackTransactionService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackTransactionService.cs.meta new file mode 100644 index 0000000..943e618 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionBuybackTransactionService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b54afbc84105b124f9116c7fbfe8158c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterConfig.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterConfig.cs new file mode 100644 index 0000000..17d0c9b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterConfig.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + [Serializable] + public class ExtractionCharacterConfig + { + public float DefaultSearchSpeedMultiplier = 1f; + public List AttributeDefinitions = new(); + public List TalentDefinitions = new(); + public List InjuryDefinitions = new(); + public List EquipmentSlotIds = new(); + public List BaselineAbilityIds = new(); + + public bool TryGetAttributeDefinition( + string attributeId, + out ExtractionCharacterAttributeDefinition definition) + { + if (AttributeDefinitions != null) + { + foreach (var candidate in AttributeDefinitions) + { + if (candidate != null && candidate.AttributeId == attributeId) + { + definition = candidate; + return true; + } + } + } + + definition = null; + return false; + } + + public bool TryGetTalentDefinition( + string talentId, + out ExtractionCharacterTalentDefinition definition) + { + if (TalentDefinitions != null) + { + foreach (var candidate in TalentDefinitions) + { + if (candidate != null && candidate.TalentId == talentId) + { + definition = candidate; + return true; + } + } + } + + definition = null; + return false; + } + + public bool TryGetInjuryDefinition( + string injuryId, + out ExtractionCharacterInjuryDefinition definition) + { + if (InjuryDefinitions != null) + { + foreach (var candidate in InjuryDefinitions) + { + if (candidate != null && candidate.InjuryId == injuryId) + { + definition = candidate; + return true; + } + } + } + + definition = null; + return false; + } + } + + [Serializable] + public class ExtractionCharacterAttributeDefinition + { + public string AttributeId; + public float DefaultValue; + public float MinValue; + public float MaxValue; + + public ExtractionCharacterAttributeDefinition( + string attributeId, + float defaultValue, + float minValue, + float maxValue) + { + AttributeId = attributeId; + DefaultValue = defaultValue; + MinValue = minValue; + MaxValue = maxValue; + } + } + + [Serializable] + public class ExtractionCharacterTalentDefinition + { + public string TalentId; + + public ExtractionCharacterTalentDefinition(string talentId) + { + TalentId = talentId; + } + } + + [Serializable] + public class ExtractionCharacterInjuryDefinition + { + public string InjuryId; + public int MaxSeverity; + + public ExtractionCharacterInjuryDefinition(string injuryId, int maxSeverity) + { + InjuryId = injuryId; + MaxSeverity = maxSeverity; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterConfig.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterConfig.cs.meta new file mode 100644 index 0000000..0b5d30c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 45b0342c19b54343b44b7afa7ae3a22d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterProfile.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterProfile.cs new file mode 100644 index 0000000..abbffee --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterProfile.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + [Serializable] + public class ExtractionCharacterProfile + { + public List AttributeValues = new(); + public List UnlockedTalentIds = new(); + public List Injuries = new(); + public float SearchSpeedMultiplier = 1f; + public List BaselineAbilityIds = new(); + + internal void EnsureInitialized() + { + AttributeValues ??= new List(); + UnlockedTalentIds ??= new List(); + Injuries ??= new List(); + BaselineAbilityIds ??= new List(); + } + } + + [Serializable] + public class ExtractionCharacterValue + { + public string AttributeId; + public float Value; + + public ExtractionCharacterValue(string attributeId, float value) + { + AttributeId = attributeId; + Value = value; + } + } + + [Serializable] + public class ExtractionCharacterInjuryState + { + public string InjuryId; + public int Severity; + + public ExtractionCharacterInjuryState(string injuryId, int severity) + { + InjuryId = injuryId; + Severity = severity; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterProfile.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterProfile.cs.meta new file mode 100644 index 0000000..520df16 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterProfile.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a66fe8b4e6e4502b6597ed1596bdd7c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterProfileValidator.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterProfileValidator.cs new file mode 100644 index 0000000..825df09 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterProfileValidator.cs @@ -0,0 +1,241 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionCharacterProfileValidator + { + public const float MinSearchSpeedMultiplier = 0.25f; + public const float MaxSearchSpeedMultiplier = 4f; + + public static bool TryValidateConfig(ExtractionCharacterConfig config, out string issue) + { + issue = null; + if (config == null) return Fail("Character config is null.", out issue); + if (!IsFiniteInRange( + config.DefaultSearchSpeedMultiplier, + MinSearchSpeedMultiplier, + MaxSearchSpeedMultiplier)) + { + return Fail("Default search speed multiplier must be between 0.25 and 4.", out issue); + } + + if (config.AttributeDefinitions == null) + return Fail("Character attribute definitions are null.", out issue); + if (config.TalentDefinitions == null) + return Fail("Character talent definitions are null.", out issue); + if (config.InjuryDefinitions == null) + return Fail("Character injury definitions are null.", out issue); + if (config.EquipmentSlotIds == null) + return Fail("Character equipment slot ids are null.", out issue); + if (config.BaselineAbilityIds == null) + return Fail("Character baseline ability ids are null.", out issue); + + var attributeIds = new HashSet(); + foreach (var definition in config.AttributeDefinitions) + { + if (definition == null || IsBlank(definition.AttributeId)) + return Fail("Character attribute id is empty.", out issue); + if (!attributeIds.Add(definition.AttributeId)) + return Fail($"Duplicate character attribute id '{definition.AttributeId}'.", out issue); + if (!IsFinite(definition.MinValue) + || !IsFinite(definition.DefaultValue) + || !IsFinite(definition.MaxValue) + || definition.MinValue < 0f + || definition.MaxValue < definition.MinValue + || definition.DefaultValue < definition.MinValue + || definition.DefaultValue > definition.MaxValue) + { + return Fail($"Character attribute '{definition.AttributeId}' has invalid bounds.", out issue); + } + } + + var talentIds = new HashSet(); + foreach (var definition in config.TalentDefinitions) + { + if (definition == null || IsBlank(definition.TalentId)) + return Fail("Character talent id is empty.", out issue); + if (!talentIds.Add(definition.TalentId)) + return Fail($"Duplicate character talent id '{definition.TalentId}'.", out issue); + } + + var injuryIds = new HashSet(); + foreach (var definition in config.InjuryDefinitions) + { + if (definition == null || IsBlank(definition.InjuryId) || definition.MaxSeverity <= 0) + return Fail("Character injury definition is invalid.", out issue); + if (!injuryIds.Add(definition.InjuryId)) + return Fail($"Duplicate character injury id '{definition.InjuryId}'.", out issue); + } + + if (!TryCollectUniqueIds(config.EquipmentSlotIds, "equipment slot", out issue)) + return false; + if (!TryCollectUniqueIds(config.BaselineAbilityIds, "baseline ability", out issue)) + return false; + + return true; + } + + public static bool TryInitializeAndValidate( + ExtractionCharacterProfile profile, + ExtractionEquipmentState equipment, + ExtractionCharacterConfig config, + out string issue) + { + issue = null; + if (!TryValidateConfig(config, out issue)) return false; + if (profile == null) return Fail("Character profile is null.", out issue); + if (equipment == null) return Fail("Character equipment state is null.", out issue); + + profile.EnsureInitialized(); + equipment.EnsureInitialized(); + + foreach (var definition in config.AttributeDefinitions) + { + if (!ContainsAttribute(profile.AttributeValues, definition.AttributeId)) + { + profile.AttributeValues.Add( + new ExtractionCharacterValue(definition.AttributeId, definition.DefaultValue)); + } + } + + if (profile.BaselineAbilityIds.Count == 0) + profile.BaselineAbilityIds.AddRange(config.BaselineAbilityIds); + + return TryValidateProfile(profile, equipment, config, out issue); + } + + public static bool TryValidateProfile( + ExtractionCharacterProfile profile, + ExtractionEquipmentState equipment, + ExtractionCharacterConfig config, + out string issue) + { + issue = null; + if (!TryValidateConfig(config, out issue)) return false; + if (profile == null) return Fail("Character profile is null.", out issue); + if (equipment == null) return Fail("Character equipment state is null.", out issue); + + profile.EnsureInitialized(); + equipment.EnsureInitialized(); + if (!IsFiniteInRange( + profile.SearchSpeedMultiplier, + MinSearchSpeedMultiplier, + MaxSearchSpeedMultiplier)) + { + return Fail("Character search speed multiplier must be between 0.25 and 4.", out issue); + } + + var attributeIds = new HashSet(); + foreach (var value in profile.AttributeValues) + { + if (value == null || IsBlank(value.AttributeId)) + return Fail("Character attribute value is invalid.", out issue); + if (!attributeIds.Add(value.AttributeId)) + return Fail($"Duplicate character attribute value '{value.AttributeId}'.", out issue); + if (!config.TryGetAttributeDefinition(value.AttributeId, out var definition)) + return Fail($"Unknown character attribute id '{value.AttributeId}'.", out issue); + if (!IsFinite(value.Value) || value.Value < definition.MinValue || value.Value > definition.MaxValue) + return Fail($"Character attribute '{value.AttributeId}' is outside its configured range.", out issue); + } + + foreach (var definition in config.AttributeDefinitions) + { + if (!attributeIds.Contains(definition.AttributeId)) + return Fail($"Character attribute '{definition.AttributeId}' is missing.", out issue); + } + + var talentIds = new HashSet(); + foreach (string talentId in profile.UnlockedTalentIds) + { + if (IsBlank(talentId) || !talentIds.Add(talentId)) + return Fail($"Character talent id '{talentId}' is invalid or duplicated.", out issue); + if (!config.TryGetTalentDefinition(talentId, out _)) + return Fail($"Unknown character talent id '{talentId}'.", out issue); + } + + var injuryIds = new HashSet(); + foreach (var injury in profile.Injuries) + { + if (injury == null || IsBlank(injury.InjuryId) || !injuryIds.Add(injury.InjuryId)) + return Fail("Character injury state is invalid or duplicated.", out issue); + if (!config.TryGetInjuryDefinition(injury.InjuryId, out var definition)) + return Fail($"Unknown character injury id '{injury.InjuryId}'.", out issue); + if (injury.Severity <= 0 || injury.Severity > definition.MaxSeverity) + return Fail($"Character injury '{injury.InjuryId}' has invalid severity.", out issue); + } + + var baselineAbilityIds = new HashSet(); + foreach (string abilityId in profile.BaselineAbilityIds) + { + if (IsBlank(abilityId) || !baselineAbilityIds.Add(abilityId)) + return Fail($"Character baseline ability id '{abilityId}' is invalid or duplicated.", out issue); + if (!config.BaselineAbilityIds.Contains(abilityId)) + return Fail($"Unknown character baseline ability id '{abilityId}'.", out issue); + } + + if (baselineAbilityIds.Count != config.BaselineAbilityIds.Count) + return Fail("Character baseline abilities do not match the configured baseline.", out issue); + + var slotIds = new HashSet(); + foreach (var slot in equipment.Slots) + { + if (slot == null || IsBlank(slot.SlotId) || !slotIds.Add(slot.SlotId)) + return Fail("Character equipment slot is invalid or duplicated.", out issue); + if (!config.EquipmentSlotIds.Contains(slot.SlotId)) + return Fail($"Unknown character equipment slot id '{slot.SlotId}'.", out issue); + } + + return true; + } + + private static bool ContainsAttribute( + List values, + string attributeId) + { + foreach (var value in values) + { + if (value != null && value.AttributeId == attributeId) + return true; + } + + return false; + } + + private static bool TryCollectUniqueIds( + List ids, + string label, + out string issue) + { + issue = null; + var unique = new HashSet(); + foreach (string id in ids) + { + if (IsBlank(id)) return Fail($"Character {label} id is empty.", out issue); + if (!unique.Add(id)) return Fail($"Duplicate character {label} id '{id}'.", out issue); + } + + return true; + } + + private static bool IsFiniteInRange(float value, float min, float max) + { + return IsFinite(value) && value >= min && value <= max; + } + + private static bool IsFinite(float value) + { + return !float.IsNaN(value) && !float.IsInfinity(value); + } + + private static bool IsBlank(string value) + { + return string.IsNullOrWhiteSpace(value); + } + + private static bool Fail(string message, out string issue) + { + issue = message; + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterProfileValidator.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterProfileValidator.cs.meta new file mode 100644 index 0000000..ebd8f43 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCharacterProfileValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1ac635c9cd434fbbab99504124dfe620 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerLootService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerLootService.cs new file mode 100644 index 0000000..cbd6328 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerLootService.cs @@ -0,0 +1,207 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace POB.Extraction +{ + public enum ExtractionContainerOpenResult + { + Opened = 0, + AlreadyOpened = 1, + InvalidRequest = 2, + MissingManifest = 3, + MissingConfiguration = 4, + LootRollFailed = 5 + } + + public static class ExtractionContainerLootService + { + private const string OpenOperationType = "container-open"; + private const string OpenReceiptType = "container-result"; + private const string EntryHashDomain = "zeroengine.extraction.container-open-entry:v1"; + private const string InstanceHashDomain = "zeroengine.extraction.container-open-instance:v1"; + private const string RollHashDomain = "zeroengine.extraction.container-open-roll:v1"; + private const string RevealOrderHashDomain = "zeroengine.extraction.container-reveal-order:v1"; + + public static bool TryOpen( + ExtractionProfileSaveData profile, + ExtractionPlayableConfig config, + string containerId, + out ExtractionRaidContainerManifest container, + out ExtractionContainerOpenResult result) + { + container = null; + result = ExtractionContainerOpenResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled + || profile?.ActiveRaid?.Content == null + || config == null + || string.IsNullOrEmpty(containerId)) + return false; + + var manifest = profile.ActiveRaid.Content.LootManifest; + if (manifest == null) + { + result = ExtractionContainerOpenResult.MissingManifest; + return false; + } + + manifest.EnsureInitialized(); + if (!manifest.TryGetContainer(containerId, out container)) return false; + if (!container.Active) return false; + if (container.Opened) + { + result = ExtractionContainerOpenResult.AlreadyOpened; + return true; + } + + if (!ExtractionRaidLootManifestGenerator.TryGetContentDefinitions( + config, + manifest, + out var tier, + out var lootProfile)) + { + result = ExtractionContainerOpenResult.MissingConfiguration; + return false; + } + + int openSequence = manifest.NextOpenSequence; + string operationId = ExtractionOperationId.Create( + OpenOperationType, + profile.ActiveRaid.RaidId, + container.ContainerId, + openSequence.ToString(CultureInfo.InvariantCulture)); + string receiptId = ExtractionReceiptId.Create(operationId, OpenReceiptType); + var generatedEntries = new List(); + for (int slotIndex = container.Entries.Count; + slotIndex < container.TargetContentCount; + slotIndex++) + { + ExtractionLootPityDefinition pity = ExtractionLootContentPolicy.IsPityEnabled( + lootProfile, + manifest.RareLootDisabled) + ? lootProfile.Pity + : null; + if (!ExtractionRaidLootManifestGenerator.TrySelectLootEntry( + config, + tier, + container.RegionId, + container.ContainerTypeId, + null, + manifest.RaidSeed, + RollHashDomain, + container.ContainerId, + openSequence + ":" + slotIndex, + out var tableEntry, + out var itemDefinition, + manifest.PityState.ConsecutiveMisses, + pity, + manifest.RareLootDisabled)) + { + result = ExtractionContainerOpenResult.LootRollFailed; + return false; + } + + string entryId = ExtractionRaidLootManifestGenerator.CreateStableId( + "entry:v1:", + EntryHashDomain, + manifest.ManifestId, + container.ContainerId, + openSequence.ToString(CultureInfo.InvariantCulture), + slotIndex.ToString(CultureInfo.InvariantCulture)); + string instanceId = ExtractionRaidLootManifestGenerator.CreateStableId( + "item:v1:", + InstanceHashDomain, + manifest.ManifestId, + entryId); + generatedEntries.Add(new ExtractionContainerLootEntry( + entryId, + instanceId, + tableEntry.DefinitionId, + tableEntry.Quantity, + itemDefinition.Rarity, + false)); + } + + container.Entries.AddRange(generatedEntries); + AssignStableRevealOrder(manifest, container, openSequence); + UpdatePity(manifest, lootProfile, container); + container.Opened = true; + container.OpenSequence = openSequence; + container.OpenReceiptId = receiptId; + container.SearchState = new ExtractionContainerSearchState(container.ContainerId) + { + ResultReceiptId = receiptId + }; + manifest.NextOpenSequence++; + AddUnique(profile.ActiveRaid.Content.OpenedContainerIds, container.ContainerId); + AddUnique(profile.ActiveRaid.Content.AppliedReceiptIds, receiptId); + result = ExtractionContainerOpenResult.Opened; + return true; + } + + private static void AssignStableRevealOrder( + ExtractionRaidLootManifest manifest, + ExtractionRaidContainerManifest container, + int openSequence) + { + var ordered = new List(container.Entries); + ordered.Sort((left, right) => + { + uint leftHash = unchecked((uint)ExtractionStableHash.ComputeInt32( + RevealOrderHashDomain, + manifest.RaidSeed.ToString(CultureInfo.InvariantCulture), + container.ContainerId, + openSequence.ToString(CultureInfo.InvariantCulture), + left.EntryId)); + uint rightHash = unchecked((uint)ExtractionStableHash.ComputeInt32( + RevealOrderHashDomain, + manifest.RaidSeed.ToString(CultureInfo.InvariantCulture), + container.ContainerId, + openSequence.ToString(CultureInfo.InvariantCulture), + right.EntryId)); + int comparison = leftHash.CompareTo(rightHash); + return comparison != 0 + ? comparison + : string.CompareOrdinal(left.EntryId, right.EntryId); + }); + + for (int index = 0; index < ordered.Count; index++) + ordered[index].RevealOrder = index; + } + + private static void UpdatePity( + ExtractionRaidLootManifest manifest, + ExtractionLootProfileDefinition profile, + ExtractionRaidContainerManifest container) + { + if (!ExtractionLootContentPolicy.IsPityEnabled(profile, manifest.RareLootDisabled)) + { + manifest.PityState.ConsecutiveMisses = 0; + return; + } + + foreach (var entry in container.Entries) + { + if (entry != null && entry.Rarity >= profile.Pity.TargetRarity) + { + manifest.PityState.ConsecutiveMisses = 0; + return; + } + } + + int maximumMisses = profile.Pity.WeightMultiplierIncrementPerMiss <= 0f + ? 0 + : (int)Math.Ceiling( + Math.Max(0f, profile.Pity.MaximumWeightMultiplier - 1f) + / profile.Pity.WeightMultiplierIncrementPerMiss); + manifest.PityState.ConsecutiveMisses = Math.Min( + maximumMisses, + manifest.PityState.ConsecutiveMisses + 1); + } + + private static void AddUnique(List values, string value) + { + if (values != null && !values.Contains(value)) values.Add(value); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerLootService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerLootService.cs.meta new file mode 100644 index 0000000..f81116e --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerLootService.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 732c5f2f4a9f47998e91450c133afc99 +timeCreated: 1784385600 diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerSearchService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerSearchService.cs new file mode 100644 index 0000000..a177aec --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerSearchService.cs @@ -0,0 +1,245 @@ +using System; + +namespace POB.Extraction +{ + public enum ExtractionContainerSearchResult + { + Started = 0, + Paused = 1, + Progressed = 2, + Revealed = 3, + Completed = 4, + InvalidRequest = 5, + NotOpened = 6, + NotActive = 7, + MissingConfiguration = 8 + } + + public static class ExtractionContainerSearchService + { + private const string RevealReceiptType = "container-reveal"; + + public static bool TryStart( + ExtractionRaidSession raid, + string containerId, + out ExtractionContainerSearchResult result) + { + result = ExtractionContainerSearchResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled) return false; + var manifest = raid?.Content?.LootManifest; + if (manifest == null || string.IsNullOrEmpty(containerId)) return false; + manifest.EnsureInitialized(); + if (!manifest.TryGetContainer(containerId, out var container) || !container.Opened) + { + result = ExtractionContainerSearchResult.NotOpened; + return false; + } + + if (!string.IsNullOrEmpty(manifest.ActiveSearchContainerId) + && manifest.ActiveSearchContainerId != containerId + && manifest.TryGetContainer(manifest.ActiveSearchContainerId, out var previous)) + { + previous.SearchState.Paused = true; + } + + manifest.ActiveSearchContainerId = containerId; + container.SearchState.Paused = false; + result = container.SearchState.Completed + ? ExtractionContainerSearchResult.Completed + : ExtractionContainerSearchResult.Started; + return true; + } + + public static bool TryPause( + ExtractionRaidSession raid, + string containerId, + out ExtractionContainerSearchResult result) + { + result = ExtractionContainerSearchResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled) return false; + var manifest = raid?.Content?.LootManifest; + if (manifest == null || !manifest.TryGetContainer(containerId, out var container)) return false; + container.SearchState.Paused = true; + if (manifest.ActiveSearchContainerId == containerId) + manifest.ActiveSearchContainerId = null; + result = ExtractionContainerSearchResult.Paused; + return true; + } + + public static bool TryAdvance( + ExtractionRaidSession raid, + ExtractionPlayableConfig config, + string containerId, + float gameplayDeltaSeconds, + float characterSearchSpeedMultiplier, + out ExtractionContainerLootEntry revealedEntry, + out ExtractionContainerSearchResult result) + { + revealedEntry = null; + result = ExtractionContainerSearchResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled || gameplayDeltaSeconds < 0f) return false; + + var manifest = raid?.Content?.LootManifest; + if (manifest == null || config == null || !manifest.TryGetContainer(containerId, out var container)) + return false; + if (!container.Opened) + { + result = ExtractionContainerSearchResult.NotOpened; + return false; + } + if (container.SearchState.Completed) + { + result = ExtractionContainerSearchResult.Completed; + return true; + } + if (manifest.ActiveSearchContainerId != containerId || container.SearchState.Paused) + { + result = ExtractionContainerSearchResult.NotActive; + return false; + } + if (!ExtractionRaidLootManifestGenerator.TryGetContentDefinitions( + config, + manifest, + out _, + out var profile) + || profile.RevealTimes?.BaseRevealSeconds == null) + { + result = ExtractionContainerSearchResult.MissingConfiguration; + return false; + } + + if (!TryGetCurrentEntry(container, out var current)) + { + Complete(manifest, container); + result = ExtractionContainerSearchResult.Completed; + return true; + } + + float speed = Math.Max(0.25f, Math.Min(4f, characterSearchSpeedMultiplier)); + float requiredSeconds = Math.Max( + 0.25f, + profile.RevealTimes.BaseRevealSeconds.Get(current.Rarity) + * container.SearchTimeMultiplier + / speed); + container.SearchState.CurrentEntryElapsedSeconds += gameplayDeltaSeconds; + if (container.SearchState.CurrentEntryElapsedSeconds < requiredSeconds) + { + result = ExtractionContainerSearchResult.Progressed; + return true; + } + + current.State = ExtractionContainerLootEntryState.Revealed; + current.RevealReceiptId = ExtractionReceiptId.Create( + ExtractionOperationId.Create( + "container-reveal", + raid.RaidId, + container.ContainerId, + current.EntryId), + RevealReceiptType); + if (!raid.Content.AppliedReceiptIds.Contains(current.RevealReceiptId)) + raid.Content.AppliedReceiptIds.Add(current.RevealReceiptId); + container.SearchState.CurrentRevealOrder++; + container.SearchState.CurrentEntryElapsedSeconds = 0f; + revealedEntry = current; + + if (container.SearchState.CurrentRevealOrder >= container.Entries.Count) + { + Complete(manifest, container); + result = ExtractionContainerSearchResult.Completed; + } + else + { + result = ExtractionContainerSearchResult.Revealed; + } + + return true; + } + + public static bool CanTransfer( + ExtractionRaidSession raid, + string containerId, + string entryId) + { + return TryGetEntry(raid, containerId, entryId, out var entry) + && entry.State == ExtractionContainerLootEntryState.Revealed; + } + + public static bool TryMarkTransferred( + ExtractionRaidSession raid, + string containerId, + string entryId, + string transferReceiptId) + { + if (!ExtractionFeatureSwitch.Enabled + || string.IsNullOrEmpty(transferReceiptId) + || !TryGetEntry(raid, containerId, entryId, out var entry) + || entry.State != ExtractionContainerLootEntryState.Revealed) + { + return false; + } + + entry.State = ExtractionContainerLootEntryState.Transferred; + entry.TransferReceiptId = transferReceiptId; + if (!raid.Content.AppliedReceiptIds.Contains(transferReceiptId)) + raid.Content.AppliedReceiptIds.Add(transferReceiptId); + return true; + } + + private static bool TryGetCurrentEntry( + ExtractionRaidContainerManifest container, + out ExtractionContainerLootEntry entry) + { + foreach (var candidate in container.Entries) + { + if (candidate != null + && candidate.RevealOrder == container.SearchState.CurrentRevealOrder + && candidate.State == ExtractionContainerLootEntryState.CommittedHidden) + { + entry = candidate; + return true; + } + } + + entry = null; + return false; + } + + private static bool TryGetEntry( + ExtractionRaidSession raid, + string containerId, + string entryId, + out ExtractionContainerLootEntry entry) + { + entry = null; + var manifest = raid?.Content?.LootManifest; + if (manifest == null + || !manifest.TryGetContainer(containerId, out var container) + || string.IsNullOrEmpty(entryId)) + { + return false; + } + + foreach (var candidate in container.Entries) + { + if (candidate != null && candidate.EntryId == entryId) + { + entry = candidate; + return true; + } + } + + return false; + } + + private static void Complete( + ExtractionRaidLootManifest manifest, + ExtractionRaidContainerManifest container) + { + container.SearchState.Completed = true; + container.SearchState.Paused = true; + container.SearchState.CurrentEntryElapsedSeconds = 0f; + if (manifest.ActiveSearchContainerId == container.ContainerId) + manifest.ActiveSearchContainerId = null; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerSearchService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerSearchService.cs.meta new file mode 100644 index 0000000..8b94834 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerSearchService.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ca2ea260e2bf4adba18c24f208b80480 +timeCreated: 1784385600 diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerTransferService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerTransferService.cs new file mode 100644 index 0000000..fb1b555 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerTransferService.cs @@ -0,0 +1,175 @@ +using System; + +namespace POB.Extraction +{ + public enum ExtractionContainerTransferResult + { + Succeeded = 0, + AlreadyTransferred = 1, + InvalidRequest = 2, + EntryNotFound = 3, + NotRevealed = 4, + DefinitionNotFound = 5, + PolicyDenied = 6, + NoSpace = 7, + LocationConflict = 8, + CommitFailed = 9 + } + + public static class ExtractionContainerTransferService + { + public static bool TryTransfer( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState raidInventory, + IExtractionItemCatalog itemCatalog, + string containerId, + string entryId, + ExtractionInventoryContainerType targetContainer, + string transferReceiptId, + out string itemInstanceId, + out ExtractionContainerTransferResult result) + { + itemInstanceId = null; + result = ExtractionContainerTransferResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled + || profile?.ActiveRaid?.Content?.LootManifest == null + || raidInventory == null + || itemCatalog == null + || string.IsNullOrEmpty(containerId) + || string.IsNullOrEmpty(entryId) + || string.IsNullOrEmpty(transferReceiptId) + || (targetContainer != ExtractionInventoryContainerType.RaidBackpack + && targetContainer != ExtractionInventoryContainerType.InSecureContainer)) + { + return false; + } + + profile.EnsureInitialized(); + raidInventory.EnsureInitialized(); + if (!TryGetEntry(profile.ActiveRaid, containerId, entryId, out var entry)) + { + result = ExtractionContainerTransferResult.EntryNotFound; + return false; + } + + itemInstanceId = entry.ItemInstanceId; + if (entry.State == ExtractionContainerLootEntryState.Transferred) + { + if (entry.TransferReceiptId == transferReceiptId + && profile.Items.TryGet(itemInstanceId, out _) + && profile.Ownership.TryGetContainer(itemInstanceId, out var existingContainer) + && existingContainer == targetContainer) + { + result = ExtractionContainerTransferResult.AlreadyTransferred; + return true; + } + + result = ExtractionContainerTransferResult.LocationConflict; + return false; + } + + if (entry.State != ExtractionContainerLootEntryState.Revealed) + { + result = ExtractionContainerTransferResult.NotRevealed; + return false; + } + if (string.IsNullOrEmpty(itemInstanceId) + || string.IsNullOrEmpty(entry.DefinitionId) + || entry.Quantity <= 0 + || !itemCatalog.TryGetItemDefinition(entry.DefinitionId, out var definition)) + { + result = ExtractionContainerTransferResult.DefinitionNotFound; + return false; + } + if (targetContainer == ExtractionInventoryContainerType.InSecureContainer + && !ExtractionItemActionPolicyService.CanPlaceInSecure(definition)) + { + result = ExtractionContainerTransferResult.PolicyDenied; + return false; + } + if (profile.Items.TryGet(itemInstanceId, out _) + || profile.Ownership.TryGetContainer(itemInstanceId, out _)) + { + result = ExtractionContainerTransferResult.LocationConflict; + return false; + } + if (!ExtractionItemLocationService.TryGetGrid( + profile, + raidInventory, + targetContainer, + out var targetGrid) + || !targetGrid.TryFindFreeSlotWithRotation( + definition.Width, + definition.Height, + definition.CanRotate, + out int x, + out int y, + out bool rotated)) + { + result = ExtractionContainerTransferResult.NoSpace; + return false; + } + + var item = new ExtractionItemInstance( + itemInstanceId, + entry.DefinitionId, + entry.Quantity, + "container", + containerId); + ExtractionItemActionPolicyService.ApplyDefinitionPolicyToInstance(definition, item); + if (!profile.Items.Register(item)) + { + result = ExtractionContainerTransferResult.LocationConflict; + return false; + } + if (!targetGrid.TryPlace(item, definition, x, y, rotated)) + { + profile.Items.TryRemove(itemInstanceId); + result = ExtractionContainerTransferResult.NoSpace; + return false; + } + if (!profile.Ownership.Register(itemInstanceId, targetContainer)) + { + targetGrid.TryRemove(itemInstanceId); + profile.Items.TryRemove(itemInstanceId); + result = ExtractionContainerTransferResult.LocationConflict; + return false; + } + if (!ExtractionContainerSearchService.TryMarkTransferred( + profile.ActiveRaid, + containerId, + entryId, + transferReceiptId)) + { + profile.Ownership.TryRemove(itemInstanceId); + targetGrid.TryRemove(itemInstanceId); + profile.Items.TryRemove(itemInstanceId); + result = ExtractionContainerTransferResult.LocationConflict; + return false; + } + + result = ExtractionContainerTransferResult.Succeeded; + return true; + } + + private static bool TryGetEntry( + ExtractionRaidSession raid, + string containerId, + string entryId, + out ExtractionContainerLootEntry entry) + { + entry = null; + var manifest = raid?.Content?.LootManifest; + if (manifest == null || !manifest.TryGetContainer(containerId, out var container)) return false; + + foreach (var candidate in container.Entries) + { + if (candidate?.EntryId != entryId) continue; + if (entry != null) return false; + entry = candidate; + } + + return entry != null; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerTransferService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerTransferService.cs.meta new file mode 100644 index 0000000..2046012 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionContainerTransferService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 434bf9c539bf4d4999b92c1f8ed02ed3 diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLootLedger.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLootLedger.cs new file mode 100644 index 0000000..7fe45e6 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLootLedger.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionCorpseLootLedger + { + public List Records = new(); + + public bool RegisterCorpse(string corpseId, ExtractionLostRunSnapshot snapshot) + { + if (string.IsNullOrEmpty(corpseId) || snapshot == null) return false; + if (string.IsNullOrEmpty(snapshot.SnapshotId)) return false; + if (FindRecord(corpseId) != null) return false; + + var record = new ExtractionCorpseLootRecord(corpseId, snapshot.SnapshotId, snapshot.MapId) + { + DeathRegion = snapshot.DeathRegion, + CorpseSeed = snapshot.CorpseSeed + }; + if (snapshot.LostItemInstanceIds != null) + { + foreach (var itemInstanceId in snapshot.LostItemInstanceIds) + { + if (string.IsNullOrEmpty(itemInstanceId)) continue; + if (Contains(record.ItemInstanceIds, itemInstanceId)) continue; + record.ItemInstanceIds.Add(itemInstanceId); + } + } + + Records.Add(record); + return true; + } + + public bool ContainsCorpse(string corpseId) + { + return FindRecord(corpseId) != null; + } + + public bool TryGetRecord(string corpseId, out ExtractionCorpseLootRecord record) + { + record = FindRecord(corpseId); + return record != null; + } + + public bool TryGetAvailableItemIds(string corpseId, List results) + { + if (results == null) return false; + results.Clear(); + + var record = FindRecord(corpseId); + if (record == null) return false; + + foreach (var itemInstanceId in record.ItemInstanceIds) + { + if (Contains(record.LootedItemInstanceIds, itemInstanceId)) continue; + results.Add(itemInstanceId); + } + + return results.Count > 0; + } + + public bool IsItemAvailable(string corpseId, string itemInstanceId) + { + var record = FindRecord(corpseId); + if (record == null) return false; + if (!Contains(record.ItemInstanceIds, itemInstanceId)) return false; + return !Contains(record.LootedItemInstanceIds, itemInstanceId); + } + + public bool TryMarkLooted(string corpseId, string itemInstanceId) + { + if (string.IsNullOrEmpty(itemInstanceId)) return false; + + var record = FindRecord(corpseId); + if (record == null) return false; + if (!Contains(record.ItemInstanceIds, itemInstanceId)) return false; + if (Contains(record.LootedItemInstanceIds, itemInstanceId)) return false; + + record.LootedItemInstanceIds.Add(itemInstanceId); + return true; + } + + private ExtractionCorpseLootRecord FindRecord(string corpseId) + { + if (string.IsNullOrEmpty(corpseId)) return null; + + foreach (var record in Records) + { + if (record != null && record.CorpseId == corpseId) return record; + } + + return null; + } + + private static bool Contains(List values, string value) + { + if (values == null || string.IsNullOrEmpty(value)) return false; + + foreach (var candidate in values) + { + if (candidate == value) return true; + } + + return false; + } + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionCorpseLootRecord + { + public string CorpseId; + public string SnapshotId; + public string MapId; + public string DeathRegion; + public int CorpseSeed; + public List ItemInstanceIds = new(); + public List LootedItemInstanceIds = new(); + + public ExtractionCorpseLootRecord(string corpseId, string snapshotId, string mapId) + { + CorpseId = corpseId; + SnapshotId = snapshotId; + MapId = mapId; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLootLedger.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLootLedger.cs.meta new file mode 100644 index 0000000..72fa52a --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLootLedger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1a8d8cf5221e70f4ba07b293d7dd8f99 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLootService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLootService.cs new file mode 100644 index 0000000..507ccf2 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLootService.cs @@ -0,0 +1,43 @@ +namespace POB.Extraction +{ + public static class ExtractionCorpseLootService + { + public static bool TryLootCorpseItem( + ExtractionProfileSaveData profile, + ExtractionCorpseLootLedger corpseLedger, + string corpseId, + string itemInstanceId) + { + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || corpseLedger == null) return false; + if (!corpseLedger.IsItemAvailable(corpseId, itemInstanceId)) return false; + if (!profile.Recovery.TryGetState(itemInstanceId, out var state)) return false; + if (state != ExtractionRecoveryItemState.Recoverable) return false; + + if (!profile.Ownership.TryMove( + itemInstanceId, + ExtractionInventoryContainerType.Lost, + ExtractionInventoryContainerType.Holding)) + { + return false; + } + + if (!corpseLedger.TryMarkLooted(corpseId, itemInstanceId)) + { + profile.Ownership.TryMove( + itemInstanceId, + ExtractionInventoryContainerType.Holding, + ExtractionInventoryContainerType.Lost); + return false; + } + + if (profile.Recovery.TryMarkRecovered(itemInstanceId)) return true; + + profile.Ownership.TryMove( + itemInstanceId, + ExtractionInventoryContainerType.Holding, + ExtractionInventoryContainerType.Lost); + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLootService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLootService.cs.meta new file mode 100644 index 0000000..944e40c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLootService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ee8ba4056a6b2814bb3ef2fc15e4697f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLossService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLossService.cs new file mode 100644 index 0000000..46acec1 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLossService.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + // M2 SD2.2a:尸袋档"宽松"档的拆分算法。Default/Hardcore 不需要算法(全丢或全保留,调用方直接用 + // 既有的全量 id 列表),只有 Lenient 需要"挑哪一半丢"——按物品价值升序排序,价值最低的 + // lossFraction 比例(向下取整,宽松档尽量少丢)记为丢失,价值最高的那部分记为保留。 + // 复合排序键(Value, OriginalIndex)让同价值物品按传入顺序稳定排列,结果可复现,不依赖 + // List.Sort 本身是否稳定的实现细节。 + public static class ExtractionCorpseLossService + { + public static void SplitLenientTierItems( + List<(string InstanceId, int Value)> items, + float lossFraction, + List lostItemIds, + List keptItemIds) + { + lostItemIds.Clear(); + keptItemIds.Clear(); + if (items == null || items.Count == 0) return; + + var indexed = new List<(string InstanceId, int Value, int OriginalIndex)>(items.Count); + for (int i = 0; i < items.Count; i++) + indexed.Add((items[i].InstanceId, items[i].Value, i)); + + indexed.Sort((a, b) => + { + int valueCompare = a.Value.CompareTo(b.Value); + return valueCompare != 0 ? valueCompare : a.OriginalIndex.CompareTo(b.OriginalIndex); + }); + + int lossCount = (int)(indexed.Count * lossFraction); + for (int i = 0; i < indexed.Count; i++) + { + if (i < lossCount) lostItemIds.Add(indexed[i].InstanceId); + else keptItemIds.Add(indexed[i].InstanceId); + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLossService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLossService.cs.meta new file mode 100644 index 0000000..249168c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLossService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8678ee86dc2d7264e888e6b3a363f857 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLossTier.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLossTier.cs new file mode 100644 index 0000000..c9f4585 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLossTier.cs @@ -0,0 +1,11 @@ +namespace POB.Extraction +{ + // M2 SD2.1:尸袋档;Default(0) 是现状行为(raid背包+装备全丢,secure容器保留),必须保持 + // 枚举序号 0,让旧存档反序列化后默认落在这一档而不改变已有玩家体感。 + public enum ExtractionCorpseLossTier + { + Default = 0, + Lenient = 1, + Hardcore = 2 + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLossTier.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLossTier.cs.meta new file mode 100644 index 0000000..a4e78cc --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCorpseLossTier.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b41b5b465c6fdf547a2a4b663573e6eb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingRecipeDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingRecipeDefinition.cs new file mode 100644 index 0000000..619a18c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingRecipeDefinition.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionCraftingIngredient + { + public string ItemDefinitionId; + public int Quantity; + + public bool IsValid => !string.IsNullOrEmpty(ItemDefinitionId) && Quantity > 0; + + public ExtractionCraftingIngredient(string itemDefinitionId, int quantity) + { + ItemDefinitionId = itemDefinitionId; + Quantity = quantity; + } + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionCraftingRecipeDefinition + { + public string RecipeId; + public List Ingredients = new(); + public string OutputItemDefinitionId; + public int OutputQuantity; + public int RequiredCraftingLevel; + public string RequiredSharedUnlockId; + public int RequiredSharedUnlockLevel; + public string RequiredSharedTalentId; + + // M2 SB2.2 新增(additive):配方蓝图门。空/未设置=不需要蓝图,老配置零改动兼容; + // dangling 引用交给 validator 拒绝(IsValid 这里没有 config 可查蓝图表是否真存在)。 + public string RequiredBlueprintId; + + public bool IsValid + { + get + { + if (string.IsNullOrEmpty(RecipeId)) return false; + if (string.IsNullOrEmpty(OutputItemDefinitionId) || OutputQuantity <= 0) return false; + if (RequiredCraftingLevel <= 0) return false; + if (Ingredients == null || Ingredients.Count == 0) return false; + + foreach (var ingredient in Ingredients) + { + if (ingredient == null || !ingredient.IsValid) return false; + } + + return true; + } + } + + public ExtractionCraftingRecipeDefinition( + string recipeId, + string outputItemDefinitionId, + int outputQuantity, + int requiredCraftingLevel) + { + RecipeId = recipeId; + OutputItemDefinitionId = outputItemDefinitionId; + OutputQuantity = outputQuantity; + RequiredCraftingLevel = requiredCraftingLevel; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingRecipeDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingRecipeDefinition.cs.meta new file mode 100644 index 0000000..ae81412 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingRecipeDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7fc921be8e7052a4aa0449314d189645 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingRecipeService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingRecipeService.cs new file mode 100644 index 0000000..4e8fe89 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingRecipeService.cs @@ -0,0 +1,79 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionCraftingRecipeService + { + public static bool TryGetAvailableRecipes( + ExtractionProfileSaveData profile, + List recipes, + IExtractionItemCatalog itemCatalog, + IExtractionMetaProgressProvider metaProgress, + List results) + { + if (results == null) return false; + results.Clear(); + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || recipes == null || itemCatalog == null) return false; + profile.EnsureInitialized(); + + int craftingLevel = profile.Facilities.GetLevel(ExtractionFacilityType.Crafting); + foreach (var recipe in recipes) + { + if (recipe == null || !recipe.IsValid) continue; + if (craftingLevel < recipe.RequiredCraftingLevel) continue; + if (!HasCatalogItems(recipe, itemCatalog)) continue; + if (!MeetsMetaRequirements(recipe, metaProgress)) continue; + if (!HasRequiredBlueprint(profile, recipe)) continue; + + results.Add(recipe); + } + + return results.Count > 0; + } + + private static bool HasCatalogItems( + ExtractionCraftingRecipeDefinition recipe, + IExtractionItemCatalog itemCatalog) + { + if (!itemCatalog.TryGetItemDefinition(recipe.OutputItemDefinitionId, out _)) return false; + + foreach (var ingredient in recipe.Ingredients) + { + if (!itemCatalog.TryGetItemDefinition(ingredient.ItemDefinitionId, out _)) return false; + } + + return true; + } + + private static bool MeetsMetaRequirements( + ExtractionCraftingRecipeDefinition recipe, + IExtractionMetaProgressProvider metaProgress) + { + metaProgress ??= new ExtractionNoOpMetaProgressProvider(); + + if (!string.IsNullOrEmpty(recipe.RequiredSharedUnlockId)) + { + if (recipe.RequiredSharedUnlockLevel <= 0) return false; + if (metaProgress.GetSharedUnlockLevel(recipe.RequiredSharedUnlockId) < recipe.RequiredSharedUnlockLevel) + return false; + } + + if (!string.IsNullOrEmpty(recipe.RequiredSharedTalentId)) + { + if (!metaProgress.HasSharedTalent(recipe.RequiredSharedTalentId)) return false; + } + + return true; + } + + // M2 SB2.2:配方蓝图门,照 RequiredSharedUnlockId/RequiredSharedTalentId 同款"空=不限制"惯例。 + private static bool HasRequiredBlueprint( + ExtractionProfileSaveData profile, + ExtractionCraftingRecipeDefinition recipe) + { + if (string.IsNullOrEmpty(recipe.RequiredBlueprintId)) return true; + return ExtractionBlueprintService.IsBlueprintUnlocked(profile, recipe.RequiredBlueprintId); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingRecipeService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingRecipeService.cs.meta new file mode 100644 index 0000000..0ed02e4 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingRecipeService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 511516cbfaf4c904e8e7e3fbb0362d69 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingTransactionService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingTransactionService.cs new file mode 100644 index 0000000..cee8a4d --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingTransactionService.cs @@ -0,0 +1,131 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionCraftingTransactionService + { + public static bool TryCraftRecipe( + ExtractionProfileSaveData profile, + ExtractionCraftingRecipeDefinition recipe, + IExtractionItemCatalog itemCatalog, + List ingredientSourceContainers, + ExtractionInventoryContainerType targetContainer, + string outputItemInstanceId, + int x, + int y, + bool rotated) + { + if (!CanCraftRecipe(profile, recipe, itemCatalog, outputItemInstanceId, out var outputDefinition)) + return false; + if (!TryConvertIngredients(recipe.Ingredients, out var costs)) return false; + if (!TryGetGrid(profile, targetContainer, out var targetGrid)) return false; + + var outputItem = new ExtractionItemInstance( + outputItemInstanceId, + recipe.OutputItemDefinitionId, + recipe.OutputQuantity); + ExtractionItemActionPolicyService.ApplyDefinitionPolicyToInstance(outputDefinition, outputItem); + if (!CanPlace(targetGrid, outputItem, outputDefinition, x, y, rotated)) return false; + if (!ExtractionItemCostService.HasEnoughItems(profile, costs, ingredientSourceContainers)) return false; + if (!ExtractionItemCostService.TryConsumeCosts(profile, costs, ingredientSourceContainers, out var costReceipt)) return false; + + if (!profile.Items.Register(outputItem)) + { + ExtractionItemCostService.RestoreCosts(profile, costReceipt, itemCatalog); + return false; + } + if (!targetGrid.TryPlace(outputItem, outputDefinition, x, y, rotated)) + { + profile.Items.TryRemove(outputItemInstanceId); + ExtractionItemCostService.RestoreCosts(profile, costReceipt, itemCatalog); + return false; + } + + if (profile.Ownership.Register(outputItemInstanceId, targetContainer)) return true; + + targetGrid.TryRemove(outputItemInstanceId); + profile.Items.TryRemove(outputItemInstanceId); + ExtractionItemCostService.RestoreCosts(profile, costReceipt, itemCatalog); + return false; + } + + private static bool CanCraftRecipe( + ExtractionProfileSaveData profile, + ExtractionCraftingRecipeDefinition recipe, + IExtractionItemCatalog itemCatalog, + string outputItemInstanceId, + out ExtractionItemDefinition outputDefinition) + { + outputDefinition = null; + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || recipe == null || !recipe.IsValid || itemCatalog == null) return false; + if (string.IsNullOrEmpty(outputItemInstanceId)) return false; + profile.EnsureInitialized(); + if (profile.Facilities.GetLevel(ExtractionFacilityType.Crafting) < recipe.RequiredCraftingLevel) + return false; + // M2 SB2.2:照 RequiredCraftingLevel 同款纵深防御——不信任调用方只会拿"可用列表"里的 + // recipeId 来调用,蓝图门在执行层再兜底一次(查询层 TryGetAvailableRecipes 已经过滤过)。 + if (!string.IsNullOrEmpty(recipe.RequiredBlueprintId) + && !ExtractionBlueprintService.IsBlueprintUnlocked(profile, recipe.RequiredBlueprintId)) + { + return false; + } + if (profile.Items.TryGet(outputItemInstanceId, out _)) return false; + if (profile.Ownership.TryGetContainer(outputItemInstanceId, out _)) return false; + if (!itemCatalog.TryGetItemDefinition(recipe.OutputItemDefinitionId, out outputDefinition)) return false; + + foreach (var ingredient in recipe.Ingredients) + { + if (!itemCatalog.TryGetItemDefinition(ingredient.ItemDefinitionId, out _)) return false; + } + + return true; + } + + private static bool TryConvertIngredients( + List ingredients, + out List costs) + { + costs = new List(); + if (ingredients == null || ingredients.Count == 0) return false; + + foreach (var ingredient in ingredients) + { + if (ingredient == null || !ingredient.IsValid) return false; + costs.Add(new ExtractionItemCost(ingredient.ItemDefinitionId, ingredient.Quantity)); + } + + return true; + } + + private static bool CanPlace( + ExtractionItemGrid grid, + ExtractionItemInstance item, + ExtractionItemDefinition definition, + int x, + int y, + bool rotated) + { + if (!grid.TryPlace(item, definition, x, y, rotated)) return false; + grid.TryRemove(item.InstanceId); + return true; + } + + private static bool TryGetGrid( + ExtractionProfileSaveData profile, + ExtractionInventoryContainerType container, + out ExtractionItemGrid grid) + { + grid = container switch + { + ExtractionInventoryContainerType.Stash => profile.Stash, + ExtractionInventoryContainerType.Loadout => profile.Loadout, + ExtractionInventoryContainerType.SecureContainer => profile.SecureContainer, + ExtractionInventoryContainerType.Holding => profile.RecoveryHolding, + _ => null + }; + + return grid != null; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingTransactionService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingTransactionService.cs.meta new file mode 100644 index 0000000..26fffd1 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionCraftingTransactionService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 555d8b83a1a389b41b2fc83804ef9d72 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionDifficultySettings.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionDifficultySettings.cs new file mode 100644 index 0000000..ec617a0 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionDifficultySettings.cs @@ -0,0 +1,18 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + // M2 SD2.1:玩家当前选择的难度档位(profile 字段)。三个字段的"零值"都必须对应现状行为—— + // CorpseLossTier.Default=0、EnemyDamageTier.Normal=0、RareLootDisabled=false——这样旧存档 + // 缺这个字段时,不论 JsonUtility 是保留字段初始值还是回落到类型默认值,结果都是现状行为, + // 满足"滑块各档存档兼容"验收项。 + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionDifficultySettings + { + public ExtractionCorpseLossTier CorpseLossTier; + public ExtractionEnemyDamageTier EnemyDamageTier; + public bool RareLootDisabled; + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionDifficultySettings.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionDifficultySettings.cs.meta new file mode 100644 index 0000000..cbd12f6 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionDifficultySettings.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5e4eb1fc9a7adbc4da8f3f77e5abf0e1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEncumbranceService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEncumbranceService.cs new file mode 100644 index 0000000..8c583bd --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEncumbranceService.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionEncumbranceService + { + // 纯函数:给定总重/容量,挑出"MinLoadPercent 最大且仍 <= 当前负重比例"的档——即已跨过的 + // 最高档位。容量<=0 或空档位表视为不参与负重系统(不生成任何 tier,调用方保持无惩罚状态)。 + public static bool TrySelectTier( + List tiers, + int totalWeight, + int capacity, + out ExtractionEncumbranceTierDefinition tier) + { + tier = null; + if (tiers == null || tiers.Count == 0) return false; + if (capacity <= 0) return false; + + int loadPercent = (int)((long)totalWeight * 100 / capacity); + + foreach (var candidate in tiers) + { + if (candidate == null || !candidate.IsValid) continue; + if (loadPercent < candidate.MinLoadPercent) continue; + if (tier == null || candidate.MinLoadPercent > tier.MinLoadPercent) tier = candidate; + } + + return tier != null; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEncumbranceService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEncumbranceService.cs.meta new file mode 100644 index 0000000..32f7a49 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEncumbranceService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 11c08bb20606c3b4085b974406aed5d5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEncumbranceTierDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEncumbranceTierDefinition.cs new file mode 100644 index 0000000..2703ef3 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEncumbranceTierDefinition.cs @@ -0,0 +1,29 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + // M2 SW.2 负重档位:MinLoadPercent 是该档生效的负重比例下限(含),MovementPercentModifier 是 + // 移速的 PercentMult 修正值(如 -0.3 = 降 30%),DisablesDoubleJump 为真时二段跳被压到 1 次。 + // 0% 档通常 MovementPercentModifier=0/DisablesDoubleJump=false,代表"无惩罚"基线。 + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionEncumbranceTierDefinition + { + public int MinLoadPercent; + public float MovementPercentModifier; + public bool DisablesDoubleJump; + + public bool IsValid => MinLoadPercent >= 0 && MovementPercentModifier > -1f; + + public ExtractionEncumbranceTierDefinition( + int minLoadPercent, + float movementPercentModifier, + bool disablesDoubleJump) + { + MinLoadPercent = minLoadPercent; + MovementPercentModifier = movementPercentModifier; + DisablesDoubleJump = disablesDoubleJump; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEncumbranceTierDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEncumbranceTierDefinition.cs.meta new file mode 100644 index 0000000..9d7bed0 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEncumbranceTierDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 88d9e43a822cf1d4292ddd35aa673e59 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEnemyDamageTier.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEnemyDamageTier.cs new file mode 100644 index 0000000..c7d9192 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEnemyDamageTier.cs @@ -0,0 +1,11 @@ +namespace POB.Extraction +{ + // M2 SD2.1:敌伤档;Normal(0) = ×1,是现状行为,必须保持枚举序号 0,让旧存档反序列化后 + // 默认落在这一档而不改变已有玩家体感。 + public enum ExtractionEnemyDamageTier + { + Normal = 0, + Half = 1, + Hard = 2 + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEnemyDamageTier.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEnemyDamageTier.cs.meta new file mode 100644 index 0000000..1893768 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEnemyDamageTier.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: db32041cdcf9e3245965bc8e4d00b419 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEquipmentState.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEquipmentState.cs new file mode 100644 index 0000000..9a1ebca --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEquipmentState.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + [Serializable] + public class ExtractionEquipmentState + { + public List Slots = new(); + public List AppliedReceiptIds = new(); + + internal void EnsureInitialized() + { + Slots ??= new List(); + AppliedReceiptIds ??= new List(); + } + + public bool TryGetItem(string slotId, out string itemInstanceId) + { + itemInstanceId = null; + if (string.IsNullOrEmpty(slotId) || Slots == null) return false; + + foreach (var slot in Slots) + { + if (slot == null || slot.SlotId != slotId) continue; + itemInstanceId = slot.ItemInstanceId; + return !string.IsNullOrEmpty(itemInstanceId); + } + + return false; + } + + public bool TryGetSlotForItem(string itemInstanceId, out string slotId) + { + slotId = null; + if (string.IsNullOrEmpty(itemInstanceId) || Slots == null) return false; + + foreach (var slot in Slots) + { + if (slot == null || slot.ItemInstanceId != itemInstanceId) continue; + slotId = slot.SlotId; + return !string.IsNullOrEmpty(slotId); + } + + return false; + } + + internal bool TrySet(string slotId, string itemInstanceId) + { + EnsureInitialized(); + if (string.IsNullOrEmpty(slotId) || string.IsNullOrEmpty(itemInstanceId)) return false; + if (TryGetSlotForItem(itemInstanceId, out var existingSlot) && existingSlot != slotId) + return false; + + foreach (var slot in Slots) + { + if (slot == null || slot.SlotId != slotId) continue; + slot.ItemInstanceId = itemInstanceId; + return true; + } + + Slots.Add(new ExtractionEquipmentSlotState(slotId, itemInstanceId)); + return true; + } + + internal bool TryClear(string slotId, string expectedItemInstanceId) + { + if (string.IsNullOrEmpty(slotId) || string.IsNullOrEmpty(expectedItemInstanceId) || Slots == null) + return false; + + for (int i = 0; i < Slots.Count; i++) + { + var slot = Slots[i]; + if (slot == null || slot.SlotId != slotId || slot.ItemInstanceId != expectedItemInstanceId) + continue; + + Slots.RemoveAt(i); + return true; + } + + return false; + } + } + + [Serializable] + public class ExtractionEquipmentSlotState + { + public string SlotId; + public string ItemInstanceId; + public string EffectReceiptId; + + public ExtractionEquipmentSlotState(string slotId, string itemInstanceId) + : this(slotId, itemInstanceId, null) + { + } + + public ExtractionEquipmentSlotState( + string slotId, + string itemInstanceId, + string effectReceiptId) + { + SlotId = slotId; + ItemInstanceId = itemInstanceId; + EffectReceiptId = effectReceiptId; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEquipmentState.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEquipmentState.cs.meta new file mode 100644 index 0000000..c241dfc --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEquipmentState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 697e6d9e7d174a51be26a55cb7d962e5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEquipmentTransactionService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEquipmentTransactionService.cs new file mode 100644 index 0000000..b32a703 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEquipmentTransactionService.cs @@ -0,0 +1,390 @@ +using System; + +namespace POB.Extraction +{ + public enum ExtractionEquipmentTransactionResult + { + Succeeded = 0, + AlreadyApplied = 1, + InvalidRequest = 2, + ItemNotFound = 3, + PolicyDenied = 4, + WrongSlot = 5, + LocationConflict = 6, + NoSpace = 7, + CommitFailed = 8 + } + + public static class ExtractionEquipmentTransactionService + { + public static bool TryEquip( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState raidInventory, + IExtractionItemCatalog itemCatalog, + string itemInstanceId, + ExtractionInventoryContainerType sourceContainer, + string equipmentLocationSubtype, + string slotId, + ExtractionEquipmentSlotType slotType, + string receiptId, + out string displacedItemInstanceId, + out ExtractionEquipmentTransactionResult result) + { + displacedItemInstanceId = null; + result = ExtractionEquipmentTransactionResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled + || profile == null + || itemCatalog == null + || string.IsNullOrEmpty(itemInstanceId) + || string.IsNullOrEmpty(slotId) + || string.IsNullOrEmpty(receiptId) + || !Enum.IsDefined(typeof(ExtractionEquipmentSlotType), slotType) + || slotType == ExtractionEquipmentSlotType.None + || !IsSourceAllowed(sourceContainer, equipmentLocationSubtype)) + { + return false; + } + + profile.EnsureInitialized(); + raidInventory?.EnsureInitialized(); + if (!ExtractionItemLocationService.TryGetEquipment( + profile, + raidInventory, + equipmentLocationSubtype, + out var equipment) + || !ExtractionItemLocationService.TryGetGrid( + profile, + raidInventory, + sourceContainer, + out var sourceGrid)) + { + result = ExtractionEquipmentTransactionResult.LocationConflict; + return false; + } + + equipment.EnsureInitialized(); + if (equipment.AppliedReceiptIds.Contains(receiptId)) + { + result = ExtractionEquipmentTransactionResult.AlreadyApplied; + return true; + } + if (!profile.Items.TryGet(itemInstanceId, out var item) + || !itemCatalog.TryGetItemDefinition(item.DefinitionId, out var definition)) + { + result = ExtractionEquipmentTransactionResult.ItemNotFound; + return false; + } + + var policy = ExtractionItemActionPolicyService.GetPolicy(definition); + if (policy == null + || !policy.CanEquip + || string.IsNullOrEmpty(policy.EffectAdapterId)) + { + result = ExtractionEquipmentTransactionResult.PolicyDenied; + return false; + } + if (policy.EquipmentSlotType != slotType + || !SlotIdMatchesType(slotId, slotType)) + { + result = ExtractionEquipmentTransactionResult.WrongSlot; + return false; + } + if (!sourceGrid.TryGetPlacement(itemInstanceId, out var sourcePlacement) + || !TryFindOwnership(profile, itemInstanceId, out var sourceEntry) + || sourceEntry.Container != sourceContainer) + { + result = ExtractionEquipmentTransactionResult.LocationConflict; + return false; + } + if (equipment.TryGetSlotForItem(itemInstanceId, out _)) + { + result = ExtractionEquipmentTransactionResult.LocationConflict; + return false; + } + + equipment.TryGetItem(slotId, out displacedItemInstanceId); + ExtractionItemInstance displacedItem = null; + ExtractionItemDefinition displacedDefinition = null; + int displacedX = 0; + int displacedY = 0; + bool displacedRotated = false; + if (!string.IsNullOrEmpty(displacedItemInstanceId)) + { + if (!profile.Items.TryGet(displacedItemInstanceId, out displacedItem) + || !itemCatalog.TryGetItemDefinition(displacedItem.DefinitionId, out displacedDefinition) + || !TryFindOwnership(profile, displacedItemInstanceId, out var displacedEntry) + || displacedEntry.Container != ExtractionInventoryContainerType.EquipmentSlot + || displacedEntry.LocationSubtype != equipmentLocationSubtype + || displacedEntry.LocationId != slotId) + { + result = ExtractionEquipmentTransactionResult.LocationConflict; + return false; + } + + var probe = CloneGrid(sourceGrid); + if (!probe.TryRemove(itemInstanceId) + || !probe.TryFindFreeSlotWithRotation( + displacedDefinition.Width, + displacedDefinition.Height, + displacedDefinition.CanRotate, + out displacedX, + out displacedY, + out displacedRotated)) + { + result = ExtractionEquipmentTransactionResult.NoSpace; + return false; + } + } + + if (!sourceGrid.TryRemove(itemInstanceId)) + { + result = ExtractionEquipmentTransactionResult.LocationConflict; + return false; + } + if (displacedItem != null + && !sourceGrid.TryPlace( + displacedItem, + displacedDefinition, + displacedX, + displacedY, + displacedRotated)) + { + sourceGrid.TryPlace( + item, + definition, + sourcePlacement.X, + sourcePlacement.Y, + sourcePlacement.Rotated); + result = ExtractionEquipmentTransactionResult.NoSpace; + return false; + } + + if (!profile.Ownership.TryMove( + itemInstanceId, + sourceContainer, + ExtractionInventoryContainerType.EquipmentSlot, + equipmentLocationSubtype, + slotId)) + { + RollbackSourceGrid( + sourceGrid, + item, + definition, + sourcePlacement, + displacedItemInstanceId); + result = ExtractionEquipmentTransactionResult.LocationConflict; + return false; + } + if (displacedItem != null + && !profile.Ownership.TryMove( + displacedItemInstanceId, + ExtractionInventoryContainerType.EquipmentSlot, + sourceContainer)) + { + profile.Ownership.TryMove( + itemInstanceId, + ExtractionInventoryContainerType.EquipmentSlot, + sourceContainer); + RollbackSourceGrid( + sourceGrid, + item, + definition, + sourcePlacement, + displacedItemInstanceId); + result = ExtractionEquipmentTransactionResult.LocationConflict; + return false; + } + + if (!equipment.TrySet(slotId, itemInstanceId)) + { + result = ExtractionEquipmentTransactionResult.LocationConflict; + return false; + } + var slot = FindSlot(equipment, slotId); + if (slot != null) slot.EffectReceiptId = receiptId; + equipment.AppliedReceiptIds.Add(receiptId); + result = ExtractionEquipmentTransactionResult.Succeeded; + return true; + } + + public static bool TryUnequip( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState raidInventory, + IExtractionItemCatalog itemCatalog, + string equipmentLocationSubtype, + string slotId, + ExtractionInventoryContainerType targetContainer, + string receiptId, + out string itemInstanceId, + out ExtractionEquipmentTransactionResult result) + { + itemInstanceId = null; + result = ExtractionEquipmentTransactionResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled + || profile == null + || itemCatalog == null + || string.IsNullOrEmpty(slotId) + || string.IsNullOrEmpty(receiptId) + || !IsSourceAllowed(targetContainer, equipmentLocationSubtype)) + { + return false; + } + + profile.EnsureInitialized(); + raidInventory?.EnsureInitialized(); + if (!ExtractionItemLocationService.TryGetEquipment( + profile, + raidInventory, + equipmentLocationSubtype, + out var equipment) + || !ExtractionItemLocationService.TryGetGrid( + profile, + raidInventory, + targetContainer, + out var targetGrid)) + { + result = ExtractionEquipmentTransactionResult.LocationConflict; + return false; + } + + equipment.EnsureInitialized(); + if (equipment.AppliedReceiptIds.Contains(receiptId)) + { + result = ExtractionEquipmentTransactionResult.AlreadyApplied; + return true; + } + if (!equipment.TryGetItem(slotId, out itemInstanceId) + || !profile.Items.TryGet(itemInstanceId, out var item) + || !itemCatalog.TryGetItemDefinition(item.DefinitionId, out var definition) + || !TryFindOwnership(profile, itemInstanceId, out var entry) + || entry.Container != ExtractionInventoryContainerType.EquipmentSlot + || entry.LocationSubtype != equipmentLocationSubtype + || entry.LocationId != slotId) + { + result = ExtractionEquipmentTransactionResult.LocationConflict; + return false; + } + + if (!targetGrid.TryFindFreeSlotWithRotation( + definition.Width, + definition.Height, + definition.CanRotate, + out int x, + out int y, + out bool rotated) + || !targetGrid.TryPlace(item, definition, x, y, rotated)) + { + result = ExtractionEquipmentTransactionResult.NoSpace; + return false; + } + if (!profile.Ownership.TryMove( + itemInstanceId, + ExtractionInventoryContainerType.EquipmentSlot, + targetContainer) + || !equipment.TryClear(slotId, itemInstanceId)) + { + targetGrid.TryRemove(itemInstanceId); + profile.Ownership.TryMove( + itemInstanceId, + targetContainer, + ExtractionInventoryContainerType.EquipmentSlot, + equipmentLocationSubtype, + slotId); + result = ExtractionEquipmentTransactionResult.LocationConflict; + return false; + } + + equipment.AppliedReceiptIds.Add(receiptId); + result = ExtractionEquipmentTransactionResult.Succeeded; + return true; + } + + private static bool IsSourceAllowed( + ExtractionInventoryContainerType container, + string equipmentLocationSubtype) + { + if (equipmentLocationSubtype == ExtractionItemLocationService.BaseEquipmentLocationSubtype) + { + return container == ExtractionInventoryContainerType.Stash + || container == ExtractionInventoryContainerType.Loadout; + } + + return equipmentLocationSubtype == ExtractionItemLocationService.RaidEquipmentLocationSubtype + && container == ExtractionInventoryContainerType.RaidBackpack; + } + + private static bool SlotIdMatchesType( + string slotId, + ExtractionEquipmentSlotType slotType) + { + string prefix = slotType switch + { + ExtractionEquipmentSlotType.Weapon => "weapon", + ExtractionEquipmentSlotType.Relic => "relic", + ExtractionEquipmentSlotType.Card => "card", + _ => null + }; + return prefix != null && slotId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryFindOwnership( + ExtractionProfileSaveData profile, + string itemInstanceId, + out ExtractionOwnershipEntry entry) + { + entry = null; + foreach (var candidate in profile.Ownership.Entries) + { + if (candidate?.ItemInstanceId != itemInstanceId) continue; + if (entry != null) return false; + entry = candidate; + } + return entry != null; + } + + private static ExtractionEquipmentSlotState FindSlot( + ExtractionEquipmentState equipment, + string slotId) + { + foreach (var slot in equipment.Slots) + { + if (slot?.SlotId == slotId) return slot; + } + return null; + } + + private static ExtractionItemGrid CloneGrid(ExtractionItemGrid source) + { + var clone = new ExtractionItemGrid(source.Width, source.Height); + foreach (var placement in source.Placements) + { + if (placement == null) continue; + clone.Placements.Add(new ExtractionItemPlacement( + placement.ItemInstanceId, + placement.X, + placement.Y, + placement.Width, + placement.Height, + placement.Rotated)); + } + return clone; + } + + private static void RollbackSourceGrid( + ExtractionItemGrid sourceGrid, + ExtractionItemInstance sourceItem, + ExtractionItemDefinition sourceDefinition, + ExtractionItemPlacement sourcePlacement, + string displacedItemInstanceId) + { + if (!string.IsNullOrEmpty(displacedItemInstanceId)) + sourceGrid.TryRemove(displacedItemInstanceId); + sourceGrid.TryPlace( + sourceItem, + sourceDefinition, + sourcePlacement.X, + sourcePlacement.Y, + sourcePlacement.Rotated); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEquipmentTransactionService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEquipmentTransactionService.cs.meta new file mode 100644 index 0000000..84e8044 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionEquipmentTransactionService.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c1e755855afc40df9a4a74c00139a215 +timeCreated: 1784386600 diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityDefinition.cs new file mode 100644 index 0000000..dbb089d --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityDefinition.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionFacilityEffect + { + public string EffectId; + public int IntValue; + + public bool IsValid => !string.IsNullOrEmpty(EffectId); + + public ExtractionFacilityEffect(string effectId, int intValue) + { + EffectId = effectId; + IntValue = intValue; + } + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionFacilityUpgradeCost + { + public string ItemDefinitionId; + public int Quantity; + + public bool IsValid => !string.IsNullOrEmpty(ItemDefinitionId) && Quantity > 0; + + public ExtractionFacilityUpgradeCost(string itemDefinitionId, int quantity) + { + ItemDefinitionId = itemDefinitionId; + Quantity = quantity; + } + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionFacilityDefinition + { + public ExtractionFacilityType FacilityType; + public int Level; + public List Effects = new(); + public List UpgradeCosts = new(); + public string RequiredSharedUnlockId; + public int RequiredSharedUnlockLevel; + public string RequiredSharedTalentId; + + public bool IsValid + { + get + { + if (Level <= 0 || Effects == null || Effects.Count == 0) return false; + foreach (var effect in Effects) + { + if (effect == null || !effect.IsValid) return false; + } + + return true; + } + } + + public ExtractionFacilityDefinition(ExtractionFacilityType facilityType, int level) + { + FacilityType = facilityType; + Level = level; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityDefinition.cs.meta new file mode 100644 index 0000000..3108ecb --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5e472cd12eacf4c4f9f03cae011b4df1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityEffectService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityEffectService.cs new file mode 100644 index 0000000..7580ea8 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityEffectService.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionFacilityEffectService + { + public static int GetIntEffect( + ExtractionProfileSaveData profile, + List definitions, + string effectId) + { + if (profile == null || definitions == null || string.IsNullOrEmpty(effectId)) return 0; + profile.EnsureInitialized(); + + int total = 0; + foreach (var definition in definitions) + { + if (definition == null || !definition.IsValid) continue; + if (profile.Facilities.GetLevel(definition.FacilityType) < definition.Level) continue; + + foreach (var effect in definition.Effects) + { + if (effect == null || effect.EffectId != effectId) continue; + total += effect.IntValue; + } + } + + return total; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityEffectService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityEffectService.cs.meta new file mode 100644 index 0000000..cd362f7 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityEffectService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 78c5c17da211fd04c8930b205300923b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityProfile.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityProfile.cs new file mode 100644 index 0000000..b49fa73 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityProfile.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + public enum ExtractionFacilityType + { + Stash, + SecureContainer, + Recovery, + MapTable, + Merchant, + Crafting, + Medical, + Intel, + Training + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionFacilityProfile + { + public List Entries = new(); + + public int GetLevel(ExtractionFacilityType facilityType) + { + var state = FindState(facilityType); + return state?.Level ?? 0; + } + + public bool TrySetLevel(ExtractionFacilityType facilityType, int level) + { + if (level <= 0) return false; + + var state = FindState(facilityType); + if (state == null) + { + Entries.Add(new ExtractionFacilityState(facilityType, level)); + return true; + } + + if (level > state.Level) + state.Level = level; + return true; + } + + private ExtractionFacilityState FindState(ExtractionFacilityType facilityType) + { + foreach (var state in Entries) + { + if (state.FacilityType == facilityType) return state; + } + + return null; + } + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionFacilityState + { + public ExtractionFacilityType FacilityType; + public int Level; + + public ExtractionFacilityState(ExtractionFacilityType facilityType, int level) + { + FacilityType = facilityType; + Level = level; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityProfile.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityProfile.cs.meta new file mode 100644 index 0000000..cfe8f05 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityProfile.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 905fc59f4eb6465392cf7b8ee57e8506 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradeApplyService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradeApplyService.cs new file mode 100644 index 0000000..40c7752 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradeApplyService.cs @@ -0,0 +1,20 @@ +namespace POB.Extraction +{ + public static class ExtractionFacilityUpgradeApplyService + { + public static bool TryApplyUpgrade( + ExtractionProfileSaveData profile, + ExtractionFacilityUpgradePlan plan) + { + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || plan == null) return false; + if (plan.TargetLevel != plan.CurrentLevel + 1) return false; + + profile.EnsureInitialized(); + int currentLevel = profile.Facilities.GetLevel(plan.FacilityType); + if (currentLevel != plan.CurrentLevel) return false; + + return profile.Facilities.TrySetLevel(plan.FacilityType, plan.TargetLevel); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradeApplyService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradeApplyService.cs.meta new file mode 100644 index 0000000..7d49378 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradeApplyService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8ceb7244a25b8e74287bab121b8ee826 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradePlanService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradePlanService.cs new file mode 100644 index 0000000..e88c63e --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradePlanService.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public class ExtractionFacilityUpgradePlan + { + public ExtractionFacilityType FacilityType; + public int CurrentLevel; + public int TargetLevel; + public List Costs = new(); + + public ExtractionFacilityUpgradePlan( + ExtractionFacilityType facilityType, + int currentLevel, + int targetLevel, + List costs) + { + FacilityType = facilityType; + CurrentLevel = currentLevel; + TargetLevel = targetLevel; + Costs = costs ?? new List(); + } + } + + public static class ExtractionFacilityUpgradePlanService + { + public static bool TryGetNextUpgradePlan( + ExtractionProfileSaveData profile, + List definitions, + ExtractionFacilityType facilityType, + IExtractionMetaProgressProvider metaProgress, + out ExtractionFacilityUpgradePlan plan) + { + plan = null; + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || definitions == null) return false; + profile.EnsureInitialized(); + + int currentLevel = profile.Facilities.GetLevel(facilityType); + int targetLevel = currentLevel + 1; + if (!TryGetTargetDefinition(definitions, facilityType, targetLevel, out var definition)) return false; + if (!MeetsMetaRequirements(definition, metaProgress)) return false; + if (!TryCopyCosts(definition.UpgradeCosts, out var costs)) return false; + + plan = new ExtractionFacilityUpgradePlan( + facilityType, + currentLevel, + targetLevel, + costs); + return true; + } + + private static bool TryGetTargetDefinition( + List definitions, + ExtractionFacilityType facilityType, + int targetLevel, + out ExtractionFacilityDefinition definition) + { + definition = null; + + foreach (var candidate in definitions) + { + if (candidate == null || !candidate.IsValid) continue; + if (candidate.FacilityType != facilityType) continue; + if (candidate.Level != targetLevel) continue; + + definition = candidate; + return true; + } + + return false; + } + + private static bool MeetsMetaRequirements( + ExtractionFacilityDefinition definition, + IExtractionMetaProgressProvider metaProgress) + { + metaProgress ??= new ExtractionNoOpMetaProgressProvider(); + + if (!string.IsNullOrEmpty(definition.RequiredSharedUnlockId)) + { + if (definition.RequiredSharedUnlockLevel <= 0) return false; + if (metaProgress.GetSharedUnlockLevel(definition.RequiredSharedUnlockId) < definition.RequiredSharedUnlockLevel) + return false; + } + + if (!string.IsNullOrEmpty(definition.RequiredSharedTalentId)) + { + if (!metaProgress.HasSharedTalent(definition.RequiredSharedTalentId)) return false; + } + + return true; + } + + private static bool TryCopyCosts( + List source, + out List costs) + { + costs = new List(); + if (source == null || source.Count == 0) return true; + + foreach (var cost in source) + { + if (cost == null || !cost.IsValid) return false; + costs.Add(new ExtractionFacilityUpgradeCost(cost.ItemDefinitionId, cost.Quantity)); + } + + return true; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradePlanService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradePlanService.cs.meta new file mode 100644 index 0000000..e5434e3 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradePlanService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 02e21b0f71d04294a881aa34ea5a0178 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradeTransactionService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradeTransactionService.cs new file mode 100644 index 0000000..61827b7 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradeTransactionService.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionFacilityUpgradeTransactionService + { + public static bool TryCompleteUpgrade( + ExtractionProfileSaveData profile, + ExtractionFacilityUpgradePlan plan, + List sourceContainers, + IExtractionItemCatalog itemCatalog = null) + { + if (!CanApplyPlan(profile, plan)) return false; + if (!TryConvertCosts(plan.Costs, out var costs)) return false; + + ExtractionCostConsumptionReceipt costReceipt = null; + if (costs.Count > 0) + { + if (!ExtractionItemCostService.HasEnoughItems(profile, costs, sourceContainers)) return false; + if (!ExtractionItemCostService.TryConsumeCosts(profile, costs, sourceContainers, out costReceipt)) return false; + } + + if (ExtractionFacilityUpgradeApplyService.TryApplyUpgrade(profile, plan)) return true; + + if (costReceipt != null) + ExtractionItemCostService.RestoreCosts(profile, costReceipt, itemCatalog); + return false; + } + + private static bool CanApplyPlan( + ExtractionProfileSaveData profile, + ExtractionFacilityUpgradePlan plan) + { + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || plan == null) return false; + if (plan.TargetLevel != plan.CurrentLevel + 1) return false; + + profile.EnsureInitialized(); + return profile.Facilities.GetLevel(plan.FacilityType) == plan.CurrentLevel; + } + + private static bool TryConvertCosts( + List source, + out List costs) + { + costs = new List(); + if (source == null || source.Count == 0) return true; + + foreach (var cost in source) + { + if (cost == null || !cost.IsValid) return false; + costs.Add(new ExtractionItemCost(cost.ItemDefinitionId, cost.Quantity)); + } + + return true; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradeTransactionService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradeTransactionService.cs.meta new file mode 100644 index 0000000..3fdc7a7 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFacilityUpgradeTransactionService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8e9920a5346d5034cb6d8d0c0bd95119 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFailureContext.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFailureContext.cs new file mode 100644 index 0000000..7c957a9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFailureContext.cs @@ -0,0 +1,30 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionFailureContext + { + public long DeathUnixSeconds; + public string DeathReason; + public string DeathRegion; + public int DangerLevel; + public int CorpseSeed; + + public ExtractionFailureContext( + long deathUnixSeconds, + string deathReason, + string deathRegion, + int dangerLevel, + int corpseSeed) + { + DeathUnixSeconds = deathUnixSeconds; + DeathReason = deathReason; + DeathRegion = deathRegion; + DangerLevel = dangerLevel; + CorpseSeed = corpseSeed; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFailureContext.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFailureContext.cs.meta new file mode 100644 index 0000000..9e3a1aa --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFailureContext.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f9b3f94c6ff77e941a04114a9f7edfc1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFeatureSwitch.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFeatureSwitch.cs new file mode 100644 index 0000000..276a844 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFeatureSwitch.cs @@ -0,0 +1,27 @@ +using System; + +namespace POB.Extraction +{ + public static class ExtractionFeatureSwitch + { + private static bool enabled; + + public static bool Enabled => enabled; + + // 供需要"关闭时不监听任何事件"的模块(如死亡/房间钩子)做响应式订阅/退订, + // 而不是在启动时无条件订阅。仅在值真正变化时触发。 + public static event Action EnabledChanged; + + public static void SetEnabled(bool value) + { + if (enabled == value) return; + enabled = value; + EnabledChanged?.Invoke(value); + } + + public static void SetEnabledForTests(bool value) + { + SetEnabled(value); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFeatureSwitch.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFeatureSwitch.cs.meta new file mode 100644 index 0000000..334bfbc --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionFeatureSwitch.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f0557184b4e0a2e448d5b756c594872f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerDefinition.cs new file mode 100644 index 0000000..fbc13cf --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerDefinition.cs @@ -0,0 +1,41 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionHostileExplorerDefinition + { + public string EncounterId; + public string MapId; + public string ActorKey; + public string LootTableId; + public int MinThreatLevel; + public int Weight; + + public bool IsValid => + !string.IsNullOrEmpty(EncounterId) + && !string.IsNullOrEmpty(MapId) + && !string.IsNullOrEmpty(ActorKey) + && !string.IsNullOrEmpty(LootTableId) + && MinThreatLevel >= 0 + && Weight > 0; + + public ExtractionHostileExplorerDefinition( + string encounterId, + string mapId, + string actorKey, + string lootTableId, + int minThreatLevel, + int weight) + { + EncounterId = encounterId; + MapId = mapId; + ActorKey = actorKey; + LootTableId = lootTableId; + MinThreatLevel = minThreatLevel; + Weight = weight; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerDefinition.cs.meta new file mode 100644 index 0000000..a10ee0e --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b3ab85b2d3370ed49ae136aeb3992948 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerLootService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerLootService.cs new file mode 100644 index 0000000..7098fba --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerLootService.cs @@ -0,0 +1,43 @@ +namespace POB.Extraction +{ + public static class ExtractionHostileExplorerLootService + { + public static bool TryRollExplorerLoot( + ExtractionHostileExplorerDefinition encounter, + IExtractionLootTableCatalog lootTables, + IExtractionItemCatalog itemCatalog, + string instanceId, + int rollValue, + out ExtractionLootPickup pickup) + { + pickup = null; + if (encounter == null || !encounter.IsValid) return false; + if (lootTables == null || itemCatalog == null) return false; + if (!lootTables.TryGetLootTable(encounter.LootTableId, out var table)) return false; + if (!HasKnownDefinitions(table, itemCatalog)) return false; + + return ExtractionLootRollService.TryRollPickup( + table, + itemCatalog, + instanceId, + rollValue, + out pickup); + } + + private static bool HasKnownDefinitions( + ExtractionLootRollTable table, + IExtractionItemCatalog itemCatalog) + { + if (table == null || table.Entries == null) return false; + + foreach (var entry in table.Entries) + { + if (entry == null || !entry.IsValid) continue; + if (!itemCatalog.TryGetItemDefinition(entry.DefinitionId, out var definition)) return false; + if (definition == null) return false; + } + + return true; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerLootService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerLootService.cs.meta new file mode 100644 index 0000000..135a51a --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerLootService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 90ab65f6aca78ec4a9677af14a1993dc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerSelectionService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerSelectionService.cs new file mode 100644 index 0000000..ffeae55 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerSelectionService.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionHostileExplorerSelectionService + { + public static bool TryGetCandidates( + List encounters, + string mapId, + int threatLevel, + List results) + { + if (results == null) return false; + results.Clear(); + if (encounters == null || string.IsNullOrEmpty(mapId)) return false; + + foreach (var candidate in encounters) + { + if (candidate == null || !candidate.IsValid) continue; + if (candidate.MapId != mapId) continue; + if (threatLevel < candidate.MinThreatLevel) continue; + results.Add(candidate); + } + + return results.Count > 0; + } + + public static bool TrySelectCandidate( + List candidates, + int rollValue, + out ExtractionHostileExplorerDefinition encounter) + { + encounter = null; + int totalWeight = GetTotalValidWeight(candidates); + if (totalWeight <= 0) return false; + + int target = NormalizeRoll(rollValue, totalWeight); + int cumulative = 0; + foreach (var candidate in candidates) + { + if (candidate == null || !candidate.IsValid) continue; + cumulative += candidate.Weight; + if (target >= cumulative) continue; + + encounter = candidate; + return true; + } + + return false; + } + + public static bool TrySelectCandidateBySeed( + List candidates, + int seed, + out ExtractionHostileExplorerDefinition encounter) + { + encounter = null; + int totalWeight = GetTotalValidWeight(candidates); + if (totalWeight <= 0) return false; + + var random = new Random(seed); + return TrySelectCandidate(candidates, random.Next(totalWeight), out encounter); + } + + private static int GetTotalValidWeight(List candidates) + { + if (candidates == null) return 0; + + int totalWeight = 0; + foreach (var candidate in candidates) + { + if (candidate == null || !candidate.IsValid) continue; + totalWeight += candidate.Weight; + } + + return totalWeight; + } + + private static int NormalizeRoll(int rollValue, int totalWeight) + { + int result = rollValue % totalWeight; + return result < 0 ? result + totalWeight : result; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerSelectionService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerSelectionService.cs.meta new file mode 100644 index 0000000..1e3a990 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionHostileExplorerSelectionService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 15375e6812c984b41b675036041efbc5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionInMemoryProfileStore.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionInMemoryProfileStore.cs new file mode 100644 index 0000000..5f31d10 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionInMemoryProfileStore.cs @@ -0,0 +1,131 @@ +namespace POB.Extraction +{ + public class ExtractionInMemoryProfileStore : IExtractionProfileStore + { + private ExtractionProfileSaveData authoritativeProfile; + private long revision; + private ExtractionProfileLoadResult lastLegacyLoad; + + public ExtractionInMemoryProfileStore() + : this(ExtractionProfileSaveData.CreateEmpty()) + { + } + + public ExtractionInMemoryProfileStore(ExtractionProfileSaveData initialProfile) + { + authoritativeProfile = ExtractionProfileCloneUtility.Clone(initialProfile); + } + + /// + /// Legacy mutable view retained for in-memory fixture seeding and inspection. + /// Production mutations must use Load/CreateDraft/Commit so failures remain observable. + /// + public ExtractionProfileSaveData Profile => authoritativeProfile; + public ExtractionProfileInMemoryCommitFault NextCommitFault { get; set; } + + public ExtractionProfileLoadResult Load() + { + if (authoritativeProfile.SchemaVersion > ExtractionProfileSaveData.CurrentSchemaVersion) + { + return ExtractionProfileLoadResult.Failed( + authoritativeProfile, + CurrentRevision, + ExtractionProfileLoadFailure.UnsupportedSchema, + "A newer profile schema is read-only in this client."); + } + + return ExtractionProfileLoadResult.Loaded(authoritativeProfile, CurrentRevision); + } + + public ExtractionProfileSaveData LoadProfile() + { + lastLegacyLoad = Load(); + return lastLegacyLoad.Profile; + } + + public ExtractionProfileCommitResult SaveProfile(ExtractionProfileSaveData profile) + { + lastLegacyLoad ??= Load(); + if (!lastLegacyLoad.TryCreateDraft(profile, out var draft)) + { + return ExtractionProfileCommitResult.Failed( + ExtractionProfileCommitFailure.ReadOnly, + "The loaded profile is read-only.", + Load()); + } + + var result = Commit(draft); + if (result.Success) + lastLegacyLoad = result.Snapshot; + return result; + } + + public ExtractionProfileCommitResult Commit(ExtractionProfileDraft draft) + { + if (draft == null) + { + return ExtractionProfileCommitResult.Failed( + ExtractionProfileCommitFailure.InvalidDraft, + "Profile draft is required.", + Load()); + } + + if (!string.Equals(draft.BaseRevision, CurrentRevision, System.StringComparison.Ordinal)) + { + return ExtractionProfileCommitResult.Failed( + ExtractionProfileCommitFailure.RevisionConflict, + "The profile changed after the draft was loaded.", + Load()); + } + + if (draft.Profile.SchemaVersion > ExtractionProfileSaveData.CurrentSchemaVersion) + { + return ExtractionProfileCommitResult.Failed( + ExtractionProfileCommitFailure.ReadOnly, + "A newer profile schema cannot be committed by this client.", + Load()); + } + + if (ConsumeFault(ExtractionProfileInMemoryCommitFault.Prepare)) + { + return ExtractionProfileCommitResult.Failed( + ExtractionProfileCommitFailure.PrepareFailed, + "Injected prepare failure.", + Load()); + } + + ExtractionProfileSaveData prepared = ExtractionProfileCloneUtility.Clone(draft.Profile); + if (ConsumeFault(ExtractionProfileInMemoryCommitFault.Commit)) + { + return ExtractionProfileCommitResult.Failed( + ExtractionProfileCommitFailure.WriteFailed, + "Injected commit failure.", + Load()); + } + + ExtractionProfileSaveData readback = ExtractionProfileCloneUtility.Clone(prepared); + if (ConsumeFault(ExtractionProfileInMemoryCommitFault.Readback)) + { + return ExtractionProfileCommitResult.Failed( + ExtractionProfileCommitFailure.ReadbackFailed, + "Injected readback failure.", + Load()); + } + + authoritativeProfile = readback; + revision++; + return ExtractionProfileCommitResult.Succeeded(Load()); + } + + private string CurrentRevision => $"memory:{revision}"; + + private bool ConsumeFault(ExtractionProfileInMemoryCommitFault fault) + { + if (NextCommitFault != fault) + return false; + + NextCommitFault = ExtractionProfileInMemoryCommitFault.None; + return true; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionInMemoryProfileStore.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionInMemoryProfileStore.cs.meta new file mode 100644 index 0000000..3821f20 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionInMemoryProfileStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f88fae8e44574f60ad72ff9da55bbdfb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionIntelHintDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionIntelHintDefinition.cs new file mode 100644 index 0000000..591c338 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionIntelHintDefinition.cs @@ -0,0 +1,32 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionIntelHintDefinition + { + public string HintId; + public string MapId; + public int RequiredIntelLevel; + public string RequiredSharedUnlockId; + public int RequiredSharedUnlockLevel; + public string RequiredSharedTalentId; + + public bool IsValid => + !string.IsNullOrEmpty(HintId) + && !string.IsNullOrEmpty(MapId) + && RequiredIntelLevel > 0; + + public ExtractionIntelHintDefinition( + string hintId, + string mapId, + int requiredIntelLevel) + { + HintId = hintId; + MapId = mapId; + RequiredIntelLevel = requiredIntelLevel; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionIntelHintDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionIntelHintDefinition.cs.meta new file mode 100644 index 0000000..651197d --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionIntelHintDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 578060f7224976d499d8fc7e160b6173 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionIntelHintService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionIntelHintService.cs new file mode 100644 index 0000000..b589a72 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionIntelHintService.cs @@ -0,0 +1,55 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionIntelHintService + { + public static bool TryGetAvailableHints( + ExtractionProfileSaveData profile, + List definitions, + string mapId, + IExtractionMetaProgressProvider metaProgress, + List results) + { + if (results == null) return false; + results.Clear(); + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || definitions == null || string.IsNullOrEmpty(mapId)) return false; + profile.EnsureInitialized(); + + int intelLevel = profile.Facilities.GetLevel(ExtractionFacilityType.Intel); + foreach (var definition in definitions) + { + if (definition == null || !definition.IsValid) continue; + if (definition.MapId != mapId) continue; + if (intelLevel < definition.RequiredIntelLevel) continue; + if (!MeetsMetaRequirements(definition, metaProgress)) continue; + + results.Add(definition); + } + + return results.Count > 0; + } + + private static bool MeetsMetaRequirements( + ExtractionIntelHintDefinition definition, + IExtractionMetaProgressProvider metaProgress) + { + metaProgress ??= new ExtractionNoOpMetaProgressProvider(); + + if (!string.IsNullOrEmpty(definition.RequiredSharedUnlockId)) + { + if (definition.RequiredSharedUnlockLevel <= 0) return false; + if (metaProgress.GetSharedUnlockLevel(definition.RequiredSharedUnlockId) < definition.RequiredSharedUnlockLevel) + return false; + } + + if (!string.IsNullOrEmpty(definition.RequiredSharedTalentId)) + { + if (!metaProgress.HasSharedTalent(definition.RequiredSharedTalentId)) return false; + } + + return true; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionIntelHintService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionIntelHintService.cs.meta new file mode 100644 index 0000000..6d90910 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionIntelHintService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8c0e268a2d2e64a48bf43c769ad4091d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionInventoryContainerType.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionInventoryContainerType.cs new file mode 100644 index 0000000..ce41750 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionInventoryContainerType.cs @@ -0,0 +1,22 @@ +namespace POB.Extraction +{ + public enum ExtractionInventoryContainerType + { + Stash = 0, + Loadout = 1, + InRaid = 2, + RaidBackpack = 3, + SecureContainer = 4, + InSecureContainer = 5, + Holding = 6, + Lost = 7, + Corpse = 8, + Buyback = 9, + EquipmentSlot = 10, + WorldPickup = 11, + Destroyed = 12, + Consumed = 13, + Sold = 14, + DestroyedByUse = 15 + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionInventoryContainerType.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionInventoryContainerType.cs.meta new file mode 100644 index 0000000..ff833d4 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionInventoryContainerType.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e243ca40bd0170846b965d80f8335e5e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemActionPolicy.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemActionPolicy.cs new file mode 100644 index 0000000..8572b05 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemActionPolicy.cs @@ -0,0 +1,235 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + public enum ExtractionEquipmentSlotType + { + None = 0, + Weapon = 1, + Relic = 2, + Card = 3 + } + + public enum ExtractionItemConsumptionType + { + None = 0, + Quantity = 1, + Durability = 2, + DestroyInstance = 3 + } + + public enum ExtractionItemActionPreset + { + NormalLoot = 0, + Equippable = 1, + Consumable = 2, + QuestKeepOnDeath = 3, + QuestDropOnDeath = 4, + DurableTool = 5 + } + + [Serializable] + public class ExtractionItemActionPolicy + { + public bool PolicyInitialized; + public bool CanEquip; + public ExtractionEquipmentSlotType EquipmentSlotType; + public string EffectAdapterId; + public bool CanUse; + public string UseActionId; + public ExtractionItemConsumptionType ConsumptionType; + public bool CanDrop = true; + public bool CanSell = true; + public bool DropOnDeath = true; + public bool CanPlaceInSecure = true; + public bool RaidBound; + public bool DestroyOnSettlement; + + public static ExtractionItemActionPolicy CreateDefaultLoot() + { + return new ExtractionItemActionPolicy + { + PolicyInitialized = true, + CanDrop = true, + CanSell = true, + DropOnDeath = true, + CanPlaceInSecure = true + }; + } + + internal void EnsureInitialized() + { + if (PolicyInitialized) return; + CanDrop = true; + CanSell = true; + DropOnDeath = true; + CanPlaceInSecure = true; + PolicyInitialized = true; + } + } + + public static class ExtractionItemActionPresetService + { + private static readonly Dictionary ChineseLabels = new() + { + [ExtractionItemActionPreset.NormalLoot] = "普通物品", + [ExtractionItemActionPreset.Equippable] = "可装备物品", + [ExtractionItemActionPreset.Consumable] = "可使用消耗品", + [ExtractionItemActionPreset.QuestKeepOnDeath] = "任务物品(死亡保留)", + [ExtractionItemActionPreset.QuestDropOnDeath] = "任务物品(死亡掉落)", + [ExtractionItemActionPreset.DurableTool] = "耐久工具 / 钥匙" + }; + + private static readonly Dictionary ChineseTooltips = new() + { + [ExtractionItemActionPreset.NormalLoot] = "默认可丢弃、可出售、死亡掉落并可放入保险箱。", + [ExtractionItemActionPreset.Equippable] = "在普通物品默认值上开启装备;仍需选择装备位类型和效果适配器。", + [ExtractionItemActionPreset.Consumable] = "在普通物品默认值上开启使用并按数量消耗;仍需填写使用行为。", + [ExtractionItemActionPreset.QuestKeepOnDeath] = "不能主动丢弃或出售,死亡时保留;任务系统仍可提交。", + [ExtractionItemActionPreset.QuestDropOnDeath] = "不能主动丢弃或出售,但死亡时进入掉落 / 尸体流程;任务系统仍可提交。", + [ExtractionItemActionPreset.DurableTool] = "按实例耐久使用;仍需填写使用行为、最大耐久和兼容目标。" + }; + + public static bool TryApply( + ExtractionItemDefinition definition, + ExtractionItemActionPreset preset) + { + if (definition == null || !Enum.IsDefined(typeof(ExtractionItemActionPreset), preset)) + return false; + + var policy = ExtractionItemActionPolicy.CreateDefaultLoot(); + switch (preset) + { + case ExtractionItemActionPreset.NormalLoot: + break; + case ExtractionItemActionPreset.Equippable: + policy.CanEquip = true; + break; + case ExtractionItemActionPreset.Consumable: + policy.CanUse = true; + policy.ConsumptionType = ExtractionItemConsumptionType.Quantity; + break; + case ExtractionItemActionPreset.QuestKeepOnDeath: + policy.CanDrop = false; + policy.CanSell = false; + policy.DropOnDeath = false; + break; + case ExtractionItemActionPreset.QuestDropOnDeath: + policy.CanDrop = false; + policy.CanSell = false; + policy.DropOnDeath = true; + break; + case ExtractionItemActionPreset.DurableTool: + policy.CanUse = true; + policy.ConsumptionType = ExtractionItemConsumptionType.Durability; + break; + default: + return false; + } + + definition.ActionPolicy = policy; + return true; + } + + public static string GetChineseLabel(ExtractionItemActionPreset preset) + { + return ChineseLabels.TryGetValue(preset, out string label) ? label : preset.ToString(); + } + + public static string GetChineseTooltip(ExtractionItemActionPreset preset) + { + return ChineseTooltips.TryGetValue(preset, out string tooltip) ? tooltip : string.Empty; + } + } + + public static class ExtractionItemActionPolicyService + { + public static ExtractionItemActionPolicy GetPolicy(ExtractionItemDefinition definition) + { + if (definition == null) return null; + definition.ActionPolicy ??= ExtractionItemActionPolicy.CreateDefaultLoot(); + definition.ActionPolicy.EnsureInitialized(); + return definition.ActionPolicy; + } + + public static bool CanSubmitToTask(ExtractionItemDefinition definition) + { + return definition != null; + } + + public static bool CanEquip(ExtractionItemDefinition definition) + { + var policy = GetPolicy(definition); + return policy != null + && policy.CanEquip + && policy.EquipmentSlotType != ExtractionEquipmentSlotType.None + && !string.IsNullOrWhiteSpace(policy.EffectAdapterId); + } + + public static bool CanUse(ExtractionItemDefinition definition) + { + var policy = GetPolicy(definition); + return policy != null + && policy.CanUse + && !string.IsNullOrWhiteSpace(policy.UseActionId) + && policy.ConsumptionType != ExtractionItemConsumptionType.None; + } + + public static bool CanDrop(ExtractionItemDefinition definition, ExtractionItemInstance item = null) + { + var policy = GetPolicy(definition); + return policy != null + && policy.CanDrop + && (item == null || item.HasFlag(ExtractionItemInstanceFlags.CanDrop)); + } + + public static bool CanSell(ExtractionItemDefinition definition, ExtractionItemInstance item = null) + { + var policy = GetPolicy(definition); + return policy != null + && policy.CanSell + && (item == null || item.HasFlag(ExtractionItemInstanceFlags.CanSell)); + } + + public static bool DropsOnDeath(ExtractionItemDefinition definition, ExtractionItemInstance item = null) + { + var policy = GetPolicy(definition); + return policy != null + && policy.DropOnDeath + && (item == null || item.HasFlag(ExtractionItemInstanceFlags.DropOnDeath)); + } + + public static bool CanPlaceInSecure(ExtractionItemDefinition definition) + { + return GetPolicy(definition)?.CanPlaceInSecure == true; + } + + public static void ApplyDefinitionPolicyToInstance( + ExtractionItemDefinition definition, + ExtractionItemInstance item) + { + if (definition == null || item == null) return; + var policy = GetPolicy(definition); + if (policy == null) return; + + item.Flags = ExtractionItemInstanceFlags.PolicyInitialized; + SetFlag(item, ExtractionItemInstanceFlags.CanDrop, policy.CanDrop); + SetFlag(item, ExtractionItemInstanceFlags.CanSell, policy.CanSell); + SetFlag(item, ExtractionItemInstanceFlags.DropOnDeath, policy.DropOnDeath); + SetFlag(item, ExtractionItemInstanceFlags.RaidBound, policy.RaidBound); + SetFlag(item, ExtractionItemInstanceFlags.DestroyOnSettlement, policy.DestroyOnSettlement); + if (definition.MaxDurability > 0 && item.CurrentDurability <= 0) + item.CurrentDurability = definition.MaxDurability; + } + + private static void SetFlag( + ExtractionItemInstance item, + ExtractionItemInstanceFlags flag, + bool enabled) + { + if (enabled) item.Flags |= flag; + else item.Flags &= ~flag; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemActionPolicy.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemActionPolicy.cs.meta new file mode 100644 index 0000000..930b82a --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemActionPolicy.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 17229cf6962a4a22a745a5d7628baa5b +timeCreated: 1784385600 diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemCostService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemCostService.cs new file mode 100644 index 0000000..8e05e6b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemCostService.cs @@ -0,0 +1,278 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionItemCost + { + public string ItemDefinitionId; + public int Quantity; + + public bool IsValid => !string.IsNullOrEmpty(ItemDefinitionId) && Quantity > 0; + + public ExtractionItemCost(string itemDefinitionId, int quantity) + { + ItemDefinitionId = itemDefinitionId; + Quantity = quantity; + } + } + + [Serializable] + public class ExtractionConsumedCostEntry + { + public string ItemInstanceId; + public string DefinitionId; + public ExtractionInventoryContainerType Container; + public int RemovedQuantity; + public bool WasFullyRemoved; + public string SourceKind; + public string SourceId; + public int X; + public int Y; + public bool Rotated; + } + + public class ExtractionCostConsumptionReceipt + { + public List Entries = new(); + } + + public static class ExtractionItemCostService + { + public static int GetAvailableQuantity( + ExtractionProfileSaveData profile, + string itemDefinitionId, + List containers) + { + if (!ExtractionFeatureSwitch.Enabled) return 0; + if (profile == null || string.IsNullOrEmpty(itemDefinitionId) || containers == null) return 0; + profile.EnsureInitialized(); + + long total = 0; + foreach (var container in containers) + { + if (!TryGetGrid(profile, container, out var grid)) continue; + + foreach (var placement in grid.Placements) + { + if (placement == null) continue; + if (!profile.Items.TryGet(placement.ItemInstanceId, out var item)) continue; + if (item.DefinitionId != itemDefinitionId || item.Quantity <= 0) continue; + if (!profile.Ownership.TryGetContainer(placement.ItemInstanceId, out var current)) continue; + if (current != container) continue; + + total += item.Quantity; + } + } + + return total > int.MaxValue ? int.MaxValue : (int)total; + } + + public static bool HasEnoughItems( + ExtractionProfileSaveData profile, + List costs, + List containers) + { + if (!TryBuildRequiredQuantities(costs, out var required)) return false; + + foreach (var entry in required) + { + if (GetAvailableQuantity(profile, entry.Key, containers) < entry.Value) + return false; + } + + return true; + } + + public static bool TryConsumeCosts( + ExtractionProfileSaveData profile, + List costs, + List containers) + { + return TryConsumeCosts(profile, costs, containers, out _); + } + + public static bool TryConsumeCosts( + ExtractionProfileSaveData profile, + List costs, + List containers, + out ExtractionCostConsumptionReceipt receipt) + { + receipt = new ExtractionCostConsumptionReceipt(); + if (profile == null || containers == null) return false; + if (!TryBuildRequiredQuantities(costs, out var required)) return false; + if (!HasEnoughItems(profile, costs, containers)) return false; + + profile.EnsureInitialized(); + foreach (var entry in required) + { + if (!TryConsumeQuantity(profile, entry.Key, entry.Value, containers, receipt)) + return false; + } + + return true; + } + + // 把回执逐项逆操作:存活项加回数量、整项消费的按原 placement 重建。 + public static bool RestoreCosts( + ExtractionProfileSaveData profile, + ExtractionCostConsumptionReceipt receipt, + IExtractionItemCatalog itemCatalog) + { + if (profile == null || receipt == null) return false; + profile.EnsureInitialized(); + + bool allRestored = true; + for (int i = receipt.Entries.Count - 1; i >= 0; i--) + { + var entry = receipt.Entries[i]; + if (entry == null) continue; + + if (!entry.WasFullyRemoved + && profile.Items.TryGet(entry.ItemInstanceId, out var survivor)) + { + survivor.Quantity += entry.RemovedQuantity; + continue; + } + + // 还原失败属不变量违反(格子刚腾出、容量必然够):记录并尽力还原其余项,不再吞物。 + if (!TryRecreateConsumedItem(profile, entry, itemCatalog)) + { + UnityEngine.Debug.LogError( + $"[Extraction] RestoreCosts 无法重建已消耗成本物 {entry.ItemInstanceId} ({entry.DefinitionId})。"); + allRestored = false; + } + } + + return allRestored; + } + + private static bool TryRecreateConsumedItem( + ExtractionProfileSaveData profile, + ExtractionConsumedCostEntry entry, + IExtractionItemCatalog itemCatalog) + { + if (itemCatalog == null) return false; + if (!itemCatalog.TryGetItemDefinition(entry.DefinitionId, out var definition)) return false; + if (!TryGetGrid(profile, entry.Container, out var grid)) return false; + + var item = new ExtractionItemInstance( + entry.ItemInstanceId, + entry.DefinitionId, + entry.RemovedQuantity, + entry.SourceKind, + entry.SourceId); + if (!profile.Items.Register(item)) return false; + if (!grid.TryPlace(item, definition, entry.X, entry.Y, entry.Rotated)) + { + profile.Items.TryRemove(entry.ItemInstanceId); + return false; + } + + if (!profile.Ownership.Register(entry.ItemInstanceId, entry.Container)) + { + grid.TryRemove(entry.ItemInstanceId); + profile.Items.TryRemove(entry.ItemInstanceId); + return false; + } + + return true; + } + + private static bool TryBuildRequiredQuantities( + List costs, + out Dictionary required) + { + required = new Dictionary(); + if (!ExtractionFeatureSwitch.Enabled) return false; + if (costs == null || costs.Count == 0) return false; + + var acc = new Dictionary(); + foreach (var cost in costs) + { + if (cost == null || !cost.IsValid) return false; + + acc.TryGetValue(cost.ItemDefinitionId, out long current); + long sum = current + cost.Quantity; + acc[cost.ItemDefinitionId] = sum > int.MaxValue ? int.MaxValue : sum; + } + + foreach (var kv in acc) + required[kv.Key] = (int)kv.Value; + + return required.Count > 0; + } + + private static bool TryConsumeQuantity( + ExtractionProfileSaveData profile, + string itemDefinitionId, + int quantity, + List containers, + ExtractionCostConsumptionReceipt receipt) + { + int remaining = quantity; + foreach (var container in containers) + { + if (!TryGetGrid(profile, container, out var grid)) continue; + var placements = new List(grid.Placements); + + foreach (var placement in placements) + { + if (placement == null) continue; + if (!profile.Items.TryGet(placement.ItemInstanceId, out var item)) continue; + if (item.DefinitionId != itemDefinitionId || item.Quantity <= 0) continue; + if (!profile.Ownership.TryGetContainer(placement.ItemInstanceId, out var current)) continue; + if (current != container) continue; + + int consumed = Math.Min(remaining, item.Quantity); + receipt.Entries.Add(new ExtractionConsumedCostEntry + { + ItemInstanceId = item.InstanceId, + DefinitionId = item.DefinitionId, + Container = container, + RemovedQuantity = consumed, + WasFullyRemoved = consumed >= item.Quantity, + SourceKind = item.SourceKind, + SourceId = item.SourceId, + X = placement.X, + Y = placement.Y, + Rotated = placement.Rotated + }); + item.Quantity -= consumed; + remaining -= consumed; + + if (item.Quantity <= 0) + { + grid.TryRemove(item.InstanceId); + profile.Items.TryRemove(item.InstanceId); + profile.Ownership.TryRemove(item.InstanceId); + } + + if (remaining <= 0) return true; + } + } + + return false; + } + + private static bool TryGetGrid( + ExtractionProfileSaveData profile, + ExtractionInventoryContainerType container, + out ExtractionItemGrid grid) + { + grid = container switch + { + ExtractionInventoryContainerType.Stash => profile.Stash, + ExtractionInventoryContainerType.Loadout => profile.Loadout, + ExtractionInventoryContainerType.SecureContainer => profile.SecureContainer, + ExtractionInventoryContainerType.Holding => profile.RecoveryHolding, + _ => null + }; + + return grid != null; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemCostService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemCostService.cs.meta new file mode 100644 index 0000000..88c79d7 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemCostService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f711d625f31d2fe4bb3c6044af8b7873 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemDefinition.cs new file mode 100644 index 0000000..af33ff9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemDefinition.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionItemDefinition + { + public string DefinitionId; + public int Width; + public int Height; + public bool CanRotate; + public int MaxStack; + public bool CanEmergencyExtract; + + // M1 R4 新增(additive,构造函数签名不变,避免动全部既有调用点): + // Value 是出售/兑换等经济系统的基准单价;DisplayNameKey/IconKey 是本地化/图标资源键, + // 参照 POBPickup 的 nameKey 惯例;Tags 供内容筛选/UI 分组,非空即用、允许为空。 + public int Value; + public ExtractionItemRarity Rarity; + public string DisplayNameKey; + public string IconKey; + public string[] Tags; + + // M2 SW.1 新增(additive,同上原则):Weight 是负重系统的单位重量基准(每份数量的重量, + // 总重=Weight×Quantity 累加),默认 0 = 不参与负重计算,兼容未配置该字段的旧物品/mod。 + public int Weight; + + // 耐久工具/钥匙配置。MaxDurability=0 表示普通物品;兼容目标使用稳定 actor ID。 + public int MaxDurability; + public List CompatibleTargetIds = new(); + public ExtractionItemActionPolicy ActionPolicy = ExtractionItemActionPolicy.CreateDefaultLoot(); + + public ExtractionItemDefinition(string definitionId, int width, int height, bool canRotate, int maxStack) + : this(definitionId, width, height, canRotate, maxStack, false) + { + } + + public ExtractionItemDefinition( + string definitionId, + int width, + int height, + bool canRotate, + int maxStack, + bool canEmergencyExtract) + { + DefinitionId = definitionId; + Width = width; + Height = height; + CanRotate = canRotate; + MaxStack = maxStack; + CanEmergencyExtract = canEmergencyExtract; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemDefinition.cs.meta new file mode 100644 index 0000000..c283fe2 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: eb0755a8d8b893141ac6055d91336f01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemGrid.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemGrid.cs new file mode 100644 index 0000000..490b28e --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemGrid.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionItemGrid + { + public int Width; + public int Height; + public List Placements = new(); + + public ExtractionItemGrid(int width, int height) + { + Width = width; + Height = height; + } + + public bool TryPlace(ExtractionItemInstance item, ExtractionItemDefinition definition, int x, int y, bool rotated) + { + if (item == null || definition == null) return false; + int width = rotated ? definition.Height : definition.Width; + int height = rotated ? definition.Width : definition.Height; + if (rotated && !definition.CanRotate) return false; + if (Contains(item.InstanceId)) return false; + if (!Fits(x, y, width, height)) return false; + if (Overlaps(x, y, width, height)) return false; + + Placements.Add(new ExtractionItemPlacement(item.InstanceId, x, y, width, height, rotated)); + return true; + } + + // 只读探针,供 SG.2 拖拽预览用:不改动任何状态,纯粹复用既有的 Fits/Overlaps 组合。 + public bool CanPlace(int x, int y, int width, int height) + { + return Fits(x, y, width, height) && !Overlaps(x, y, width, height); + } + + public bool TryRemove(string itemInstanceId) + { + if (string.IsNullOrEmpty(itemInstanceId)) return false; + + for (int i = 0; i < Placements.Count; i++) + { + if (Placements[i].ItemInstanceId != itemInstanceId) continue; + Placements.RemoveAt(i); + return true; + } + + return false; + } + + public bool TryFindFreeSlot(int width, int height, out int x, out int y) + { + x = 0; + y = 0; + if (width <= 0 || height <= 0) return false; + + for (int scanY = 0; scanY + height <= Height; scanY++) + { + for (int scanX = 0; scanX + width <= Width; scanX++) + { + if (Overlaps(scanX, scanY, width, height)) continue; + + x = scanX; + y = scanY; + return true; + } + } + + return false; + } + + // 先按原始朝向找空位,失败且允许旋转时再试一次宽高互换。两个调用方(武器折回/loot 自动摆放) + // 都需要这个"找不到就试试转 90 度"的兜底,抽到 grid 自己身上,避免各自维护一份重复逻辑。 + public bool TryFindFreeSlotWithRotation(int width, int height, bool canRotate, out int x, out int y, out bool rotated) + { + rotated = false; + if (TryFindFreeSlot(width, height, out x, out y)) return true; + if (!canRotate) return false; + + rotated = TryFindFreeSlot(height, width, out x, out y); + return rotated; + } + + public bool TryGetPlacement(string itemInstanceId, out ExtractionItemPlacement placement) + { + if (string.IsNullOrEmpty(itemInstanceId)) + { + placement = null; + return false; + } + + foreach (var candidate in Placements) + { + if (candidate.ItemInstanceId != itemInstanceId) continue; + placement = candidate; + return true; + } + + placement = null; + return false; + } + + // 供 SD.2 点选放置/交换用:给一个格子坐标,反查"这个格子当前落在哪个 placement 的 + // 占用范围内"(多格物品的任意一格都能查到同一个 placement,不要求正好是左上角)。 + public bool TryGetPlacementAt(int x, int y, out ExtractionItemPlacement placement) + { + foreach (var candidate in Placements) + { + if (x < candidate.X || x >= candidate.X + candidate.Width) continue; + if (y < candidate.Y || y >= candidate.Y + candidate.Height) continue; + + placement = candidate; + return true; + } + + placement = null; + return false; + } + + private bool Contains(string itemInstanceId) + { + foreach (var placement in Placements) + { + if (placement.ItemInstanceId == itemInstanceId) return true; + } + + return false; + } + + private bool Fits(int x, int y, int width, int height) + { + return x >= 0 && y >= 0 && width > 0 && height > 0 && x + width <= Width && y + height <= Height; + } + + private bool Overlaps(int x, int y, int width, int height) + { + foreach (var existing in Placements) + { + bool separated = x + width <= existing.X + || existing.X + existing.Width <= x + || y + height <= existing.Y + || existing.Y + existing.Height <= y; + if (!separated) return true; + } + + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemGrid.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemGrid.cs.meta new file mode 100644 index 0000000..d27a38b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemGrid.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2ce91e2f0a692b945817dd808b897b09 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemInstance.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemInstance.cs new file mode 100644 index 0000000..7a2aa27 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemInstance.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Flags] + public enum ExtractionItemInstanceFlags + { + None = 0, + CanDrop = 1 << 0, + CanSell = 1 << 1, + DropOnDeath = 1 << 2, + PolicyInitialized = 1 << 3, + RaidBound = 1 << 4, + DestroyOnSettlement = 1 << 5 + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionItemInstance + { + public string InstanceId; + public string DefinitionId; + public int Quantity; + public string SourceKind; + public string SourceId; + public int CurrentDurability; + public int EnhancementLevel; + public int ForgeTier; + public List AffixIds = new(); + public ExtractionItemInstanceFlags Flags = DefaultLegacyFlags; + + public const ExtractionItemInstanceFlags DefaultLegacyFlags = + ExtractionItemInstanceFlags.CanDrop + | ExtractionItemInstanceFlags.CanSell + | ExtractionItemInstanceFlags.DropOnDeath + | ExtractionItemInstanceFlags.PolicyInitialized; + + public ExtractionItemInstance(string instanceId, string definitionId, int quantity) + : this(instanceId, definitionId, quantity, null, null) + { + } + + public ExtractionItemInstance( + string instanceId, + string definitionId, + int quantity, + string sourceKind, + string sourceId) + { + InstanceId = instanceId; + DefinitionId = definitionId; + Quantity = quantity; + SourceKind = sourceKind; + SourceId = sourceId; + } + + internal ExtractionItemInstance(ExtractionItemInstance source) + : this( + source.InstanceId, + source.DefinitionId, + source.Quantity, + source.SourceKind, + source.SourceId) + { + CurrentDurability = source.CurrentDurability; + EnhancementLevel = source.EnhancementLevel; + ForgeTier = source.ForgeTier; + Flags = source.Flags; + AffixIds = source.AffixIds != null + ? new List(source.AffixIds) + : new List(); + EnsureInitialized(); + } + + public bool HasFlag(ExtractionItemInstanceFlags flag) + { + return (Flags & flag) == flag; + } + + internal void EnsureInitialized() + { + AffixIds ??= new List(); + if (!HasFlag(ExtractionItemInstanceFlags.PolicyInitialized)) + Flags |= DefaultLegacyFlags; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemInstance.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemInstance.cs.meta new file mode 100644 index 0000000..4581bb4 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemInstance.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 657d136adc62cff4097819e75f81b27a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemLifecycleService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemLifecycleService.cs new file mode 100644 index 0000000..e8e59af --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemLifecycleService.cs @@ -0,0 +1,639 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + public enum ExtractionItemLifecycleResult + { + Succeeded = 0, + Cancelled = 1, + InvalidRequest = 2, + ItemNotFound = 3, + PolicyDenied = 4, + NoSpace = 5, + LocationConflict = 6, + CommitFailed = 7 + } + + public static class ExtractionItemLifecycleService + { + public static bool TryDropInRaid( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState raidInventory, + IExtractionItemCatalog itemCatalog, + string itemInstanceId, + string worldPickupId, + out ExtractionItemLifecycleResult result) + { + result = ExtractionItemLifecycleResult.InvalidRequest; + if (!TryGetItemAndLocation( + profile, + itemCatalog, + itemInstanceId, + out var item, + out var definition, + out var entry)) + { + result = ExtractionItemLifecycleResult.ItemNotFound; + return false; + } + + if (!ExtractionFeatureSwitch.Enabled + || string.IsNullOrEmpty(worldPickupId) + || !ExtractionItemActionPolicyService.CanDrop(definition, item)) + { + result = ExtractionItemLifecycleResult.PolicyDenied; + return false; + } + if (entry.Container != ExtractionInventoryContainerType.RaidBackpack + && (entry.Container != ExtractionInventoryContainerType.EquipmentSlot + || entry.LocationSubtype != ExtractionItemLocationService.RaidEquipmentLocationSubtype)) + { + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + + if (!TryDetach(profile, raidInventory, item, definition, entry, out var restore)) + { + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + if (!profile.Ownership.TryMove( + itemInstanceId, + entry.Container, + ExtractionInventoryContainerType.WorldPickup, + "world-pickup", + worldPickupId)) + { + restore(); + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + + var worldItems = profile.ActiveRaid?.Content?.WorldPickupItemInstanceIds; + if (worldItems != null && !worldItems.Contains(itemInstanceId)) worldItems.Add(itemInstanceId); + result = ExtractionItemLifecycleResult.Succeeded; + return true; + } + + public static bool TryPickupWorldItem( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState raidInventory, + IExtractionItemCatalog itemCatalog, + string itemInstanceId, + ExtractionInventoryContainerType targetContainer, + out ExtractionItemLifecycleResult result) + { + result = ExtractionItemLifecycleResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled + || raidInventory == null + || (targetContainer != ExtractionInventoryContainerType.RaidBackpack + && targetContainer != ExtractionInventoryContainerType.InSecureContainer) + || !TryGetItemAndLocation( + profile, + itemCatalog, + itemInstanceId, + out var item, + out var definition, + out var entry)) + { + return false; + } + if (entry.Container != ExtractionInventoryContainerType.WorldPickup) + { + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + + if (targetContainer == ExtractionInventoryContainerType.InSecureContainer + && !ExtractionItemActionPolicyService.CanPlaceInSecure(definition)) + { + result = ExtractionItemLifecycleResult.PolicyDenied; + return false; + } + + if (!ExtractionItemLocationService.TryGetGrid(profile, raidInventory, targetContainer, out var targetGrid) + || !targetGrid.TryFindFreeSlotWithRotation( + definition.Width, + definition.Height, + definition.CanRotate, + out int x, + out int y, + out bool rotated) + || !targetGrid.TryPlace(item, definition, x, y, rotated)) + { + result = ExtractionItemLifecycleResult.NoSpace; + return false; + } + + if (!profile.Ownership.TryMove( + itemInstanceId, + ExtractionInventoryContainerType.WorldPickup, + targetContainer)) + { + targetGrid.TryRemove(itemInstanceId); + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + + profile.ActiveRaid?.Content?.WorldPickupItemInstanceIds?.Remove(itemInstanceId); + result = ExtractionItemLifecycleResult.Succeeded; + return true; + } + + public static bool TryMoveRaidCarriedItem( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState raidInventory, + IExtractionItemCatalog itemCatalog, + string itemInstanceId, + ExtractionInventoryContainerType targetContainer, + out ExtractionItemLifecycleResult result) + { + result = ExtractionItemLifecycleResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled + || raidInventory == null + || (targetContainer != ExtractionInventoryContainerType.RaidBackpack + && targetContainer != ExtractionInventoryContainerType.InSecureContainer) + || !TryGetItemAndLocation( + profile, + itemCatalog, + itemInstanceId, + out var item, + out var definition, + out var entry)) + { + return false; + } + + if ((entry.Container != ExtractionInventoryContainerType.RaidBackpack + && entry.Container != ExtractionInventoryContainerType.InSecureContainer) + || entry.Container == targetContainer) + { + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + + if (targetContainer == ExtractionInventoryContainerType.InSecureContainer + && !ExtractionItemActionPolicyService.CanPlaceInSecure(definition)) + { + result = ExtractionItemLifecycleResult.PolicyDenied; + return false; + } + + if (!ExtractionItemLocationService.TryGetGrid( + profile, + raidInventory, + entry.Container, + out var sourceGrid) + || !ExtractionItemLocationService.TryGetGrid( + profile, + raidInventory, + targetContainer, + out var targetGrid) + || !sourceGrid.TryGetPlacement(itemInstanceId, out var sourcePlacement) + || !targetGrid.TryFindFreeSlotWithRotation( + definition.Width, + definition.Height, + definition.CanRotate, + out int x, + out int y, + out bool rotated) + || !targetGrid.TryPlace(item, definition, x, y, rotated)) + { + result = ExtractionItemLifecycleResult.NoSpace; + return false; + } + + if (!sourceGrid.TryRemove(itemInstanceId)) + { + targetGrid.TryRemove(itemInstanceId); + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + + if (!profile.Ownership.TryMove(itemInstanceId, entry.Container, targetContainer)) + { + targetGrid.TryRemove(itemInstanceId); + sourceGrid.TryPlace( + item, + definition, + sourcePlacement.X, + sourcePlacement.Y, + sourcePlacement.Rotated); + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + + result = ExtractionItemLifecycleResult.Succeeded; + return true; + } + + public static bool TryMoveBaseStoredItem( + ExtractionProfileSaveData profile, + IExtractionItemCatalog itemCatalog, + string itemInstanceId, + ExtractionInventoryContainerType targetContainer, + out ExtractionItemLifecycleResult result) + { + result = ExtractionItemLifecycleResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled + || (targetContainer != ExtractionInventoryContainerType.Stash + && targetContainer != ExtractionInventoryContainerType.Loadout + && targetContainer != ExtractionInventoryContainerType.SecureContainer) + || !TryGetItemAndLocation( + profile, + itemCatalog, + itemInstanceId, + out var item, + out var definition, + out var entry)) + { + return false; + } + + if (!IsBaseGridLocation(entry.Container) || entry.Container == targetContainer) + { + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + if (targetContainer == ExtractionInventoryContainerType.SecureContainer + && !ExtractionItemActionPolicyService.CanPlaceInSecure(definition)) + { + result = ExtractionItemLifecycleResult.PolicyDenied; + return false; + } + + if (!ExtractionItemLocationService.TryGetGrid(profile, null, entry.Container, out var sourceGrid) + || !ExtractionItemLocationService.TryGetGrid(profile, null, targetContainer, out var targetGrid) + || !sourceGrid.TryGetPlacement(itemInstanceId, out var sourcePlacement) + || !targetGrid.TryFindFreeSlotWithRotation( + definition.Width, + definition.Height, + definition.CanRotate, + out int x, + out int y, + out bool rotated) + || !targetGrid.TryPlace(item, definition, x, y, rotated)) + { + result = ExtractionItemLifecycleResult.NoSpace; + return false; + } + + if (!sourceGrid.TryRemove(itemInstanceId)) + { + targetGrid.TryRemove(itemInstanceId); + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + + if (!profile.Ownership.TryMove(itemInstanceId, entry.Container, targetContainer)) + { + targetGrid.TryRemove(itemInstanceId); + sourceGrid.TryPlace( + item, + definition, + sourcePlacement.X, + sourcePlacement.Y, + sourcePlacement.Rotated); + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + + result = ExtractionItemLifecycleResult.Succeeded; + return true; + } + + public static bool TryDestroyAtBase( + ExtractionProfileSaveData profile, + IExtractionItemCatalog itemCatalog, + string itemInstanceId, + bool confirmed, + out ExtractionItemLifecycleResult result) + { + result = confirmed + ? ExtractionItemLifecycleResult.InvalidRequest + : ExtractionItemLifecycleResult.Cancelled; + if (!confirmed) return false; + if (!ExtractionFeatureSwitch.Enabled + || !TryGetItemAndLocation( + profile, + itemCatalog, + itemInstanceId, + out var item, + out var definition, + out var entry)) + { + return false; + } + + if (!ExtractionItemActionPolicyService.CanDrop(definition, item)) + { + result = ExtractionItemLifecycleResult.PolicyDenied; + return false; + } + if (!IsBaseLocation(entry)) + { + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + + if (!TryDetach(profile, null, item, definition, entry, out var restore)) + { + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + if (!profile.Ownership.TryMove( + itemInstanceId, + entry.Container, + ExtractionInventoryContainerType.Destroyed, + "base-destroy", + null)) + { + restore(); + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + + result = ExtractionItemLifecycleResult.Succeeded; + return true; + } + + public static bool TrySubmitToTask( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState raidInventory, + IExtractionItemCatalog itemCatalog, + string itemInstanceId, + string taskId, + out ExtractionItemLifecycleResult result) + { + result = ExtractionItemLifecycleResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled + || string.IsNullOrEmpty(taskId) + || !TryGetItemAndLocation( + profile, + itemCatalog, + itemInstanceId, + out var item, + out var definition, + out var entry)) + { + return false; + } + + if (!ExtractionItemActionPolicyService.CanSubmitToTask(definition)) return false; + if (!TryDetach(profile, raidInventory, item, definition, entry, out var restore)) + { + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + if (!profile.Ownership.TryMove( + itemInstanceId, + entry.Container, + ExtractionInventoryContainerType.Consumed, + "task", + taskId)) + { + restore(); + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + + result = ExtractionItemLifecycleResult.Succeeded; + return true; + } + + public static bool TryApplyDeathPolicy( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState raidInventory, + IExtractionItemCatalog itemCatalog, + string corpseId, + out ExtractionItemLifecycleResult result) + { + result = ExtractionItemLifecycleResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled + || profile == null + || raidInventory == null + || itemCatalog == null + || string.IsNullOrEmpty(corpseId)) + { + return false; + } + + profile.EnsureInitialized(); + raidInventory.EnsureInitialized(); + var plans = new List(); + var stagedHolding = CloneGrid(profile.RecoveryHolding); + foreach (var entry in profile.Ownership.Entries) + { + if (entry == null || !IsRaidCarriedLocation(entry)) continue; + if (!profile.Items.TryGet(entry.ItemInstanceId, out var item) + || !itemCatalog.TryGetItemDefinition(item.DefinitionId, out var definition)) + { + result = ExtractionItemLifecycleResult.ItemNotFound; + return false; + } + + var policy = ExtractionItemActionPolicyService.GetPolicy(definition); + bool destroy = policy != null + && (policy.DestroyOnSettlement + || policy.RaidBound + || item.HasFlag(ExtractionItemInstanceFlags.DestroyOnSettlement) + || item.HasFlag(ExtractionItemInstanceFlags.RaidBound)); + bool protectedFromDeath = entry.Container == ExtractionInventoryContainerType.InSecureContainer + || !ExtractionItemActionPolicyService.DropsOnDeath(definition, item); + var target = destroy + ? ExtractionInventoryContainerType.Destroyed + : protectedFromDeath + ? ExtractionInventoryContainerType.Holding + : ExtractionInventoryContainerType.Corpse; + + if (target == ExtractionInventoryContainerType.Holding) + { + if (!stagedHolding.TryFindFreeSlotWithRotation( + definition.Width, + definition.Height, + definition.CanRotate, + out int x, + out int y, + out bool rotated) + || !stagedHolding.TryPlace(item, definition, x, y, rotated)) + { + result = ExtractionItemLifecycleResult.NoSpace; + return false; + } + } + + plans.Add(new DeathPlan(entry, item, definition, target)); + } + + foreach (var plan in plans) + { + if (!TryDetach(profile, raidInventory, plan.Item, plan.Definition, plan.Entry, out _)) + { + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + + string subtype = plan.Target == ExtractionInventoryContainerType.Corpse + ? "corpse" + : plan.Target == ExtractionInventoryContainerType.Destroyed + ? "death-destroy" + : null; + string locationId = plan.Target == ExtractionInventoryContainerType.Corpse ? corpseId : null; + if (!profile.Ownership.TryMove( + plan.Item.InstanceId, + plan.Entry.Container, + plan.Target, + subtype, + locationId)) + { + result = ExtractionItemLifecycleResult.LocationConflict; + return false; + } + } + + profile.RecoveryHolding = stagedHolding; + result = ExtractionItemLifecycleResult.Succeeded; + return true; + } + + private static bool TryGetItemAndLocation( + ExtractionProfileSaveData profile, + IExtractionItemCatalog itemCatalog, + string itemInstanceId, + out ExtractionItemInstance item, + out ExtractionItemDefinition definition, + out ExtractionOwnershipEntry entry) + { + item = null; + definition = null; + entry = null; + if (profile == null || itemCatalog == null || string.IsNullOrEmpty(itemInstanceId)) return false; + profile.EnsureInitialized(); + if (!profile.Items.TryGet(itemInstanceId, out item) + || !itemCatalog.TryGetItemDefinition(item.DefinitionId, out definition)) + { + return false; + } + + foreach (var candidate in profile.Ownership.Entries) + { + if (candidate?.ItemInstanceId != itemInstanceId) continue; + if (entry != null) return false; + entry = candidate; + } + + return entry != null; + } + + private static bool TryDetach( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState raidInventory, + ExtractionItemInstance item, + ExtractionItemDefinition definition, + ExtractionOwnershipEntry entry, + out Action restore) + { + restore = () => { }; + if (entry.Container == ExtractionInventoryContainerType.EquipmentSlot) + { + if (!ExtractionItemLocationService.TryGetEquipment( + profile, + raidInventory, + entry.LocationSubtype, + out var equipment) + || string.IsNullOrEmpty(entry.LocationId) + || !equipment.TryClear(entry.LocationId, item.InstanceId)) + { + return false; + } + + restore = () => equipment.TrySet(entry.LocationId, item.InstanceId); + return true; + } + + if (ExtractionItemLocationService.TryGetGrid( + profile, + raidInventory, + entry.Container, + out var grid)) + { + if (!grid.TryGetPlacement(item.InstanceId, out var placement) + || !grid.TryRemove(item.InstanceId)) + { + return false; + } + + restore = () => grid.TryPlace(item, definition, placement.X, placement.Y, placement.Rotated); + return true; + } + + return entry.Container == ExtractionInventoryContainerType.WorldPickup + || entry.Container == ExtractionInventoryContainerType.InRaid; + } + + private static bool IsBaseLocation(ExtractionOwnershipEntry entry) + { + return entry.Container == ExtractionInventoryContainerType.Stash + || entry.Container == ExtractionInventoryContainerType.Loadout + || entry.Container == ExtractionInventoryContainerType.SecureContainer + || entry.Container == ExtractionInventoryContainerType.Holding + || (entry.Container == ExtractionInventoryContainerType.EquipmentSlot + && entry.LocationSubtype == ExtractionItemLocationService.BaseEquipmentLocationSubtype); + } + + private static bool IsBaseGridLocation(ExtractionInventoryContainerType container) + { + return container == ExtractionInventoryContainerType.Stash + || container == ExtractionInventoryContainerType.Loadout + || container == ExtractionInventoryContainerType.SecureContainer; + } + + private static bool IsRaidCarriedLocation(ExtractionOwnershipEntry entry) + { + return entry.Container == ExtractionInventoryContainerType.RaidBackpack + || entry.Container == ExtractionInventoryContainerType.InRaid + || entry.Container == ExtractionInventoryContainerType.InSecureContainer + || (entry.Container == ExtractionInventoryContainerType.EquipmentSlot + && entry.LocationSubtype == ExtractionItemLocationService.RaidEquipmentLocationSubtype); + } + + private static ExtractionItemGrid CloneGrid(ExtractionItemGrid source) + { + var clone = new ExtractionItemGrid(source?.Width ?? 1, source?.Height ?? 1); + if (source?.Placements == null) return clone; + foreach (var placement in source.Placements) + { + if (placement == null) continue; + clone.Placements.Add(new ExtractionItemPlacement( + placement.ItemInstanceId, + placement.X, + placement.Y, + placement.Width, + placement.Height, + placement.Rotated)); + } + return clone; + } + + private sealed class DeathPlan + { + internal DeathPlan( + ExtractionOwnershipEntry entry, + ExtractionItemInstance item, + ExtractionItemDefinition definition, + ExtractionInventoryContainerType target) + { + Entry = entry; + Item = item; + Definition = definition; + Target = target; + } + + internal ExtractionOwnershipEntry Entry { get; } + internal ExtractionItemInstance Item { get; } + internal ExtractionItemDefinition Definition { get; } + internal ExtractionInventoryContainerType Target { get; } + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemLifecycleService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemLifecycleService.cs.meta new file mode 100644 index 0000000..59e203f --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemLifecycleService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 525aab9dc33b41058b79a88fe8039f04 diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemLocationService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemLocationService.cs new file mode 100644 index 0000000..d0bd667 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemLocationService.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionItemLocationService + { + public const string BaseEquipmentLocationSubtype = "base-equipment"; + public const string RaidEquipmentLocationSubtype = "raid-equipment"; + + public static bool TryValidate( + ExtractionProfileSaveData profile, + out string issue) + { + issue = null; + if (profile == null) return Fail("Profile is null.", out issue); + profile.EnsureInitialized(); + + var registryIds = new HashSet(StringComparer.Ordinal); + foreach (var item in profile.Items.Entries) + { + if (item == null || string.IsNullOrEmpty(item.InstanceId)) + return Fail("Item registry contains an invalid entry.", out issue); + if (!registryIds.Add(item.InstanceId)) + return Fail($"Item '{item.InstanceId}' is registered more than once.", out issue); + } + + var ownership = new Dictionary(StringComparer.Ordinal); + foreach (var entry in profile.Ownership.Entries) + { + if (entry == null || string.IsNullOrEmpty(entry.ItemInstanceId)) + return Fail("Ownership ledger contains an invalid entry.", out issue); + if (!registryIds.Contains(entry.ItemInstanceId)) + return Fail($"Ownership references unknown item '{entry.ItemInstanceId}'.", out issue); + if (!ownership.TryAdd(entry.ItemInstanceId, entry)) + return Fail($"Item '{entry.ItemInstanceId}' has more than one ownership location.", out issue); + } + + foreach (string itemId in registryIds) + { + if (!ownership.ContainsKey(itemId)) + return Fail($"Item '{itemId}' has no ownership location.", out issue); + } + + var physicalLocations = new Dictionary(StringComparer.Ordinal); + if (!CollectGrid(profile.Stash, "stash", physicalLocations, out issue) + || !CollectGrid(profile.CarryGrid, "carry-grid", physicalLocations, out issue) + || !CollectGrid(profile.SecureContainer, "secure", physicalLocations, out issue) + || !CollectGrid(profile.RecoveryHolding, "holding", physicalLocations, out issue) + || !CollectGrid(profile.ActiveRaidInventory?.RaidBackpack, "raid-backpack", physicalLocations, out issue) + || !CollectGrid(profile.ActiveRaidInventory?.SecureContainer, "raid-secure", physicalLocations, out issue) + || !CollectEquipment(profile.Equipment, BaseEquipmentLocationSubtype, physicalLocations, out issue) + || !CollectEquipment(profile.ActiveRaidInventory?.Equipment, RaidEquipmentLocationSubtype, physicalLocations, out issue)) + { + return false; + } + + foreach (var pair in ownership) + { + var entry = pair.Value; + if (entry.Container == ExtractionInventoryContainerType.EquipmentSlot) + { + if (entry.LocationSubtype != BaseEquipmentLocationSubtype + && entry.LocationSubtype != RaidEquipmentLocationSubtype) + { + return Fail($"Equipped item '{pair.Key}' has no valid equipment scope.", out issue); + } + + if (!physicalLocations.TryGetValue(pair.Key, out string actual) + || actual != entry.LocationSubtype + ":" + entry.LocationId) + { + return Fail($"Equipped item '{pair.Key}' does not match slot '{entry.LocationId}'.", out issue); + } + + continue; + } + + if (TryGetExpectedPhysicalLocation(entry.Container, out string expected)) + { + if (!physicalLocations.TryGetValue(pair.Key, out string actual) || actual != expected) + return Fail($"Item '{pair.Key}' does not match its '{entry.Container}' grid location.", out issue); + continue; + } + + if (physicalLocations.ContainsKey(pair.Key)) + return Fail($"Item '{pair.Key}' is both '{entry.Container}' and physically stored.", out issue); + } + + foreach (var pair in physicalLocations) + { + if (!ownership.ContainsKey(pair.Key)) + return Fail($"Physical item '{pair.Key}' is missing from ownership.", out issue); + } + + return true; + } + + public static int CalculateWeight( + ExtractionProfileSaveData profile, + IExtractionItemCatalog itemCatalog, + params ExtractionInventoryContainerType[] containers) + { + if (profile == null || itemCatalog == null || containers == null || containers.Length == 0) + return 0; + + var included = new HashSet(containers); + long total = 0; + foreach (var entry in profile.Ownership.Entries) + { + if (entry == null || !included.Contains(entry.Container)) continue; + if (!profile.Items.TryGet(entry.ItemInstanceId, out var item)) continue; + if (!itemCatalog.TryGetItemDefinition(item.DefinitionId, out var definition)) continue; + + total += (long)Math.Max(0, definition.Weight) * Math.Max(0, item.Quantity); + if (total >= int.MaxValue) return int.MaxValue; + } + + return (int)total; + } + + internal static bool TryGetGrid( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState raidInventory, + ExtractionInventoryContainerType container, + out ExtractionItemGrid grid) + { + grid = container switch + { + ExtractionInventoryContainerType.Stash => profile?.Stash, + ExtractionInventoryContainerType.Loadout => profile?.CarryGrid, + ExtractionInventoryContainerType.SecureContainer => profile?.SecureContainer, + ExtractionInventoryContainerType.Holding => profile?.RecoveryHolding, + ExtractionInventoryContainerType.RaidBackpack => raidInventory?.RaidBackpack, + ExtractionInventoryContainerType.InSecureContainer => raidInventory?.SecureContainer, + _ => null + }; + return grid != null; + } + + internal static bool TryGetEquipment( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState raidInventory, + string locationSubtype, + out ExtractionEquipmentState equipment) + { + equipment = locationSubtype switch + { + BaseEquipmentLocationSubtype => profile?.Equipment, + RaidEquipmentLocationSubtype => raidInventory?.Equipment, + _ => null + }; + return equipment != null; + } + + private static bool TryGetExpectedPhysicalLocation( + ExtractionInventoryContainerType container, + out string location) + { + location = container switch + { + ExtractionInventoryContainerType.Stash => "stash", + ExtractionInventoryContainerType.Loadout => "carry-grid", + ExtractionInventoryContainerType.SecureContainer => "secure", + ExtractionInventoryContainerType.Holding => "holding", + ExtractionInventoryContainerType.RaidBackpack => "raid-backpack", + ExtractionInventoryContainerType.InSecureContainer => "raid-secure", + _ => null + }; + return location != null; + } + + private static bool CollectGrid( + ExtractionItemGrid grid, + string location, + Dictionary physicalLocations, + out string issue) + { + issue = null; + if (grid?.Placements == null) return true; + + foreach (var placement in grid.Placements) + { + if (placement == null || string.IsNullOrEmpty(placement.ItemInstanceId)) + return Fail($"Grid '{location}' contains an invalid placement.", out issue); + if (!physicalLocations.TryAdd(placement.ItemInstanceId, location)) + return Fail($"Item '{placement.ItemInstanceId}' appears in multiple physical locations.", out issue); + } + + return true; + } + + private static bool CollectEquipment( + ExtractionEquipmentState equipment, + string locationSubtype, + Dictionary physicalLocations, + out string issue) + { + issue = null; + if (equipment?.Slots == null) return true; + + var slotIds = new HashSet(StringComparer.Ordinal); + foreach (var slot in equipment.Slots) + { + if (slot == null || string.IsNullOrEmpty(slot.SlotId) || string.IsNullOrEmpty(slot.ItemInstanceId)) + return Fail($"Equipment '{locationSubtype}' contains an invalid slot.", out issue); + if (!slotIds.Add(slot.SlotId)) + return Fail($"Equipment slot '{slot.SlotId}' appears more than once.", out issue); + if (!physicalLocations.TryAdd(slot.ItemInstanceId, locationSubtype + ":" + slot.SlotId)) + return Fail($"Item '{slot.ItemInstanceId}' appears in multiple physical locations.", out issue); + } + + return true; + } + + private static bool Fail(string message, out string issue) + { + issue = message; + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemLocationService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemLocationService.cs.meta new file mode 100644 index 0000000..4e6906a --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemLocationService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3c9cb6dcef8049a3a752e1ef37b546cc diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemPlacement.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemPlacement.cs new file mode 100644 index 0000000..72210e5 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemPlacement.cs @@ -0,0 +1,27 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionItemPlacement + { + public string ItemInstanceId; + public int X; + public int Y; + public int Width; + public int Height; + public bool Rotated; + + public ExtractionItemPlacement(string itemInstanceId, int x, int y, int width, int height, bool rotated) + { + ItemInstanceId = itemInstanceId; + X = x; + Y = y; + Width = width; + Height = height; + Rotated = rotated; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemPlacement.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemPlacement.cs.meta new file mode 100644 index 0000000..6d83052 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemPlacement.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ee427c410454c454c8dc7ea3ced493cc +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemRarity.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemRarity.cs new file mode 100644 index 0000000..b8b246e --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemRarity.cs @@ -0,0 +1,12 @@ +namespace POB.Extraction +{ + public enum ExtractionItemRarity + { + Common = 0, + Uncommon = 1, + Rare = 2, + Epic = 3, + Legendary = 4, + Mythic = 5 + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemRarity.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemRarity.cs.meta new file mode 100644 index 0000000..0927bd0 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemRarity.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6bb0cd428f68b34287d2c169c279bde +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemRegistry.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemRegistry.cs new file mode 100644 index 0000000..6a0b055 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemRegistry.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionItemRegistry + { + public List Entries = new(); + + public bool Register(ExtractionItemInstance item) + { + if (item == null || string.IsNullOrEmpty(item.InstanceId) || string.IsNullOrEmpty(item.DefinitionId)) + return false; + if (FindEntry(item.InstanceId) != null) return false; + + Entries.Add(new ExtractionItemInstance(item)); + return true; + } + + public bool TryGet(string itemInstanceId, out ExtractionItemInstance item) + { + item = FindEntry(itemInstanceId); + return item != null; + } + + public bool TryRemove(string itemInstanceId) + { + if (string.IsNullOrEmpty(itemInstanceId)) return false; + + for (int i = 0; i < Entries.Count; i++) + { + if (Entries[i].InstanceId != itemInstanceId) continue; + Entries.RemoveAt(i); + return true; + } + + return false; + } + + private ExtractionItemInstance FindEntry(string itemInstanceId) + { + foreach (var entry in Entries) + { + if (entry.InstanceId == itemInstanceId) return entry; + } + + return null; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemRegistry.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemRegistry.cs.meta new file mode 100644 index 0000000..865cfda --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemRegistry.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0aca08c6bdcf4c67addd31b34f130977 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemUseService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemUseService.cs new file mode 100644 index 0000000..9b3ec0c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemUseService.cs @@ -0,0 +1,218 @@ +using System; + +namespace POB.Extraction +{ + public enum ExtractionItemUseResult + { + Succeeded = 0, + AlreadyApplied = 1, + InvalidRequest = 2, + ItemNotFound = 3, + PolicyDenied = 4, + Exhausted = 5, + LocationConflict = 6, + CommitFailed = 7 + } + + public static class ExtractionItemUseService + { + public static bool TryConsumeForUse( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState raidInventory, + IExtractionItemCatalog itemCatalog, + string itemInstanceId, + string receiptId, + out string useActionId, + out ExtractionItemUseResult result) + { + useActionId = null; + result = ExtractionItemUseResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled + || profile == null + || itemCatalog == null + || string.IsNullOrEmpty(itemInstanceId) + || string.IsNullOrEmpty(receiptId)) + { + return false; + } + + profile.EnsureInitialized(); + if (!profile.Items.TryGet(itemInstanceId, out var item) + || !itemCatalog.TryGetItemDefinition(item.DefinitionId, out var definition)) + { + result = ExtractionItemUseResult.ItemNotFound; + return false; + } + + var policy = ExtractionItemActionPolicyService.GetPolicy(definition); + useActionId = policy?.UseActionId; + if (profile.ItemActionReceiptIds.Contains(receiptId)) + { + result = ExtractionItemUseResult.AlreadyApplied; + return true; + } + if (!ExtractionItemActionPolicyService.CanUse(definition)) + { + result = ExtractionItemUseResult.PolicyDenied; + return false; + } + + if (!TryFindOwnership(profile, itemInstanceId, out var entry)) + { + result = ExtractionItemUseResult.LocationConflict; + return false; + } + if (!IsUsableLocation(entry)) + { + result = ExtractionItemUseResult.LocationConflict; + return false; + } + + switch (policy.ConsumptionType) + { + case ExtractionItemConsumptionType.None: + break; + case ExtractionItemConsumptionType.Quantity: + if (item.Quantity <= 0) + { + result = ExtractionItemUseResult.Exhausted; + return false; + } + item.Quantity--; + if (item.Quantity == 0 + && !TryMoveToTerminal( + profile, + raidInventory, + item, + definition, + entry, + ExtractionInventoryContainerType.Consumed)) + { + item.Quantity++; + result = ExtractionItemUseResult.LocationConflict; + return false; + } + break; + case ExtractionItemConsumptionType.Durability: + if (item.CurrentDurability <= 0) + { + result = ExtractionItemUseResult.Exhausted; + return false; + } + item.CurrentDurability--; + if (item.CurrentDurability == 0 + && !TryMoveToTerminal( + profile, + raidInventory, + item, + definition, + entry, + ExtractionInventoryContainerType.DestroyedByUse)) + { + item.CurrentDurability++; + result = ExtractionItemUseResult.LocationConflict; + return false; + } + break; + case ExtractionItemConsumptionType.DestroyInstance: + if (!TryMoveToTerminal( + profile, + raidInventory, + item, + definition, + entry, + ExtractionInventoryContainerType.DestroyedByUse)) + { + result = ExtractionItemUseResult.LocationConflict; + return false; + } + break; + default: + return false; + } + + profile.ItemActionReceiptIds.Add(receiptId); + result = ExtractionItemUseResult.Succeeded; + return true; + } + + private static bool TryMoveToTerminal( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState raidInventory, + ExtractionItemInstance item, + ExtractionItemDefinition definition, + ExtractionOwnershipEntry entry, + ExtractionInventoryContainerType terminal) + { + Action restore = () => { }; + if (entry.Container == ExtractionInventoryContainerType.EquipmentSlot) + { + if (!ExtractionItemLocationService.TryGetEquipment( + profile, + raidInventory, + entry.LocationSubtype, + out var equipment) + || !equipment.TryClear(entry.LocationId, item.InstanceId)) + { + return false; + } + restore = () => equipment.TrySet(entry.LocationId, item.InstanceId); + } + else if (ExtractionItemLocationService.TryGetGrid( + profile, + raidInventory, + entry.Container, + out var grid)) + { + if (!grid.TryGetPlacement(item.InstanceId, out var placement) + || !grid.TryRemove(item.InstanceId)) + { + return false; + } + restore = () => grid.TryPlace(item, definition, placement.X, placement.Y, placement.Rotated); + } + + var source = entry.Container; + if (profile.Ownership.TryMove( + item.InstanceId, + source, + terminal, + "use", + null)) + { + return true; + } + + restore(); + return false; + } + + private static bool TryFindOwnership( + ExtractionProfileSaveData profile, + string itemInstanceId, + out ExtractionOwnershipEntry entry) + { + entry = null; + foreach (var candidate in profile.Ownership.Entries) + { + if (candidate?.ItemInstanceId != itemInstanceId) continue; + if (entry != null) return false; + entry = candidate; + } + return entry != null; + } + + private static bool IsUsableLocation(ExtractionOwnershipEntry entry) + { + if (entry == null) return false; + return entry.Container == ExtractionInventoryContainerType.Stash + || entry.Container == ExtractionInventoryContainerType.Loadout + || entry.Container == ExtractionInventoryContainerType.SecureContainer + || entry.Container == ExtractionInventoryContainerType.Holding + || entry.Container == ExtractionInventoryContainerType.InRaid + || entry.Container == ExtractionInventoryContainerType.RaidBackpack + || entry.Container == ExtractionInventoryContainerType.InSecureContainer + || entry.Container == ExtractionInventoryContainerType.EquipmentSlot; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemUseService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemUseService.cs.meta new file mode 100644 index 0000000..adc709b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionItemUseService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1e2b6e1dde954fa79fad1c18fe7a8e23 diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLeverService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLeverService.cs new file mode 100644 index 0000000..3b8b97c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLeverService.cs @@ -0,0 +1,225 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + public enum ExtractionLeverEffectType + { + ExtendRaidDeadlineSeconds = 0, + AddThreatLevel = 1, + UnlockBonusContainerGroup = 2, + AdvanceExtractionPointSeconds = 3, + SetRaidFlag = 4 + } + + [Serializable] + public class ExtractionLeverEffectDefinition + { + public ExtractionLeverEffectType EffectType; + public string TargetId; + public int Amount; + public int SafetyMinimumSeconds; + + public ExtractionLeverEffectDefinition( + ExtractionLeverEffectType effectType, + string targetId, + int amount, + int safetyMinimumSeconds = 0) + { + EffectType = effectType; + TargetId = targetId; + Amount = amount; + SafetyMinimumSeconds = safetyMinimumSeconds; + } + } + + [Serializable] + public class ExtractionLeverDefinition + { + public string LeverId; + public string MapId; + public bool SingleUse = true; + public string DisplayName; + public string Description; + public List Effects = new(); + + public ExtractionLeverDefinition(string leverId, string mapId) + { + LeverId = leverId; + MapId = mapId; + } + } + + [Serializable] + public class ExtractionPointRuntimeState + { + public string PointId; + public int EffectiveOpenAtElapsedSeconds; + + public ExtractionPointRuntimeState(string pointId, int effectiveOpenAtElapsedSeconds) + { + PointId = pointId; + EffectiveOpenAtElapsedSeconds = effectiveOpenAtElapsedSeconds; + } + } + + public enum ExtractionLeverActivationResult + { + Activated = 0, + AlreadyActivated = 1, + InvalidRequest = 2, + MissingDefinition = 3, + InvalidEffect = 4, + MissingTarget = 5 + } + + public static class ExtractionLeverService + { + public static bool TryActivate( + ExtractionProfileSaveData profile, + ExtractionPlayableConfig config, + string leverId, + out ExtractionLeverActivationResult result) + { + result = ExtractionLeverActivationResult.InvalidRequest; + var raid = profile?.ActiveRaid; + if (!ExtractionFeatureSwitch.Enabled + || raid == null + || config == null + || string.IsNullOrEmpty(leverId)) + { + return false; + } + if (raid.Content.ActivatedLeverIds.Contains(leverId)) + { + result = ExtractionLeverActivationResult.AlreadyActivated; + return true; + } + if (!TryGetLever(config, raid.MapId, leverId, out var definition)) + { + result = ExtractionLeverActivationResult.MissingDefinition; + return false; + } + + foreach (var effect in definition.Effects) + { + if (!CanApply(config, raid, effect, out result)) return false; + } + + foreach (var effect in definition.Effects) + Apply(config, raid, effect); + + raid.Content.ActivatedLeverIds.Add(leverId); + string receiptId = ExtractionReceiptId.Create( + ExtractionOperationId.Create("lever", raid.RaidId, leverId), + "lever-activated"); + if (!raid.Content.AppliedReceiptIds.Contains(receiptId)) + raid.Content.AppliedReceiptIds.Add(receiptId); + result = ExtractionLeverActivationResult.Activated; + return true; + } + + private static bool CanApply( + ExtractionPlayableConfig config, + ExtractionRaidSession raid, + ExtractionLeverEffectDefinition effect, + out ExtractionLeverActivationResult result) + { + result = ExtractionLeverActivationResult.InvalidEffect; + if (effect == null || !Enum.IsDefined(typeof(ExtractionLeverEffectType), effect.EffectType)) + return false; + + switch (effect.EffectType) + { + case ExtractionLeverEffectType.ExtendRaidDeadlineSeconds: + case ExtractionLeverEffectType.AddThreatLevel: + return effect.Amount > 0; + case ExtractionLeverEffectType.UnlockBonusContainerGroup: + if (string.IsNullOrEmpty(effect.TargetId)) return false; + if (raid.Content.LootManifest?.Containers == null) + { + result = ExtractionLeverActivationResult.MissingTarget; + return false; + } + foreach (var container in raid.Content.LootManifest.Containers) + if (container?.BonusGroupId == effect.TargetId) return true; + result = ExtractionLeverActivationResult.MissingTarget; + return false; + case ExtractionLeverEffectType.AdvanceExtractionPointSeconds: + if (effect.Amount <= 0 + || !config.TryGetExtractionPoint(effect.TargetId, raid.MapId, out _)) + { + result = ExtractionLeverActivationResult.MissingTarget; + return false; + } + return true; + case ExtractionLeverEffectType.SetRaidFlag: + return !string.IsNullOrEmpty(effect.TargetId); + default: + return false; + } + } + + private static void Apply( + ExtractionPlayableConfig config, + ExtractionRaidSession raid, + ExtractionLeverEffectDefinition effect) + { + switch (effect.EffectType) + { + case ExtractionLeverEffectType.ExtendRaidDeadlineSeconds: + raid.Content.DeadlineExtensionSeconds += effect.Amount; + break; + case ExtractionLeverEffectType.AddThreatLevel: + raid.Content.ThreatLevelDelta += effect.Amount; + break; + case ExtractionLeverEffectType.UnlockBonusContainerGroup: + if (!raid.Content.UnlockedBonusContainerGroupIds.Contains(effect.TargetId)) + raid.Content.UnlockedBonusContainerGroupIds.Add(effect.TargetId); + foreach (var container in raid.Content.LootManifest.Containers) + if (container?.BonusGroupId == effect.TargetId) container.Active = true; + break; + case ExtractionLeverEffectType.AdvanceExtractionPointSeconds: + config.TryGetExtractionPoint(effect.TargetId, raid.MapId, out var point); + var state = GetOrCreatePointState(raid.Content, point); + state.EffectiveOpenAtElapsedSeconds = Math.Max( + effect.SafetyMinimumSeconds, + state.EffectiveOpenAtElapsedSeconds - effect.Amount); + break; + case ExtractionLeverEffectType.SetRaidFlag: + raid.MarkRaidFlag(effect.TargetId); + break; + } + } + + internal static ExtractionPointRuntimeState GetOrCreatePointState( + ExtractionActiveRaidContentState content, + ExtractionPointDefinition point) + { + foreach (var state in content.ExtractionPointStates) + if (state != null && state.PointId == point.PointId) return state; + var created = new ExtractionPointRuntimeState(point.PointId, point.OpenAtElapsedSeconds); + content.ExtractionPointStates.Add(created); + return created; + } + + private static bool TryGetLever( + ExtractionPlayableConfig config, + string mapId, + string leverId, + out ExtractionLeverDefinition definition) + { + foreach (var candidate in config.LeverDefinitions) + { + if (candidate != null && candidate.LeverId == leverId && candidate.MapId == mapId) + { + definition = candidate; + return true; + } + } + + definition = null; + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLeverService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLeverService.cs.meta new file mode 100644 index 0000000..8a93014 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLeverService.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: fa93ccb8f53f4bb9befdfaaef6b46f74 +timeCreated: 1784385600 diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLockService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLockService.cs new file mode 100644 index 0000000..a2e8ed6 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLockService.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + [Serializable] + public class ExtractionLockDefinition + { + public string LockId; + public string MapId; + public List CompatibleKeyDefinitionIds = new(); + + public ExtractionLockDefinition(string lockId, string mapId) + { + LockId = lockId; + MapId = mapId; + } + } + + public enum ExtractionUnlockResult + { + Unlocked = 0, + AlreadyUnlocked = 1, + InvalidRequest = 2, + MissingLock = 3, + InvalidKeyLocation = 4, + IncompatibleKey = 5, + NoDurability = 6, + InvalidInventoryState = 7 + } + + public static class ExtractionLockService + { + public static bool TryUnlock( + ExtractionProfileSaveData profile, + ExtractionPlayableConfig config, + string lockId, + string keyItemInstanceId, + out ExtractionUnlockResult result) + { + result = ExtractionUnlockResult.InvalidRequest; + var raid = profile?.ActiveRaid; + if (!ExtractionFeatureSwitch.Enabled + || raid == null + || config == null + || string.IsNullOrEmpty(lockId) + || string.IsNullOrEmpty(keyItemInstanceId)) + return false; + if (raid.Content.OpenedLockIds.Contains(lockId)) + { + result = ExtractionUnlockResult.AlreadyUnlocked; + return true; + } + if (!TryGetLock(config, raid.MapId, lockId, out var definition)) + { + result = ExtractionUnlockResult.MissingLock; + return false; + } + if (!profile.Items.TryGet(keyItemInstanceId, out var key) + || !config.TryGetItemDefinition(key.DefinitionId, out var keyDefinition)) + { + return false; + } + if (!profile.Ownership.TryGetContainer(keyItemInstanceId, out var location) + || (location != ExtractionInventoryContainerType.RaidBackpack + && location != ExtractionInventoryContainerType.InSecureContainer)) + { + result = ExtractionUnlockResult.InvalidKeyLocation; + return false; + } + if (!IsCompatible(definition, keyDefinition)) + { + result = ExtractionUnlockResult.IncompatibleKey; + return false; + } + if (keyDefinition.MaxDurability <= 0 || key.CurrentDurability <= 0) + { + result = ExtractionUnlockResult.NoDurability; + return false; + } + + key.CurrentDurability--; + if (key.CurrentDurability == 0 && !TryDestroySpentKey(profile, keyItemInstanceId, location)) + { + key.CurrentDurability++; + result = ExtractionUnlockResult.InvalidInventoryState; + return false; + } + + raid.Content.OpenedLockIds.Add(lockId); + string receiptId = ExtractionReceiptId.Create( + ExtractionOperationId.Create("unlock", raid.RaidId, lockId, keyItemInstanceId), + "lock-opened"); + if (!raid.Content.AppliedReceiptIds.Contains(receiptId)) + raid.Content.AppliedReceiptIds.Add(receiptId); + result = ExtractionUnlockResult.Unlocked; + return true; + } + + private static bool TryDestroySpentKey( + ExtractionProfileSaveData profile, + string itemInstanceId, + ExtractionInventoryContainerType source) + { + ExtractionItemGrid grid = source == ExtractionInventoryContainerType.RaidBackpack + ? profile.ActiveRaidInventory?.RaidBackpack + : profile.ActiveRaidInventory?.SecureContainer; + if (grid == null) return false; + if (!profile.Ownership.TryMove( + itemInstanceId, + source, + ExtractionInventoryContainerType.DestroyedByUse)) + { + return false; + } + + if (grid.TryRemove(itemInstanceId)) return true; + profile.Ownership.TryMove( + itemInstanceId, + ExtractionInventoryContainerType.DestroyedByUse, + source); + return false; + } + + private static bool IsCompatible( + ExtractionLockDefinition lockDefinition, + ExtractionItemDefinition keyDefinition) + { + bool keyListsLock = keyDefinition.CompatibleTargetIds != null + && keyDefinition.CompatibleTargetIds.Contains(lockDefinition.LockId); + bool lockListsKey = lockDefinition.CompatibleKeyDefinitionIds != null + && lockDefinition.CompatibleKeyDefinitionIds.Contains(keyDefinition.DefinitionId); + return keyListsLock || lockListsKey; + } + + private static bool TryGetLock( + ExtractionPlayableConfig config, + string mapId, + string lockId, + out ExtractionLockDefinition definition) + { + foreach (var candidate in config.LockDefinitions) + { + if (candidate != null && candidate.LockId == lockId && candidate.MapId == mapId) + { + definition = candidate; + return true; + } + } + + definition = null; + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLockService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLockService.cs.meta new file mode 100644 index 0000000..df37cd9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLockService.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a46b9c12d3344979985eb0b6f76ab1ad +timeCreated: 1784385600 diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootContentConfigValidator.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootContentConfigValidator.cs new file mode 100644 index 0000000..5bdcb3b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootContentConfigValidator.cs @@ -0,0 +1,737 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + public sealed class ExtractionLootContentValidationReport + { + public readonly List Errors = new(); + public readonly List Warnings = new(); + + public bool IsValid => Errors.Count == 0; + public string FirstError => Errors.Count > 0 ? Errors[0] : null; + + internal void AddError(string message) + { + Errors.Add(message); + } + + internal void AddWarning(string message) + { + Warnings.Add(message); + } + } + + public static class ExtractionLootContentConfigValidator + { + private static readonly ExtractionItemRarity[] Rarities = + { + ExtractionItemRarity.Common, + ExtractionItemRarity.Uncommon, + ExtractionItemRarity.Rare, + ExtractionItemRarity.Epic, + ExtractionItemRarity.Legendary, + ExtractionItemRarity.Mythic + }; + + public static ExtractionLootContentValidationReport Validate(ExtractionPlayableConfig config) + { + var report = new ExtractionLootContentValidationReport(); + if (config == null) + { + report.AddError("搜打撤内容配置为空。"); + return report; + } + + if (!HasAnyContentConfiguration(config)) + return report; + + if (!HasRequiredLists(config, report)) + return report; + + var items = CollectItems(config, report); + var tables = CollectLootTables(config, report); + var tiers = CollectContentTiers(config, report); + var profiles = CollectLootProfiles(config, report); + var regions = CollectRegions(config, report); + var containers = CollectContainers(config, report); + var spawns = CollectSpawns(config, report); + if (!report.IsValid) + return report; + + ValidateMapReferences(config, tiers, profiles, report); + ValidateProfileReferences(profiles, tiers, regions, report); + ValidateRegionReferences(regions, containers, spawns, report); + ValidateSpawnReferences(spawns, regions, containers, report); + ValidateContainerTables(containers, tables, items, report); + if (!report.IsValid) + return report; + + ValidateProfileCapacityAndAvailability(profiles, regions, spawns, containers, tables, items, report); + return report; + } + + public static bool HasAnyContentConfiguration(ExtractionPlayableConfig config) + { + if (config == null) return false; + if (HasEntries(config.ContentTiers) + || HasEntries(config.LootProfiles) + || HasEntries(config.LootRegions) + || HasEntries(config.ContainerDefinitions) + || HasEntries(config.ContainerSpawns)) + { + return true; + } + + if (config.Maps == null) return false; + foreach (var map in config.Maps) + { + if (map == null) continue; + if (!string.IsNullOrEmpty(map.ContentTierId) || !string.IsNullOrEmpty(map.LootProfileId)) + return true; + } + + return false; + } + + private static bool HasRequiredLists( + ExtractionPlayableConfig config, + ExtractionLootContentValidationReport report) + { + bool valid = true; + valid &= RequireNonEmpty(config.ContentTiers, "内容档列表", report); + valid &= RequireNonEmpty(config.LootProfiles, "掉落配置列表", report); + valid &= RequireNonEmpty(config.LootRegions, "掉落区域列表", report); + valid &= RequireNonEmpty(config.ContainerDefinitions, "容器定义列表", report); + valid &= RequireNonEmpty(config.ContainerSpawns, "容器生成点列表", report); + return valid; + } + + private static Dictionary CollectItems( + ExtractionPlayableConfig config, + ExtractionLootContentValidationReport report) + { + var result = new Dictionary(); + if (config.ItemDefinitions == null) + { + report.AddError("物品定义列表为空。"); + return result; + } + + foreach (var item in config.ItemDefinitions) + { + if (item == null || IsBlank(item.DefinitionId) || !Enum.IsDefined(typeof(ExtractionItemRarity), item.Rarity)) + { + report.AddError("内容配置包含无效物品定义或品质。"); + continue; + } + + if (result.ContainsKey(item.DefinitionId)) + report.AddError($"物品定义 ID '{item.DefinitionId}' 重复。"); + else + result.Add(item.DefinitionId, item); + } + + return result; + } + + private static Dictionary CollectLootTables( + ExtractionPlayableConfig config, + ExtractionLootContentValidationReport report) + { + var result = new Dictionary(); + if (config.LootTables == null) + { + report.AddError("掉落表列表为空。"); + return result; + } + + foreach (var table in config.LootTables) + { + if (table == null || IsBlank(table.TableId) || table.Entries == null || table.Entries.Count == 0) + { + report.AddError("内容配置包含无效掉落表。"); + continue; + } + + if (result.ContainsKey(table.TableId)) + report.AddError($"掉落表 ID '{table.TableId}' 重复。"); + else + result.Add(table.TableId, table); + } + + return result; + } + + private static Dictionary CollectContentTiers( + ExtractionPlayableConfig config, + ExtractionLootContentValidationReport report) + { + var result = new Dictionary(); + foreach (var tier in config.ContentTiers) + { + if (tier == null || IsBlank(tier.ContentTierId)) + { + report.AddError("内容档定义为空或 ID 无效。"); + continue; + } + + if (!ValidatePositiveCurve(tier.RarityWeightMultipliers)) + report.AddError($"内容档 '{tier.ContentTierId}' 的六档品质倍率必须全部大于 0。"); + else if (IsUniform(tier.RarityWeightMultipliers)) + report.AddWarning($"内容档 '{tier.ContentTierId}' 的六档品质曲线是统一倍率,不会改变归一化后的掉落概率。"); + + if (result.ContainsKey(tier.ContentTierId)) + report.AddError($"内容档 ID '{tier.ContentTierId}' 重复。"); + else + result.Add(tier.ContentTierId, tier); + } + + return result; + } + + private static Dictionary CollectLootProfiles( + ExtractionPlayableConfig config, + ExtractionLootContentValidationReport report) + { + var result = new Dictionary(); + foreach (var profile in config.LootProfiles) + { + if (profile == null || IsBlank(profile.LootProfileId) || IsBlank(profile.ContentTierId)) + { + report.AddError("掉落配置为空,或 LootProfileId/ContentTierId 无效。"); + continue; + } + + if (!ValidateMinimumDrops(profile.MinimumGeneratedDropsByRarity)) + report.AddError($"掉落配置 '{profile.LootProfileId}' 的六档最低生成数必须全部大于等于 0。"); + if (!ValidatePity(profile.Pity)) + report.AddError($"掉落配置 '{profile.LootProfileId}' 的珍品保底参数无效。"); + if (!ValidateRevealTimes(profile.RevealTimes)) + report.AddError($"掉落配置 '{profile.LootProfileId}' 的六档显现时间必须全部大于 0 且严格递增。"); + if (profile.RegionIds == null || profile.RegionIds.Count == 0 || HasBlankOrDuplicate(profile.RegionIds)) + report.AddError($"掉落配置 '{profile.LootProfileId}' 必须引用至少一个且不重复的区域 ID。"); + + if (result.ContainsKey(profile.LootProfileId)) + report.AddError($"掉落配置 ID '{profile.LootProfileId}' 重复。"); + else + result.Add(profile.LootProfileId, profile); + } + + return result; + } + + private static Dictionary CollectRegions( + ExtractionPlayableConfig config, + ExtractionLootContentValidationReport report) + { + var result = new Dictionary(); + foreach (var region in config.LootRegions) + { + if (region == null || IsBlank(region.RegionId)) + { + report.AddError("掉落区域定义为空或区域 ID 无效。"); + continue; + } + + if (!ValidatePositiveCurve(region.RarityWeightMultipliers)) + report.AddError($"区域 '{region.RegionId}' 的六档品质倍率必须全部大于 0。"); + else if (IsUniform(region.RarityWeightMultipliers)) + report.AddWarning($"区域 '{region.RegionId}' 的六档品质曲线是统一倍率,不会改变归一化后的掉落概率。"); + if (region.AllowedContainerTypeIds == null + || region.AllowedContainerTypeIds.Count == 0 + || HasBlankOrDuplicate(region.AllowedContainerTypeIds)) + { + report.AddError($"区域 '{region.RegionId}' 必须配置至少一个且不重复的允许容器类型。"); + } + if (region.ContainerSpawnIds == null + || region.ContainerSpawnIds.Count == 0 + || HasBlankOrDuplicate(region.ContainerSpawnIds)) + { + report.AddError($"区域 '{region.RegionId}' 必须配置至少一个且不重复的容器生成点。"); + } + + if (result.ContainsKey(region.RegionId)) + report.AddError($"区域 ID '{region.RegionId}' 重复。"); + else + result.Add(region.RegionId, region); + } + + return result; + } + + private static Dictionary CollectContainers( + ExtractionPlayableConfig config, + ExtractionLootContentValidationReport report) + { + var result = new Dictionary(); + foreach (var container in config.ContainerDefinitions) + { + if (container == null || IsBlank(container.ContainerTypeId)) + { + report.AddError("容器定义为空或容器类型 ID 无效。"); + continue; + } + + if (container.Capacity <= 0 + || container.MinimumContentCount <= 0 + || container.MaximumContentCount < container.MinimumContentCount + || container.MaximumContentCount > container.Capacity) + { + report.AddError($"容器 '{container.ContainerTypeId}' 的内容数量区间必须为正、min<=max,且 max 不得超过容量。"); + } + if (container.SearchTimeMultiplier <= 0f) + report.AddError($"容器 '{container.ContainerTypeId}' 的搜索时间倍率必须大于 0。"); + if (container.LootTableIds == null + || container.LootTableIds.Count == 0 + || HasBlankOrDuplicate(container.LootTableIds)) + { + report.AddError($"容器 '{container.ContainerTypeId}' 必须引用至少一个且不重复的掉落表。"); + } + + if (result.ContainsKey(container.ContainerTypeId)) + report.AddError($"容器类型 ID '{container.ContainerTypeId}' 重复。"); + else + result.Add(container.ContainerTypeId, container); + } + + return result; + } + + private static Dictionary CollectSpawns( + ExtractionPlayableConfig config, + ExtractionLootContentValidationReport report) + { + var result = new Dictionary(); + foreach (var spawn in config.ContainerSpawns) + { + if (spawn == null || IsBlank(spawn.SpawnId) || IsBlank(spawn.RegionId)) + { + report.AddError("容器生成点为空,或 SpawnId/RegionId 无效。"); + continue; + } + + if (spawn.Always == spawn.ChancePerRaid) + report.AddError($"容器生成点 '{spawn.SpawnId}' 必须且只能启用 Always 与 ChancePerRaid 其中一种模式。"); + else if (spawn.ChancePerRaid && (spawn.Chance <= 0f || spawn.Chance > 1f)) + report.AddError($"容器生成点 '{spawn.SpawnId}' 的 ChancePerRaid 出现率必须在 (0, 1] 内。"); + else if (spawn.Always && Math.Abs(spawn.Chance - 1f) > 0.0001f) + report.AddError($"固定容器生成点 '{spawn.SpawnId}' 的出现率必须为 1。"); + + if (spawn.Candidates == null || spawn.Candidates.Count == 0) + { + report.AddError($"容器生成点 '{spawn.SpawnId}' 至少需要一个候选容器类型。"); + } + else + { + var candidateIds = new HashSet(); + foreach (var candidate in spawn.Candidates) + { + if (candidate == null || IsBlank(candidate.ContainerTypeId) || candidate.Weight <= 0) + { + report.AddError($"容器生成点 '{spawn.SpawnId}' 包含无效候选或非正权重。"); + continue; + } + if (!candidateIds.Add(candidate.ContainerTypeId)) + report.AddError($"容器生成点 '{spawn.SpawnId}' 重复引用候选容器 '{candidate.ContainerTypeId}'。"); + } + } + + if (result.ContainsKey(spawn.SpawnId)) + report.AddError($"容器生成点 ID '{spawn.SpawnId}' 重复。"); + else + result.Add(spawn.SpawnId, spawn); + } + + return result; + } + + private static void ValidateMapReferences( + ExtractionPlayableConfig config, + Dictionary tiers, + Dictionary profiles, + ExtractionLootContentValidationReport report) + { + if (config.Maps == null) return; + foreach (var map in config.Maps) + { + if (map == null) continue; + bool hasTier = !IsBlank(map.ContentTierId); + bool hasProfile = !IsBlank(map.LootProfileId); + if (!hasTier && !hasProfile) continue; + if (!hasTier || !hasProfile) + { + report.AddError($"地图 '{map.MapId}' 必须同时配置 ContentTierId 与 LootProfileId。"); + continue; + } + if (!tiers.ContainsKey(map.ContentTierId)) + report.AddError($"地图 '{map.MapId}' 引用了不存在的内容档 '{map.ContentTierId}'。"); + if (!profiles.TryGetValue(map.LootProfileId, out var profile)) + report.AddError($"地图 '{map.MapId}' 引用了不存在的掉落配置 '{map.LootProfileId}'。"); + else if (profile.ContentTierId != map.ContentTierId) + report.AddError($"地图 '{map.MapId}' 的内容档与掉落配置所属内容档不一致。"); + } + } + + private static void ValidateProfileReferences( + Dictionary profiles, + Dictionary tiers, + Dictionary regions, + ExtractionLootContentValidationReport report) + { + foreach (var pair in profiles) + { + var profile = pair.Value; + if (!tiers.ContainsKey(profile.ContentTierId)) + report.AddError($"掉落配置 '{profile.LootProfileId}' 引用了不存在的内容档 '{profile.ContentTierId}'。"); + if (profile.RegionIds == null) continue; + foreach (var regionId in profile.RegionIds) + { + if (!regions.ContainsKey(regionId)) + report.AddError($"掉落配置 '{profile.LootProfileId}' 引用了不存在的区域 '{regionId}'。"); + } + } + } + + private static void ValidateRegionReferences( + Dictionary regions, + Dictionary containers, + Dictionary spawns, + ExtractionLootContentValidationReport report) + { + foreach (var pair in regions) + { + var region = pair.Value; + if (region.AllowedContainerTypeIds != null) + { + foreach (var containerId in region.AllowedContainerTypeIds) + { + if (!containers.ContainsKey(containerId)) + report.AddError($"区域 '{region.RegionId}' 引用了不存在的容器类型 '{containerId}'。"); + } + } + if (region.ContainerSpawnIds == null) continue; + foreach (var spawnId in region.ContainerSpawnIds) + { + if (!spawns.TryGetValue(spawnId, out var spawn)) + report.AddError($"区域 '{region.RegionId}' 引用了不存在的容器生成点 '{spawnId}'。"); + else if (spawn.RegionId != region.RegionId) + report.AddError($"容器生成点 '{spawnId}' 的 RegionId 与区域 '{region.RegionId}' 不一致。"); + } + } + } + + private static void ValidateSpawnReferences( + Dictionary spawns, + Dictionary regions, + Dictionary containers, + ExtractionLootContentValidationReport report) + { + foreach (var pair in spawns) + { + var spawn = pair.Value; + if (!regions.TryGetValue(spawn.RegionId, out var region)) + { + report.AddError($"容器生成点 '{spawn.SpawnId}' 引用了不存在的区域 '{spawn.RegionId}'。"); + continue; + } + if (region.ContainerSpawnIds == null || !region.ContainerSpawnIds.Contains(spawn.SpawnId)) + report.AddError($"容器生成点 '{spawn.SpawnId}' 未列入所属区域 '{spawn.RegionId}' 的生成点列表。"); + if (spawn.Candidates == null) continue; + foreach (var candidate in spawn.Candidates) + { + if (candidate == null) continue; + if (!containers.ContainsKey(candidate.ContainerTypeId)) + report.AddError($"容器生成点 '{spawn.SpawnId}' 引用了不存在的容器类型 '{candidate.ContainerTypeId}'。"); + else if (region.AllowedContainerTypeIds == null + || !region.AllowedContainerTypeIds.Contains(candidate.ContainerTypeId)) + { + report.AddError($"容器生成点 '{spawn.SpawnId}' 的候选容器 '{candidate.ContainerTypeId}' 不在区域允许集合内。"); + } + } + } + } + + private static void ValidateContainerTables( + Dictionary containers, + Dictionary tables, + Dictionary items, + ExtractionLootContentValidationReport report) + { + foreach (var pair in containers) + { + var container = pair.Value; + bool hasValidItem = false; + if (container.LootTableIds == null) continue; + foreach (var tableId in container.LootTableIds) + { + if (!tables.TryGetValue(tableId, out var table)) + { + report.AddError($"容器 '{container.ContainerTypeId}' 引用了不存在的掉落表 '{tableId}'。"); + continue; + } + hasValidItem |= HasAnyValidItem(table, items, null, false); + } + + if (!hasValidItem) + report.AddError($"容器 '{container.ContainerTypeId}' 的掉落表没有合法物品候选。"); + } + } + + private static void ValidateProfileCapacityAndAvailability( + Dictionary profiles, + Dictionary regions, + Dictionary spawns, + Dictionary containers, + Dictionary tables, + Dictionary items, + ExtractionLootContentValidationReport report) + { + foreach (var pair in profiles) + { + var profile = pair.Value; + var allContainers = new HashSet(); + var guaranteedSpawns = new List(); + CollectProfileContainers(profile, regions, spawns, allContainers, guaranteedSpawns); + + foreach (var containerId in allContainers) + { + if (!containers.TryGetValue(containerId, out var container)) continue; + if (!HasReachableItem(container, tables, items, null, true)) + { + report.AddError( + $"容器 '{containerId}' 在关闭珍品后没有非珍品候选;禁止回退到珍品表。"); + return; + } + } + + foreach (var rarity in Rarities) + { + int minimum = profile.MinimumGeneratedDropsByRarity.Get(rarity); + if (minimum <= 0) continue; + int rarityCapacity = GetMinimumGuaranteedCapacityForRarity( + guaranteedSpawns, + containers, + tables, + items, + rarity); + if (minimum > rarityCapacity) + { + report.AddError( + $"掉落配置 '{profile.LootProfileId}' 为 {rarity} 配置了最低生成数 {minimum}," + + $"但固定容器在所有候选结果下只能承载 {rarityCapacity} 个该品质合法物品。"); + return; + } + } + + int required = GetTotalMinimumDrops(profile.MinimumGeneratedDropsByRarity); + int guaranteedCapacity = GetMinimumGuaranteedCapacity(guaranteedSpawns, containers); + if (required > guaranteedCapacity) + { + report.AddError( + $"掉落配置 '{profile.LootProfileId}' 的最低生成数合计 {required} 超过固定容器容量 {guaranteedCapacity}。"); + return; + } + } + } + + private static void CollectProfileContainers( + ExtractionLootProfileDefinition profile, + Dictionary regions, + Dictionary spawns, + HashSet allContainers, + List guaranteedSpawns) + { + if (profile.RegionIds == null) return; + foreach (var regionId in profile.RegionIds) + { + if (!regions.TryGetValue(regionId, out var region) || region.ContainerSpawnIds == null) continue; + foreach (var spawnId in region.ContainerSpawnIds) + { + if (!spawns.TryGetValue(spawnId, out var spawn) || spawn.Candidates == null) continue; + if (spawn.Always) guaranteedSpawns.Add(spawn); + foreach (var candidate in spawn.Candidates) + { + if (candidate != null) allContainers.Add(candidate.ContainerTypeId); + } + } + } + } + + private static int GetMinimumGuaranteedCapacityForRarity( + List guaranteedSpawns, + Dictionary containers, + Dictionary tables, + Dictionary items, + ExtractionItemRarity rarity) + { + int total = 0; + foreach (var spawn in guaranteedSpawns) + { + int minimum = int.MaxValue; + foreach (var candidate in spawn.Candidates) + { + if (candidate == null || !containers.TryGetValue(candidate.ContainerTypeId, out var container)) + { + minimum = 0; + break; + } + if (!HasReachableItem(container, tables, items, rarity, false)) + { + minimum = 0; + break; + } + minimum = Math.Min(minimum, container.MaximumContentCount); + } + if (minimum != int.MaxValue) total += minimum; + } + + return total; + } + + private static int GetMinimumGuaranteedCapacity( + List guaranteedSpawns, + Dictionary containers) + { + int total = 0; + foreach (var spawn in guaranteedSpawns) + { + int minimum = int.MaxValue; + foreach (var candidate in spawn.Candidates) + { + if (candidate == null || !containers.TryGetValue(candidate.ContainerTypeId, out var container)) + continue; + minimum = Math.Min(minimum, container.MaximumContentCount); + } + if (minimum != int.MaxValue) total += minimum; + } + return total; + } + + private static bool HasReachableItem( + ExtractionContainerDefinition container, + Dictionary tables, + Dictionary items, + ExtractionItemRarity? requiredRarity, + bool nonRareOnly) + { + if (container.LootTableIds == null) return false; + foreach (var tableId in container.LootTableIds) + { + if (!tables.TryGetValue(tableId, out var table)) continue; + if (nonRareOnly && table.IsRare) continue; + if (HasAnyValidItem(table, items, requiredRarity, nonRareOnly)) return true; + } + return false; + } + + private static bool HasAnyValidItem( + ExtractionLootTableDefinition table, + Dictionary items, + ExtractionItemRarity? requiredRarity, + bool nonRareOnly) + { + if (table == null || table.Entries == null) return false; + foreach (var entry in table.Entries) + { + if (entry == null || entry.Weight <= 0 || entry.Quantity <= 0) continue; + if (!items.TryGetValue(entry.DefinitionId, out var item)) continue; + if (requiredRarity.HasValue && item.Rarity != requiredRarity.Value) continue; + if (nonRareOnly && (int)item.Rarity >= (int)ExtractionItemRarity.Rare) continue; + return true; + } + return false; + } + + private static bool ValidateMinimumDrops(ExtractionRarityIntValues values) + { + if (values == null) return false; + foreach (var rarity in Rarities) + { + if (values.Get(rarity) < 0) return false; + } + return true; + } + + private static bool ValidatePity(ExtractionLootPityDefinition pity) + { + return pity != null + && Enum.IsDefined(typeof(ExtractionItemRarity), pity.TargetRarity) + && (int)pity.TargetRarity >= (int)ExtractionItemRarity.Rare + && pity.WeightMultiplierIncrementPerMiss > 0f + && pity.MaximumWeightMultiplier > 1f; + } + + private static bool ValidateRevealTimes(ExtractionRevealTimeDefinition revealTimes) + { + var values = revealTimes?.BaseRevealSeconds; + if (!ValidatePositiveCurve(values)) return false; + float previous = 0f; + foreach (var rarity in Rarities) + { + float current = values.Get(rarity); + if (current <= previous) return false; + previous = current; + } + return true; + } + + private static bool ValidatePositiveCurve(ExtractionRarityFloatValues values) + { + if (values == null) return false; + foreach (var rarity in Rarities) + { + if (values.Get(rarity) <= 0f) return false; + } + return true; + } + + private static bool IsUniform(ExtractionRarityFloatValues values) + { + float common = values.Common; + foreach (var rarity in Rarities) + { + if (Math.Abs(values.Get(rarity) - common) > 0.0001f) return false; + } + return true; + } + + private static int GetTotalMinimumDrops(ExtractionRarityIntValues values) + { + int total = 0; + foreach (var rarity in Rarities) + total += values.Get(rarity); + return total; + } + + private static bool HasBlankOrDuplicate(List values) + { + var ids = new HashSet(); + foreach (var value in values) + { + if (IsBlank(value) || !ids.Add(value)) return true; + } + return false; + } + + private static bool RequireNonEmpty( + List values, + string label, + ExtractionLootContentValidationReport report) + { + if (values != null && values.Count > 0) return true; + report.AddError($"{label}为空;新内容配置必须完整声明 ContentTier、LootProfile、Region、ContainerDefinition 与 ContainerSpawn。"); + return false; + } + + private static bool HasEntries(List values) + { + return values != null && values.Count > 0; + } + + private static bool IsBlank(string value) + { + return string.IsNullOrWhiteSpace(value); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootContentConfigValidator.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootContentConfigValidator.cs.meta new file mode 100644 index 0000000..98acdf6 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootContentConfigValidator.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b64769cb591643a2b913833169b3a9e8 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootContentDefinitions.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootContentDefinitions.cs new file mode 100644 index 0000000..9a1fc91 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootContentDefinitions.cs @@ -0,0 +1,267 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + [Serializable] + public class ExtractionRarityFloatValues + { + public float Common; + public float Uncommon; + public float Rare; + public float Epic; + public float Legendary; + public float Mythic; + + public ExtractionRarityFloatValues() + { + } + + public ExtractionRarityFloatValues( + float common, + float uncommon, + float rare, + float epic, + float legendary, + float mythic) + { + Common = common; + Uncommon = uncommon; + Rare = rare; + Epic = epic; + Legendary = legendary; + Mythic = mythic; + } + + public float Get(ExtractionItemRarity rarity) + { + switch (rarity) + { + case ExtractionItemRarity.Common: return Common; + case ExtractionItemRarity.Uncommon: return Uncommon; + case ExtractionItemRarity.Rare: return Rare; + case ExtractionItemRarity.Epic: return Epic; + case ExtractionItemRarity.Legendary: return Legendary; + case ExtractionItemRarity.Mythic: return Mythic; + default: return 0f; + } + } + } + + [Serializable] + public class ExtractionRarityIntValues + { + public int Common; + public int Uncommon; + public int Rare; + public int Epic; + public int Legendary; + public int Mythic; + + public ExtractionRarityIntValues() + { + } + + public ExtractionRarityIntValues( + int common, + int uncommon, + int rare, + int epic, + int legendary, + int mythic) + { + Common = common; + Uncommon = uncommon; + Rare = rare; + Epic = epic; + Legendary = legendary; + Mythic = mythic; + } + + public int Get(ExtractionItemRarity rarity) + { + switch (rarity) + { + case ExtractionItemRarity.Common: return Common; + case ExtractionItemRarity.Uncommon: return Uncommon; + case ExtractionItemRarity.Rare: return Rare; + case ExtractionItemRarity.Epic: return Epic; + case ExtractionItemRarity.Legendary: return Legendary; + case ExtractionItemRarity.Mythic: return Mythic; + default: return 0; + } + } + } + + [Serializable] + public class ExtractionContentTierDefinition + { + public string ContentTierId; + public ExtractionRarityFloatValues RarityWeightMultipliers; + + public ExtractionContentTierDefinition( + string contentTierId, + ExtractionRarityFloatValues rarityWeightMultipliers) + { + ContentTierId = contentTierId; + RarityWeightMultipliers = rarityWeightMultipliers; + } + } + + [Serializable] + public class ExtractionLootPityDefinition + { + public ExtractionItemRarity TargetRarity; + public float WeightMultiplierIncrementPerMiss; + public float MaximumWeightMultiplier; + + public ExtractionLootPityDefinition( + ExtractionItemRarity targetRarity, + float weightMultiplierIncrementPerMiss, + float maximumWeightMultiplier) + { + TargetRarity = targetRarity; + WeightMultiplierIncrementPerMiss = weightMultiplierIncrementPerMiss; + MaximumWeightMultiplier = maximumWeightMultiplier; + } + } + + [Serializable] + public class ExtractionRevealTimeDefinition + { + public ExtractionRarityFloatValues BaseRevealSeconds; + + public ExtractionRevealTimeDefinition(ExtractionRarityFloatValues baseRevealSeconds) + { + BaseRevealSeconds = baseRevealSeconds; + } + } + + [Serializable] + public class ExtractionLootProfileDefinition + { + public string LootProfileId; + public string ContentTierId; + public ExtractionRarityIntValues MinimumGeneratedDropsByRarity; + public ExtractionLootPityDefinition Pity; + public ExtractionRevealTimeDefinition RevealTimes; + public List RegionIds = new(); + + public ExtractionLootProfileDefinition(string lootProfileId, string contentTierId) + { + LootProfileId = lootProfileId; + ContentTierId = contentTierId; + } + } + + [Serializable] + public class ExtractionLootRegionDefinition + { + public string RegionId; + public ExtractionRarityFloatValues RarityWeightMultipliers; + public List AllowedContainerTypeIds = new(); + public List ContainerSpawnIds = new(); + + public ExtractionLootRegionDefinition( + string regionId, + ExtractionRarityFloatValues rarityWeightMultipliers) + { + RegionId = regionId; + RarityWeightMultipliers = rarityWeightMultipliers; + } + } + + [Serializable] + public class ExtractionContainerDefinition + { + public string ContainerTypeId; + public int Capacity; + public int MinimumContentCount; + public int MaximumContentCount; + public float SearchTimeMultiplier; + public List LootTableIds = new(); + + public ExtractionContainerDefinition( + string containerTypeId, + int capacity, + int minimumContentCount, + int maximumContentCount, + float searchTimeMultiplier) + { + ContainerTypeId = containerTypeId; + Capacity = capacity; + MinimumContentCount = minimumContentCount; + MaximumContentCount = maximumContentCount; + SearchTimeMultiplier = searchTimeMultiplier; + } + } + + [Serializable] + public class ExtractionWeightedContainerCandidate + { + public string ContainerTypeId; + public int Weight; + + public ExtractionWeightedContainerCandidate(string containerTypeId, int weight) + { + ContainerTypeId = containerTypeId; + Weight = weight; + } + } + + [Serializable] + public class ExtractionContainerSpawnDefinition + { + public string SpawnId; + public string RegionId; + public List Candidates = new(); + public bool Always; + public bool ChancePerRaid; + public float Chance; + public string BonusGroupId; + + public ExtractionContainerSpawnDefinition( + string spawnId, + string regionId, + bool always, + bool chancePerRaid, + float chance) + { + SpawnId = spawnId; + RegionId = regionId; + Always = always; + ChancePerRaid = chancePerRaid; + Chance = chance; + } + } + + public static class ExtractionLootContentPolicy + { + public static bool IsRarityEnabled(ExtractionItemRarity rarity, bool rareLootDisabled) + { + return !rareLootDisabled || (int)rarity < (int)ExtractionItemRarity.Rare; + } + + public static bool IsLootTableEnabled(ExtractionLootTableDefinition table, bool rareLootDisabled) + { + return table != null && (!rareLootDisabled || !table.IsRare); + } + + public static int GetMinimumGeneratedDrops( + ExtractionLootProfileDefinition profile, + ExtractionItemRarity rarity, + bool rareLootDisabled) + { + if (profile == null || profile.MinimumGeneratedDropsByRarity == null) + return 0; + return IsRarityEnabled(rarity, rareLootDisabled) + ? profile.MinimumGeneratedDropsByRarity.Get(rarity) + : 0; + } + + public static bool IsPityEnabled(ExtractionLootProfileDefinition profile, bool rareLootDisabled) + { + return !rareLootDisabled && profile != null && profile.Pity != null; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootContentDefinitions.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootContentDefinitions.cs.meta new file mode 100644 index 0000000..cae7e15 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootContentDefinitions.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 77da654288284254862ba7b733acb41e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootPickup.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootPickup.cs new file mode 100644 index 0000000..b74a4cb --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootPickup.cs @@ -0,0 +1,26 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionLootPickup + { + public ExtractionItemInstance Item; + public ExtractionItemDefinition Definition; + public bool CanEnterSecureContainer; + + public bool IsValid => Item != null && Definition != null && !string.IsNullOrEmpty(Item.InstanceId); + + public ExtractionLootPickup( + ExtractionItemInstance item, + ExtractionItemDefinition definition, + bool canEnterSecureContainer) + { + Item = item; + Definition = definition; + CanEnterSecureContainer = canEnterSecureContainer; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootPickup.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootPickup.cs.meta new file mode 100644 index 0000000..b31883c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootPickup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 880a35bf47994384a770aaca91797f7a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootPickupService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootPickupService.cs new file mode 100644 index 0000000..185d009 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootPickupService.cs @@ -0,0 +1,72 @@ +namespace POB.Extraction +{ + public static class ExtractionLootPickupService + { + public static bool TryPickup( + ExtractionProfileSaveData profile, + ExtractionRaidInventoryState inventory, + ExtractionLootPickup pickup, + ExtractionPickupTarget target, + int x, + int y, + bool rotated) + { + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || inventory == null || pickup == null || !pickup.IsValid) return false; + if (profile.ActiveRaid == null || string.IsNullOrEmpty(profile.activeRaidId)) return false; + + if (!TryResolveTarget( + inventory, + pickup, + target, + out var grid, + out var container)) + { + return false; + } + + profile.EnsureInitialized(); + + if (!grid.TryPlace(pickup.Item, pickup.Definition, x, y, rotated)) return false; + if (!profile.Ownership.Register(pickup.Item.InstanceId, container)) + { + grid.TryRemove(pickup.Item.InstanceId); + return false; + } + + if (profile.Items.Register(pickup.Item)) return true; + + profile.Ownership.TryRemove(pickup.Item.InstanceId); + grid.TryRemove(pickup.Item.InstanceId); + return false; + } + + private static bool TryResolveTarget( + ExtractionRaidInventoryState inventory, + ExtractionLootPickup pickup, + ExtractionPickupTarget target, + out ExtractionItemGrid grid, + out ExtractionInventoryContainerType container) + { + if (target == ExtractionPickupTarget.RaidBackpack) + { + grid = inventory.RaidBackpack; + container = ExtractionInventoryContainerType.RaidBackpack; + return grid != null; + } + + if (target == ExtractionPickupTarget.SecureContainer) + { + grid = inventory.SecureContainer; + container = ExtractionInventoryContainerType.InSecureContainer; + return pickup.CanEnterSecureContainer + && ExtractionItemActionPolicyService.CanPlaceInSecure(pickup.Definition) + && grid != null; + } + + grid = null; + container = default; + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootPickupService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootPickupService.cs.meta new file mode 100644 index 0000000..1c5733a --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootPickupService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b2f50f98d2f344c0a1fa4bb03a5c7047 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootRollTable.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootRollTable.cs new file mode 100644 index 0000000..af1181e --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootRollTable.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionLootTableEntry + { + public string DefinitionId; + public int Weight; + public int Quantity; + public bool CanEnterSecureContainer; + + public bool IsValid => !string.IsNullOrEmpty(DefinitionId) && Weight > 0 && Quantity > 0; + + public ExtractionLootTableEntry( + string definitionId, + int weight, + int quantity, + bool canEnterSecureContainer) + { + DefinitionId = definitionId; + Weight = weight; + Quantity = quantity; + CanEnterSecureContainer = canEnterSecureContainer; + } + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionLootRollTable + { + public List Entries = new(); + } + + public static class ExtractionLootRollService + { + public static bool TryRollPickup( + ExtractionLootRollTable table, + IExtractionItemCatalog catalog, + string instanceId, + int rollValue, + out ExtractionLootPickup pickup) + { + pickup = null; + if (table == null || table.Entries == null) return false; + if (catalog == null || string.IsNullOrEmpty(instanceId)) return false; + + long totalWeight = GetTotalValidWeight(table, catalog); + if (totalWeight <= 0) return false; + + long target = NormalizeRoll(rollValue, totalWeight); + long cursor = 0; + + foreach (var entry in table.Entries) + { + if (!TryGetValidDefinition(entry, catalog, out var definition)) continue; + + cursor += entry.Weight; + if (target >= cursor) continue; + + var item = new ExtractionItemInstance(instanceId, entry.DefinitionId, entry.Quantity); + ExtractionItemActionPolicyService.ApplyDefinitionPolicyToInstance(definition, item); + pickup = new ExtractionLootPickup(item, definition, entry.CanEnterSecureContainer); + return pickup.IsValid; + } + + return false; + } + + private static long GetTotalValidWeight( + ExtractionLootRollTable table, + IExtractionItemCatalog catalog) + { + long totalWeight = 0; + foreach (var entry in table.Entries) + { + if (!TryGetValidDefinition(entry, catalog, out _)) continue; + totalWeight += entry.Weight; + } + + return totalWeight; + } + + private static bool TryGetValidDefinition( + ExtractionLootTableEntry entry, + IExtractionItemCatalog catalog, + out ExtractionItemDefinition definition) + { + if (entry == null || !entry.IsValid) + { + definition = null; + return false; + } + + return catalog.TryGetItemDefinition(entry.DefinitionId, out definition) && definition != null; + } + + private static long NormalizeRoll(int rollValue, long totalWeight) + { + long result = rollValue % totalWeight; + return result < 0 ? result + totalWeight : result; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootRollTable.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootRollTable.cs.meta new file mode 100644 index 0000000..e10b35d --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootRollTable.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4c2bd16f4c1f7a243b7e007f1ce097e1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootTableCatalog.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootTableCatalog.cs new file mode 100644 index 0000000..0e3bf89 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootTableCatalog.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + public interface IExtractionLootTableCatalog + { + bool TryGetLootTable(string tableId, out ExtractionLootTableDefinition table); + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionLootTableDefinition : ExtractionLootRollTable + { + public string TableId; + + // M2 SD2.2c:珍品掉落开关的显式标记,取代此前"表 id 带 -rare 后缀"的命名约定判定。 + // additive 字段默认 false,向后兼容不配这个字段的旧配置/mod。 + public bool IsRare; + + public bool IsValid => !string.IsNullOrEmpty(TableId) && HasValidEntry(); + + public ExtractionLootTableDefinition(string tableId) + { + TableId = tableId; + } + + private bool HasValidEntry() + { + if (Entries == null) return false; + + foreach (var entry in Entries) + { + if (entry != null && entry.IsValid) return true; + } + + return false; + } + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionStaticLootTableCatalog : IExtractionLootTableCatalog + { + public List LootTables = new(); + + public bool TryGetLootTable(string tableId, out ExtractionLootTableDefinition table) + { + table = null; + if (string.IsNullOrEmpty(tableId)) return false; + + foreach (var candidate in LootTables) + { + if (candidate == null || !candidate.IsValid) continue; + if (candidate.TableId != tableId) continue; + + table = candidate; + return true; + } + + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootTableCatalog.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootTableCatalog.cs.meta new file mode 100644 index 0000000..fed03df --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLootTableCatalog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ae23cf1c7f44c2749a7b36c2d59c5ff7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLostRunSnapshot.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLostRunSnapshot.cs new file mode 100644 index 0000000..583a2cb --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLostRunSnapshot.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionLostRunSnapshot + { + public string SnapshotId; + public string MapId; + public int RaidSeed; + public long DeathUnixSeconds; + public string DeathReason; + public string DeathRegion; + public int DangerLevel; + public int CorpseSeed; + public List LostItemInstanceIds = new(); + public List LoadoutItemInstanceIds = new(); + public List BackpackItemInstanceIds = new(); + public List SecureItemInstanceIds = new(); + public List RecoverableCandidateItemInstanceIds = new(); + + public ExtractionLostRunSnapshot(string snapshotId, string mapId) + { + SnapshotId = snapshotId; + MapId = mapId; + } + + public void ApplyContext(ExtractionFailureContext context) + { + if (context == null) return; + + DeathUnixSeconds = context.DeathUnixSeconds; + DeathReason = context.DeathReason; + DeathRegion = context.DeathRegion; + DangerLevel = context.DangerLevel; + CorpseSeed = context.CorpseSeed; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLostRunSnapshot.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLostRunSnapshot.cs.meta new file mode 100644 index 0000000..3913689 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionLostRunSnapshot.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 84082863603a841409ffcf5e0c654851 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapAccessService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapAccessService.cs new file mode 100644 index 0000000..ed74564 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapAccessService.cs @@ -0,0 +1,94 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionMapAccessService + { + public static bool IsMapUnlocked(ExtractionProfileSaveData profile, string mapId) + { + if (profile == null || string.IsNullOrEmpty(mapId)) return false; + profile.EnsureInitialized(); + foreach (var unlockedMapId in profile.UnlockedMapIds) + { + if (unlockedMapId == mapId) return true; + } + + return false; + } + + public static bool TryUnlockMap( + ExtractionProfileSaveData profile, + ExtractionPlayableConfig config, + string mapId) + { + if (profile == null || config == null || string.IsNullOrEmpty(mapId)) return false; + if (!config.TryGetMap(mapId, out var map) || map == null || !map.IsValid) return false; + + profile.EnsureInitialized(); + if (IsMapUnlocked(profile, mapId)) return true; + + profile.UnlockedMapIds.Add(mapId); + return true; + } + + public static bool TryGetSelectableMaps( + ExtractionProfileSaveData profile, + ExtractionPlayableConfig config, + List results) + { + if (profile == null || config == null || results == null) return false; + profile.EnsureInitialized(); + results.Clear(); + + foreach (var mapId in profile.UnlockedMapIds) + { + if (!config.TryGetMap(mapId, out var map) || map == null || !map.IsValid) continue; + if (ContainsMap(results, map.MapId)) continue; + results.Add(map); + } + + return results.Count > 0; + } + + public static bool TryGetSelectableMapInfos( + ExtractionProfileSaveData profile, + ExtractionPlayableConfig config, + List results) + { + if (profile == null || config == null || results == null) return false; + profile.EnsureInitialized(); + results.Clear(); + + foreach (var mapId in profile.UnlockedMapIds) + { + if (!config.TryGetMap(mapId, out var map) || map == null || !map.IsValid) continue; + if (ContainsMapInfo(results, map.MapId)) continue; + + var info = ExtractionMapSelectionInfo.FromDefinition(map); + if (info != null) results.Add(info); + } + + return results.Count > 0; + } + + private static bool ContainsMap(List maps, string mapId) + { + foreach (var map in maps) + { + if (map != null && map.MapId == mapId) return true; + } + + return false; + } + + private static bool ContainsMapInfo(List maps, string mapId) + { + foreach (var map in maps) + { + if (map != null && map.MapId == mapId) return true; + } + + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapAccessService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapAccessService.cs.meta new file mode 100644 index 0000000..1a75ea0 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapAccessService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 149415f77f6eddb4d8f253662ada2448 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapDefinition.cs new file mode 100644 index 0000000..4852efd --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapDefinition.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionMapDefinition + { + public string MapId; + public string RaidRoomName; + public int DurationSeconds; + public int ThreatLevel; + public bool AllowEmergencyExtraction; + public bool UnlockedByDefault; + public string DisplayName; + public string Description; + public int DangerLevel; + public int RecommendedGearLevel; + public List LootTableIds = new(); + public List SpawnPointIds = new(); + public List CorpseRegionIds = new(); + + // C2 内容奖励档。两者都留空时继续使用旧 lootTableIds;新内容地图必须同时填写, + // 且 LootProfile.ContentTierId 必须与 ContentTierId 一致。 + public string ContentTierId; + public string LootProfileId; + + // M1 SC.1 新增(additive):进 raid 时自动发放的起始武器,值须等于一个可解析的 + // WeaponDataSO.ID;留空/未设置 = 本地图不发放起始武器(老配置零改动兼容)。 + public string StarterWeaponDefinitionId; + + // M1 SB.1 新增(additive):loot 布点 id 列表,和 SpawnPointIds(敌人布点)是独立概念—— + // 同一个 Transform 理论上可以两者共用同一个 id 字符串,但场景标记组件类型不同 + // (ExtractionSpawnPointActor vs ExtractionLootSpawnPointActor),互不误配。 + public List LootSpawnPointIds = new(); + + // M2 SS.1 新增(additive):上涌危险参数。HazardEnabled=false(默认)=本地图不启用该机制, + // 老配置零改动兼容。HazardRiseRate 单位=每秒上升高度;HazardDamagePerSecond 按每次 tick + // (1 秒一次)整量伤害,不是每帧分摊。 + public bool HazardEnabled; + public float HazardStartY; + public float HazardRiseRate; + public float HazardDamagePerSecond; + + // M2 SM.1 新增(additive):解锁本地图所需的物品成本列表。留空/未设置(默认)=本地图 + // 解锁免费,和老配置零改动兼容,走既有 ExtractionMapAccessService.TryUnlockMap 的免费路径; + // 非空则须走 ExtractionMapUnlockTransactionService.TryUnlockMapWithCost 消耗后解锁。 + public List UnlockCosts = new(); + + // M2 SM.4 新增(additive):列在此处的 spawnPointId 固定只从本地图 LootTableIds 里 + // IsRare 的子集中选表(见 ExtractionRoomLootBridge.ResolveLootTableIdsForSpawnPoint), + // 而不是走正常的"按 hash 覆盖全表"选择。留空/未设置(默认)=该机制不生效,和老配置零改动兼容。 + public List RareLootSpawnPointIds = new(); + + public bool IsValid => + !string.IsNullOrEmpty(MapId) + && !string.IsNullOrEmpty(RaidRoomName) + && DurationSeconds > 0; + + public ExtractionMapDefinition( + string mapId, + string raidRoomName, + int durationSeconds, + int threatLevel, + bool allowEmergencyExtraction, + string displayName = "", + string description = "", + int dangerLevel = 0, + int recommendedGearLevel = 0, + IEnumerable lootTableIds = null, + IEnumerable spawnPointIds = null, + IEnumerable corpseRegionIds = null, + bool unlockedByDefault = false) + { + MapId = mapId; + RaidRoomName = raidRoomName; + DurationSeconds = durationSeconds; + ThreatLevel = threatLevel; + AllowEmergencyExtraction = allowEmergencyExtraction; + UnlockedByDefault = unlockedByDefault; + DisplayName = displayName ?? string.Empty; + Description = description ?? string.Empty; + DangerLevel = dangerLevel; + RecommendedGearLevel = recommendedGearLevel; + if (lootTableIds != null) + LootTableIds.AddRange(lootTableIds); + if (spawnPointIds != null) + SpawnPointIds.AddRange(spawnPointIds); + if (corpseRegionIds != null) + CorpseRegionIds.AddRange(corpseRegionIds); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapDefinition.cs.meta new file mode 100644 index 0000000..eb163a9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 572e2703e3730674ba39563095845a48 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapSelectionInfo.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapSelectionInfo.cs new file mode 100644 index 0000000..4bcef2a --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapSelectionInfo.cs @@ -0,0 +1,56 @@ +namespace POB.Extraction +{ + public sealed class ExtractionMapSelectionInfo + { + public readonly string MapId; + public readonly string DisplayName; + public readonly string Description; + public readonly string RaidRoomName; + public readonly int DurationSeconds; + public readonly int ThreatLevel; + public readonly int DangerLevel; + public readonly int RecommendedGearLevel; + public readonly bool AllowEmergencyExtraction; + + public ExtractionMapSelectionInfo( + string mapId, + string displayName, + string description, + string raidRoomName, + int durationSeconds, + int threatLevel, + int dangerLevel, + int recommendedGearLevel, + bool allowEmergencyExtraction) + { + MapId = mapId ?? string.Empty; + DisplayName = displayName ?? string.Empty; + Description = description ?? string.Empty; + RaidRoomName = raidRoomName ?? string.Empty; + DurationSeconds = durationSeconds; + ThreatLevel = threatLevel; + DangerLevel = dangerLevel; + RecommendedGearLevel = recommendedGearLevel; + AllowEmergencyExtraction = allowEmergencyExtraction; + } + + public static ExtractionMapSelectionInfo FromDefinition(ExtractionMapDefinition map) + { + if (map == null) return null; + + string displayName = string.IsNullOrEmpty(map.DisplayName) + ? map.MapId + : map.DisplayName; + return new ExtractionMapSelectionInfo( + map.MapId, + displayName, + map.Description, + map.RaidRoomName, + map.DurationSeconds, + map.ThreatLevel, + map.DangerLevel, + map.RecommendedGearLevel, + map.AllowEmergencyExtraction); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapSelectionInfo.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapSelectionInfo.cs.meta new file mode 100644 index 0000000..199836c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapSelectionInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 94fdfd17f091ff64b81df3cf227b5dec +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapUnlockTransactionService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapUnlockTransactionService.cs new file mode 100644 index 0000000..8f5083a --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapUnlockTransactionService.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionMapUnlockTransactionService + { + public static bool TryUnlockMapWithCost( + ExtractionProfileSaveData profile, + ExtractionPlayableConfig config, + string mapId, + IExtractionItemCatalog itemCatalog, + List costSourceContainers) + { + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || config == null || string.IsNullOrEmpty(mapId)) return false; + if (!config.TryGetMap(mapId, out var map) || map == null || !map.IsValid) return false; + + profile.EnsureInitialized(); + if (ExtractionMapAccessService.IsMapUnlocked(profile, mapId)) return true; + + var costs = map.UnlockCosts; + if (costs == null || costs.Count == 0) + return ExtractionMapAccessService.TryUnlockMap(profile, config, mapId); + + if (!ExtractionItemCostService.HasEnoughItems(profile, costs, costSourceContainers)) return false; + if (!ExtractionItemCostService.TryConsumeCosts(profile, costs, costSourceContainers, out var costReceipt)) return false; + + if (ExtractionMapAccessService.TryUnlockMap(profile, config, mapId)) return true; + + ExtractionItemCostService.RestoreCosts(profile, costReceipt, itemCatalog); + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapUnlockTransactionService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapUnlockTransactionService.cs.meta new file mode 100644 index 0000000..a864ff2 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMapUnlockTransactionService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5b17d9f82fdf6a940b028046f0c0370f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentDefinition.cs new file mode 100644 index 0000000..2ebbfb8 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentDefinition.cs @@ -0,0 +1,40 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionMedicalTreatmentDefinition + { + public string TreatmentId; + public string ConditionId; + public string CostItemDefinitionId; + public int CostQuantity; + public int RequiredMedicalLevel; + public string RequiredSharedUnlockId; + public int RequiredSharedUnlockLevel; + public string RequiredSharedTalentId; + + public bool IsValid => + !string.IsNullOrEmpty(TreatmentId) + && !string.IsNullOrEmpty(ConditionId) + && !string.IsNullOrEmpty(CostItemDefinitionId) + && CostQuantity > 0 + && RequiredMedicalLevel > 0; + + public ExtractionMedicalTreatmentDefinition( + string treatmentId, + string conditionId, + string costItemDefinitionId, + int costQuantity, + int requiredMedicalLevel) + { + TreatmentId = treatmentId; + ConditionId = conditionId; + CostItemDefinitionId = costItemDefinitionId; + CostQuantity = costQuantity; + RequiredMedicalLevel = requiredMedicalLevel; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentDefinition.cs.meta new file mode 100644 index 0000000..ce1d262 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aeacef56142ce9044876f5aa1f67e097 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentLedger.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentLedger.cs new file mode 100644 index 0000000..d422a08 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentLedger.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionMedicalTreatmentLedger + { + public List Entries = new(); + + public int Count => Entries?.Count ?? 0; + + public bool Record(string treatmentId, string conditionId) + { + if (string.IsNullOrEmpty(treatmentId) || string.IsNullOrEmpty(conditionId)) return false; + + Entries ??= new List(); + Entries.Add(new ExtractionMedicalTreatmentRecord(treatmentId, conditionId)); + return true; + } + + public bool HasTreatedCondition(string conditionId) + { + if (string.IsNullOrEmpty(conditionId) || Entries == null) return false; + foreach (var entry in Entries) + { + if (entry != null && entry.ConditionId == conditionId) return true; + } + return false; + } + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionMedicalTreatmentRecord + { + public string TreatmentId; + public string ConditionId; + + public ExtractionMedicalTreatmentRecord(string treatmentId, string conditionId) + { + TreatmentId = treatmentId; + ConditionId = conditionId; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentLedger.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentLedger.cs.meta new file mode 100644 index 0000000..8d09439 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentLedger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e264563a28372bf419fba3cf87ee5b84 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentService.cs new file mode 100644 index 0000000..c29a1dd --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentService.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionMedicalTreatmentService + { + public static bool TryGetAvailableTreatments( + ExtractionProfileSaveData profile, + List definitions, + IExtractionItemCatalog itemCatalog, + string conditionId, + IExtractionMetaProgressProvider metaProgress, + List results) + { + if (results == null) return false; + results.Clear(); + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || definitions == null || itemCatalog == null) return false; + if (string.IsNullOrEmpty(conditionId)) return false; + profile.EnsureInitialized(); + + int medicalLevel = profile.Facilities.GetLevel(ExtractionFacilityType.Medical); + foreach (var definition in definitions) + { + if (definition == null || !definition.IsValid) continue; + if (definition.ConditionId != conditionId) continue; + if (medicalLevel < definition.RequiredMedicalLevel) continue; + if (!itemCatalog.TryGetItemDefinition(definition.CostItemDefinitionId, out _)) continue; + if (!MeetsMetaRequirements(definition, metaProgress)) continue; + if (profile.MedicalTreatments.HasTreatedCondition(definition.ConditionId)) continue; + + results.Add(definition); + } + + return results.Count > 0; + } + + private static bool MeetsMetaRequirements( + ExtractionMedicalTreatmentDefinition definition, + IExtractionMetaProgressProvider metaProgress) + { + metaProgress ??= new ExtractionNoOpMetaProgressProvider(); + + if (!string.IsNullOrEmpty(definition.RequiredSharedUnlockId)) + { + if (definition.RequiredSharedUnlockLevel <= 0) return false; + if (metaProgress.GetSharedUnlockLevel(definition.RequiredSharedUnlockId) < definition.RequiredSharedUnlockLevel) + return false; + } + + if (!string.IsNullOrEmpty(definition.RequiredSharedTalentId)) + { + if (!metaProgress.HasSharedTalent(definition.RequiredSharedTalentId)) return false; + } + + return true; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentService.cs.meta new file mode 100644 index 0000000..a65fb27 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b71148dc10e26b748a873d09ae4971af +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentTransactionService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentTransactionService.cs new file mode 100644 index 0000000..827001b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentTransactionService.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionMedicalTreatmentTransactionService + { + public static bool TryCompleteTreatment( + ExtractionProfileSaveData profile, + ExtractionMedicalTreatmentDefinition treatment, + IExtractionItemCatalog itemCatalog, + List costSourceContainers) + { + if (!CanCompleteTreatment(profile, treatment, itemCatalog, out var costs)) return false; + if (!ExtractionItemCostService.HasEnoughItems(profile, costs, costSourceContainers)) return false; + if (!ExtractionItemCostService.TryConsumeCosts(profile, costs, costSourceContainers, out var costReceipt)) return false; + + if (profile.MedicalTreatments.Record(treatment.TreatmentId, treatment.ConditionId)) return true; + + ExtractionItemCostService.RestoreCosts(profile, costReceipt, itemCatalog); + return false; + } + + private static bool CanCompleteTreatment( + ExtractionProfileSaveData profile, + ExtractionMedicalTreatmentDefinition treatment, + IExtractionItemCatalog itemCatalog, + out List costs) + { + costs = null; + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || treatment == null || !treatment.IsValid || itemCatalog == null) return false; + + profile.EnsureInitialized(); + if (profile.Facilities.GetLevel(ExtractionFacilityType.Medical) < treatment.RequiredMedicalLevel) + return false; + if (!itemCatalog.TryGetItemDefinition(treatment.CostItemDefinitionId, out _)) return false; + if (profile.MedicalTreatments.HasTreatedCondition(treatment.ConditionId)) return false; + + costs = new List + { + new ExtractionItemCost(treatment.CostItemDefinitionId, treatment.CostQuantity) + }; + return true; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentTransactionService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentTransactionService.cs.meta new file mode 100644 index 0000000..2df6dbb --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMedicalTreatmentTransactionService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f693719349ee65840b44ec48e074f5e4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantOfferDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantOfferDefinition.cs new file mode 100644 index 0000000..8cfd0d5 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantOfferDefinition.cs @@ -0,0 +1,55 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionMerchantOfferDefinition + { + public const string BloodSampleResourceId = "BloodSample"; + + public string OfferId; + public string ItemDefinitionId; + public int Quantity; + public string PriceItemDefinitionId; + public string PriceResourceId; + public int PriceQuantity; + public int InitialStock = 1; + public int SelectionWeight = 1; + public bool IsReliefOffer; + public int RequiredMerchantLevel; + public string RequiredSharedUnlockId; + public int RequiredSharedUnlockLevel; + public string RequiredSharedTalentId; + + public bool IsValid => + !string.IsNullOrEmpty(OfferId) + && !string.IsNullOrEmpty(ItemDefinitionId) + && Quantity > 0 + && (!string.IsNullOrEmpty(PriceItemDefinitionId) + || PriceResourceId == BloodSampleResourceId) + && PriceQuantity > 0 + && InitialStock > 0 + && SelectionWeight > 0 + && RequiredMerchantLevel > 0; + + public bool UsesSharedBloodSample => PriceResourceId == BloodSampleResourceId; + + public ExtractionMerchantOfferDefinition( + string offerId, + string itemDefinitionId, + int quantity, + string priceItemDefinitionId, + int priceQuantity, + int requiredMerchantLevel) + { + OfferId = offerId; + ItemDefinitionId = itemDefinitionId; + Quantity = quantity; + PriceItemDefinitionId = priceItemDefinitionId; + PriceQuantity = priceQuantity; + RequiredMerchantLevel = requiredMerchantLevel; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantOfferDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantOfferDefinition.cs.meta new file mode 100644 index 0000000..311cff3 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantOfferDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bf66a5f56a0ea6c46a6bdc582bbc4f46 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantOfferService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantOfferService.cs new file mode 100644 index 0000000..2615b0c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantOfferService.cs @@ -0,0 +1,64 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionMerchantOfferService + { + public static bool TryGetAvailableOffers( + ExtractionProfileSaveData profile, + List offers, + IExtractionItemCatalog itemCatalog, + IExtractionMetaProgressProvider metaProgress, + List results) + { + if (results == null) return false; + results.Clear(); + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || offers == null || itemCatalog == null) return false; + profile.EnsureInitialized(); + + int merchantLevel = profile.Facilities.GetLevel(ExtractionFacilityType.Merchant); + foreach (var offer in offers) + { + if (offer == null || !offer.IsValid) continue; + if (merchantLevel < offer.RequiredMerchantLevel) continue; + if (!HasCatalogItems(offer, itemCatalog)) continue; + if (!MeetsMetaRequirements(offer, metaProgress)) continue; + + results.Add(offer); + } + + return results.Count > 0; + } + + private static bool HasCatalogItems( + ExtractionMerchantOfferDefinition offer, + IExtractionItemCatalog itemCatalog) + { + if (!itemCatalog.TryGetItemDefinition(offer.ItemDefinitionId, out _)) return false; + return offer.UsesSharedBloodSample + || itemCatalog.TryGetItemDefinition(offer.PriceItemDefinitionId, out _); + } + + private static bool MeetsMetaRequirements( + ExtractionMerchantOfferDefinition offer, + IExtractionMetaProgressProvider metaProgress) + { + metaProgress ??= new ExtractionNoOpMetaProgressProvider(); + + if (!string.IsNullOrEmpty(offer.RequiredSharedUnlockId)) + { + if (offer.RequiredSharedUnlockLevel <= 0) return false; + if (metaProgress.GetSharedUnlockLevel(offer.RequiredSharedUnlockId) < offer.RequiredSharedUnlockLevel) + return false; + } + + if (!string.IsNullOrEmpty(offer.RequiredSharedTalentId)) + { + if (!metaProgress.HasSharedTalent(offer.RequiredSharedTalentId)) return false; + } + + return true; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantOfferService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantOfferService.cs.meta new file mode 100644 index 0000000..0cd1d87 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantOfferService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b34bfa11a3dcea340b7b09175344eb16 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantPurchaseTransactionService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantPurchaseTransactionService.cs new file mode 100644 index 0000000..9d789b3 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantPurchaseTransactionService.cs @@ -0,0 +1,108 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionMerchantPurchaseTransactionService + { + public static bool TryPurchaseOffer( + ExtractionProfileSaveData profile, + ExtractionMerchantOfferDefinition offer, + IExtractionItemCatalog itemCatalog, + List priceSourceContainers, + ExtractionInventoryContainerType targetContainer, + string purchasedItemInstanceId, + int x, + int y, + bool rotated) + { + if (!CanPurchaseOffer(profile, offer, itemCatalog, purchasedItemInstanceId, out var purchasedDefinition)) + return false; + if (!TryGetGrid(profile, targetContainer, out var targetGrid)) return false; + + var purchasedItem = new ExtractionItemInstance( + purchasedItemInstanceId, + offer.ItemDefinitionId, + offer.Quantity); + ExtractionItemActionPolicyService.ApplyDefinitionPolicyToInstance(purchasedDefinition, purchasedItem); + if (!CanPlace(targetGrid, purchasedItem, purchasedDefinition, x, y, rotated)) return false; + + var costs = new List + { + new ExtractionItemCost(offer.PriceItemDefinitionId, offer.PriceQuantity) + }; + if (!ExtractionItemCostService.HasEnoughItems(profile, costs, priceSourceContainers)) return false; + if (!ExtractionItemCostService.TryConsumeCosts(profile, costs, priceSourceContainers, out var costReceipt)) return false; + + if (!profile.Items.Register(purchasedItem)) + { + ExtractionItemCostService.RestoreCosts(profile, costReceipt, itemCatalog); + return false; + } + if (!targetGrid.TryPlace(purchasedItem, purchasedDefinition, x, y, rotated)) + { + profile.Items.TryRemove(purchasedItemInstanceId); + ExtractionItemCostService.RestoreCosts(profile, costReceipt, itemCatalog); + return false; + } + + if (profile.Ownership.Register(purchasedItemInstanceId, targetContainer)) return true; + + targetGrid.TryRemove(purchasedItemInstanceId); + profile.Items.TryRemove(purchasedItemInstanceId); + ExtractionItemCostService.RestoreCosts(profile, costReceipt, itemCatalog); + return false; + } + + private static bool CanPurchaseOffer( + ExtractionProfileSaveData profile, + ExtractionMerchantOfferDefinition offer, + IExtractionItemCatalog itemCatalog, + string purchasedItemInstanceId, + out ExtractionItemDefinition purchasedDefinition) + { + purchasedDefinition = null; + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || offer == null || !offer.IsValid || itemCatalog == null) return false; + if (string.IsNullOrEmpty(purchasedItemInstanceId)) return false; + profile.EnsureInitialized(); + if (profile.Facilities.GetLevel(ExtractionFacilityType.Merchant) < offer.RequiredMerchantLevel) + return false; + if (profile.Items.TryGet(purchasedItemInstanceId, out _)) return false; + if (profile.Ownership.TryGetContainer(purchasedItemInstanceId, out _)) return false; + if (!itemCatalog.TryGetItemDefinition(offer.ItemDefinitionId, out purchasedDefinition)) return false; + if (!itemCatalog.TryGetItemDefinition(offer.PriceItemDefinitionId, out _)) return false; + + return true; + } + + private static bool CanPlace( + ExtractionItemGrid grid, + ExtractionItemInstance item, + ExtractionItemDefinition definition, + int x, + int y, + bool rotated) + { + if (!grid.TryPlace(item, definition, x, y, rotated)) return false; + grid.TryRemove(item.InstanceId); + return true; + } + + private static bool TryGetGrid( + ExtractionProfileSaveData profile, + ExtractionInventoryContainerType container, + out ExtractionItemGrid grid) + { + grid = container switch + { + ExtractionInventoryContainerType.Stash => profile.Stash, + ExtractionInventoryContainerType.Loadout => profile.Loadout, + ExtractionInventoryContainerType.SecureContainer => profile.SecureContainer, + ExtractionInventoryContainerType.Holding => profile.RecoveryHolding, + _ => null + }; + + return grid != null; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantPurchaseTransactionService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantPurchaseTransactionService.cs.meta new file mode 100644 index 0000000..f3e4c50 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantPurchaseTransactionService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 79c19ef751cc21b4fbd94bc982adb348 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantRotationService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantRotationService.cs new file mode 100644 index 0000000..dd841ba --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantRotationService.cs @@ -0,0 +1,269 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace POB.Extraction +{ + [Serializable] + public class ExtractionMerchantOfferPoolDefinition + { + public string PoolId; + public List OfferIds = new(); + + public ExtractionMerchantOfferPoolDefinition(string poolId) + { + PoolId = poolId; + } + } + + [Serializable] + public class ExtractionMerchantSlotDefinition + { + public string SlotId; + public string PoolId; + + public ExtractionMerchantSlotDefinition(string slotId, string poolId) + { + SlotId = slotId; + PoolId = poolId; + } + } + + [Serializable] + public class ExtractionMerchantRotationSettings + { + public int MinimumValidRaidGameplaySeconds = 30; + public int BasePaidRefreshCost = 10; + public int PaidRefreshCostIncrement = 5; + } + + public enum ExtractionMerchantRaidOutcomeType + { + Success = 0, + Timeout = 1, + Death = 2, + Abandon = 3 + } + + public sealed class ExtractionMerchantRaidOutcome + { + public string RaidId; + public ExtractionMerchantRaidOutcomeType Outcome; + public int GameplayElapsedSeconds; + public bool HasParticipationReceipt; + + public ExtractionMerchantRaidOutcome( + string raidId, + ExtractionMerchantRaidOutcomeType outcome, + int gameplayElapsedSeconds, + bool hasParticipationReceipt) + { + RaidId = raidId; + Outcome = outcome; + GameplayElapsedSeconds = gameplayElapsedSeconds; + HasParticipationReceipt = hasParticipationReceipt; + } + } + + public static class ExtractionMerchantRotationService + { + private const string SelectionHashDomain = "zeroengine.extraction.merchant-offer:v1"; + + public static bool TryInitialize( + ExtractionProfileSaveData profile, + ExtractionPlayableConfig config, + string rotationId, + int seed) + { + if (!ExtractionFeatureSwitch.Enabled || profile == null || config == null) return false; + profile.EnsureInitialized(); + if (profile.Merchant.Offers.Count > 0) return true; + return TryReplaceRotation(profile.Merchant, config, rotationId, seed, resetPaidRefresh: true); + } + + public static bool TryRotateAfterSettledRaid( + ExtractionProfileSaveData profile, + ExtractionPlayableConfig config, + ExtractionMerchantRaidOutcome raidOutcome, + int seed) + { + if (!ExtractionFeatureSwitch.Enabled + || profile == null + || config == null + || raidOutcome == null + || string.IsNullOrEmpty(raidOutcome.RaidId)) + { + return false; + } + + profile.EnsureInitialized(); + if (profile.Merchant.LastProcessedSettledRaidId == raidOutcome.RaidId) return true; + if (!IsValidRaid(config.MerchantRotation, raidOutcome)) + { + profile.Merchant.LastProcessedSettledRaidId = raidOutcome.RaidId; + return true; + } + + string rotationId = ExtractionOperationId.Create("merchant.rotation", raidOutcome.RaidId); + if (!TryReplaceRotation(profile.Merchant, config, rotationId, seed, resetPaidRefresh: true)) + return false; + + profile.Merchant.LastProcessedSettledRaidId = raidOutcome.RaidId; + profile.Merchant.FreeRotationCount++; + return true; + } + + public static bool TryBuildOffers( + ExtractionPlayableConfig config, + string rotationId, + int seed, + List results) + { + if (results == null) return false; + results.Clear(); + if (config == null + || string.IsNullOrEmpty(rotationId) + || config.MerchantSlots == null + || config.MerchantSlots.Count == 0) + { + return false; + } + + var selectedIds = new HashSet(StringComparer.Ordinal); + foreach (var slot in config.MerchantSlots) + { + if (slot == null + || string.IsNullOrEmpty(slot.SlotId) + || !TryGetPool(config, slot.PoolId, out var pool) + || !TrySelectOffer(config, pool, rotationId, slot.SlotId, seed, selectedIds, out var offer)) + { + results.Clear(); + return false; + } + + selectedIds.Add(offer.OfferId); + results.Add(new ExtractionMerchantOfferState(offer.OfferId, offer.InitialStock)); + } + + return results.Count == config.MerchantSlots.Count; + } + + public static int GetPaidRefreshCost( + ExtractionMerchantState state, + ExtractionMerchantRotationSettings settings) + { + if (settings == null) return 0; + if (state?.NextPaidRefreshCost > 0) return state.NextPaidRefreshCost; + return Math.Max(1, settings.BasePaidRefreshCost); + } + + internal static bool TryReplaceRotation( + ExtractionMerchantState state, + ExtractionPlayableConfig config, + string rotationId, + int seed, + bool resetPaidRefresh) + { + if (state == null || config == null) return false; + var offers = new List(); + if (!TryBuildOffers(config, rotationId, seed, offers)) return false; + + state.RotationId = rotationId; + state.RotationSeed = seed; + state.Offers = offers; + if (resetPaidRefresh) + { + state.PaidRefreshCount = 0; + state.NextPaidRefreshCost = Math.Max(1, config.MerchantRotation?.BasePaidRefreshCost ?? 1); + } + return true; + } + + private static bool IsValidRaid( + ExtractionMerchantRotationSettings settings, + ExtractionMerchantRaidOutcome raidOutcome) + { + if (raidOutcome.Outcome == ExtractionMerchantRaidOutcomeType.Success + || raidOutcome.Outcome == ExtractionMerchantRaidOutcomeType.Timeout) + { + return true; + } + + int minimum = Math.Max(0, settings?.MinimumValidRaidGameplaySeconds ?? 0); + return raidOutcome.HasParticipationReceipt + || raidOutcome.GameplayElapsedSeconds >= minimum; + } + + private static bool TryGetPool( + ExtractionPlayableConfig config, + string poolId, + out ExtractionMerchantOfferPoolDefinition pool) + { + pool = null; + if (string.IsNullOrEmpty(poolId) || config.MerchantOfferPools == null) return false; + foreach (var candidate in config.MerchantOfferPools) + { + if (candidate?.PoolId != poolId) continue; + if (pool != null) return false; + pool = candidate; + } + return pool?.OfferIds != null && pool.OfferIds.Count > 0; + } + + private static bool TrySelectOffer( + ExtractionPlayableConfig config, + ExtractionMerchantOfferPoolDefinition pool, + string rotationId, + string slotId, + int seed, + HashSet selectedIds, + out ExtractionMerchantOfferDefinition selected) + { + selected = null; + var candidates = new List(); + long totalWeight = 0; + foreach (string offerId in pool.OfferIds) + { + if (selectedIds.Contains(offerId)) continue; + if (!TryGetOffer(config, offerId, out var offer) || !offer.IsValid || !offer.UsesSharedBloodSample) + continue; + candidates.Add(offer); + totalWeight += offer.SelectionWeight; + } + if (candidates.Count == 0 || totalWeight <= 0) return false; + + uint hash = unchecked((uint)ExtractionStableHash.ComputeInt32( + SelectionHashDomain, + rotationId, + slotId, + seed.ToString(CultureInfo.InvariantCulture))); + long roll = hash % totalWeight; + foreach (var candidate in candidates) + { + if (roll < candidate.SelectionWeight) + { + selected = candidate; + return true; + } + roll -= candidate.SelectionWeight; + } + return false; + } + + internal static bool TryGetOffer( + ExtractionPlayableConfig config, + string offerId, + out ExtractionMerchantOfferDefinition offer) + { + offer = null; + if (config?.MerchantOfferDefinitions == null || string.IsNullOrEmpty(offerId)) return false; + foreach (var candidate in config.MerchantOfferDefinitions) + { + if (candidate?.OfferId != offerId) continue; + if (offer != null) return false; + offer = candidate; + } + return offer != null; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantRotationService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantRotationService.cs.meta new file mode 100644 index 0000000..f31cfbf --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantRotationService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2ae825bcb048444cb68f9b12388f5f5d diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantState.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantState.cs new file mode 100644 index 0000000..f921bdf --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantState.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + [Serializable] + public class ExtractionMerchantState + { + public string RotationId; + public int RotationSeed; + public List Offers = new(); + public string LastProcessedSettledRaidId; + public int FreeRotationCount; + public int PaidRefreshCount; + public int NextPaidRefreshCost; + + internal void EnsureInitialized() + { + Offers ??= new List(); + } + } + + [Serializable] + public class ExtractionMerchantOfferState + { + public string OfferId; + public int RemainingStock; + + public ExtractionMerchantOfferState(string offerId, int remainingStock) + { + OfferId = offerId; + RemainingStock = remainingStock; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantState.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantState.cs.meta new file mode 100644 index 0000000..513282b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aa815b49bea244d3965ad14e36492b05 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantWalletTransactionService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantWalletTransactionService.cs new file mode 100644 index 0000000..56d0ef3 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantWalletTransactionService.cs @@ -0,0 +1,382 @@ +using System; +using System.Globalization; + +namespace POB.Extraction +{ + public sealed class ExtractionMerchantWalletTransactionResult + { + public SharedMetaWalletProfileSagaRequest Request; + public SharedMetaWalletProfileSagaResult SagaResult; + + public ExtractionMerchantWalletTransactionResult( + SharedMetaWalletProfileSagaRequest request, + SharedMetaWalletProfileSagaResult sagaResult) + { + Request = request; + SagaResult = sagaResult; + } + + public bool IsSuccess => SagaResult?.IsSuccess == true; + } + + public sealed class ExtractionMerchantWalletTransactionService + { + public const float SellValueMultiplier = 0.5f; + + private readonly IExtractionProfileStore store; + private readonly ISharedMetaWallet wallet; + private readonly ExtractionPlayableConfig config; + private readonly SharedMetaWalletProfileSaga saga; + + public ExtractionMerchantWalletTransactionService( + IExtractionProfileStore store, + ISharedMetaWallet wallet, + ExtractionPlayableConfig config) + { + this.store = store; + this.wallet = wallet; + this.config = config; + saga = new SharedMetaWalletProfileSaga(store, wallet); + } + + public ExtractionMerchantWalletTransactionResult TryPurchase( + string transactionId, + string offerId, + string purchasedItemInstanceId) + { + if (!ExtractionFeatureSwitch.Enabled + || string.IsNullOrEmpty(transactionId) + || string.IsNullOrEmpty(offerId) + || string.IsNullOrEmpty(purchasedItemInstanceId) + || !ExtractionMerchantRotationService.TryGetOffer(config, offerId, out var offer) + || !offer.IsValid + || !offer.UsesSharedBloodSample) + { + return Invalid(); + } + + string payload = ExtractionStableHash.ComputeSha256( + "zeroengine.extraction.merchant-purchase:v1", + offerId, + purchasedItemInstanceId, + offer.ItemDefinitionId, + offer.Quantity.ToString(CultureInfo.InvariantCulture), + offer.PriceQuantity.ToString(CultureInfo.InvariantCulture)); + var request = new SharedMetaWalletProfileSagaRequest( + transactionId, + "merchant.purchase", + ExtractionMerchantOfferDefinition.BloodSampleResourceId, + offer.PriceQuantity, + payload, + SharedMetaWalletProfileFlow.DebitThenProfile); + var result = saga.Execute( + request, + profile => CanPurchase(profile, offer, purchasedItemInstanceId), + profile => ApplyPurchase(profile, offer, purchasedItemInstanceId)); + return new ExtractionMerchantWalletTransactionResult(request, result); + } + + public bool TryGetSellQuote(string itemInstanceId, out int creditQuantity) + { + creditQuantity = 0; + var profile = store?.Load()?.Profile; + return TryGetSellQuote(profile, itemInstanceId, out creditQuantity); + } + + public ExtractionMerchantWalletTransactionResult TrySell( + string transactionId, + string itemInstanceId, + int expectedCreditQuantity) + { + if (!ExtractionFeatureSwitch.Enabled + || string.IsNullOrEmpty(transactionId) + || string.IsNullOrEmpty(itemInstanceId) + || expectedCreditQuantity <= 0) + { + return Invalid(); + } + + string payload = ExtractionStableHash.ComputeSha256( + "zeroengine.extraction.merchant-sell:v1", + itemInstanceId, + expectedCreditQuantity.ToString(CultureInfo.InvariantCulture)); + var request = new SharedMetaWalletProfileSagaRequest( + transactionId, + "merchant.sell", + ExtractionMerchantOfferDefinition.BloodSampleResourceId, + expectedCreditQuantity, + payload, + SharedMetaWalletProfileFlow.ProfileThenCredit); + var result = saga.Execute( + request, + profile => TryGetSellQuote(profile, itemInstanceId, out int quote) + && quote == expectedCreditQuantity, + profile => ApplySell(profile, itemInstanceId)); + return new ExtractionMerchantWalletTransactionResult(request, result); + } + + public ExtractionMerchantWalletTransactionResult TryPaidRefresh( + string transactionId, + int seed) + { + var loaded = store?.Load(); + int cost = loaded?.Profile == null + ? 0 + : ExtractionMerchantRotationService.GetPaidRefreshCost( + loaded.Profile.Merchant, + config?.MerchantRotation); + if (!ExtractionFeatureSwitch.Enabled + || string.IsNullOrEmpty(transactionId) + || cost <= 0 + || config == null) + { + return Invalid(); + } + + string rotationId = ExtractionOperationId.Create("merchant.paid-refresh", transactionId); + string payload = ExtractionStableHash.ComputeSha256( + "zeroengine.extraction.merchant-paid-refresh:v1", + rotationId, + seed.ToString(CultureInfo.InvariantCulture), + cost.ToString(CultureInfo.InvariantCulture)); + var request = new SharedMetaWalletProfileSagaRequest( + transactionId, + "merchant.paid-refresh", + ExtractionMerchantOfferDefinition.BloodSampleResourceId, + cost, + payload, + SharedMetaWalletProfileFlow.DebitThenProfile); + var result = saga.Execute( + request, + profile => CanRefresh(profile, rotationId, seed, cost), + profile => ApplyRefresh(profile, rotationId, seed, cost)); + return new ExtractionMerchantWalletTransactionResult(request, result); + } + + public SharedMetaWalletProfileSagaResult Complete( + ExtractionMerchantWalletTransactionResult transaction) + { + return transaction?.Request == null + ? new SharedMetaWalletProfileSagaResult( + SharedMetaWalletProfileSagaStatus.InvalidRequest, + 0) + : saga.Complete(transaction.Request); + } + + private bool CanPurchase( + ExtractionProfileSaveData profile, + ExtractionMerchantOfferDefinition offer, + string purchasedItemInstanceId) + { + if (profile == null + || config == null + || !config.TryGetItemDefinition(offer.ItemDefinitionId, out var definition) + || profile.Items.TryGet(purchasedItemInstanceId, out _) + || profile.Ownership.TryGetContainer(purchasedItemInstanceId, out _) + || !TryGetOfferState(profile.Merchant, offer.OfferId, out var state) + || state.RemainingStock <= 0) + { + return false; + } + + return profile.Stash.TryFindFreeSlotWithRotation( + definition.Width, + definition.Height, + definition.CanRotate, + out _, + out _, + out _); + } + + private void ApplyPurchase( + ExtractionProfileSaveData profile, + ExtractionMerchantOfferDefinition offer, + string purchasedItemInstanceId) + { + if (!config.TryGetItemDefinition(offer.ItemDefinitionId, out var definition) + || !TryGetOfferState(profile.Merchant, offer.OfferId, out var state) + || !profile.Stash.TryFindFreeSlotWithRotation( + definition.Width, + definition.Height, + definition.CanRotate, + out int x, + out int y, + out bool rotated)) + { + throw new InvalidOperationException("Purchase preconditions changed."); + } + + var item = new ExtractionItemInstance( + purchasedItemInstanceId, + offer.ItemDefinitionId, + offer.Quantity, + "merchant", + offer.OfferId); + ExtractionItemActionPolicyService.ApplyDefinitionPolicyToInstance(definition, item); + if (!profile.Items.Register(item) + || !profile.Stash.TryPlace(item, definition, x, y, rotated) + || !profile.Ownership.Register( + purchasedItemInstanceId, + ExtractionInventoryContainerType.Stash)) + { + throw new InvalidOperationException("Purchase mutation failed."); + } + state.RemainingStock--; + } + + private bool TryGetSellQuote( + ExtractionProfileSaveData profile, + string itemInstanceId, + out int creditQuantity) + { + creditQuantity = 0; + if (profile == null + || config == null + || !profile.Items.TryGet(itemInstanceId, out var item) + || !config.TryGetItemDefinition(item.DefinitionId, out var definition) + || !TryFindOwnership(profile, itemInstanceId, out var entry)) + { + return false; + } + + if (!ExtractionItemActionPolicyService.CanSell(definition, item) + || !IsSellableBaseLocation(entry)) return false; + long raw = (long)definition.Value * Math.Max(0, item.Quantity); + creditQuantity = (int)Math.Min(int.MaxValue, raw * 1 / 2); + return creditQuantity > 0; + } + + private void ApplySell(ExtractionProfileSaveData profile, string itemInstanceId) + { + if (!profile.Items.TryGet(itemInstanceId, out var item) + || !config.TryGetItemDefinition(item.DefinitionId, out var definition) + || !TryFindOwnership(profile, itemInstanceId, out var entry)) + { + throw new InvalidOperationException("Sell preconditions changed."); + } + + if (entry.Container == ExtractionInventoryContainerType.EquipmentSlot) + { + if (!ExtractionItemLocationService.TryGetEquipment( + profile, + null, + entry.LocationSubtype, + out var equipment) + || !equipment.TryClear(entry.LocationId, itemInstanceId)) + { + throw new InvalidOperationException("Equipped sell item could not be detached."); + } + } + else + { + if (!ExtractionItemLocationService.TryGetGrid(profile, null, entry.Container, out var grid) + || !grid.TryRemove(itemInstanceId)) + { + throw new InvalidOperationException("Sell item could not be detached."); + } + } + + if (!profile.Ownership.TryMove( + itemInstanceId, + entry.Container, + ExtractionInventoryContainerType.Sold, + "merchant", + null)) + { + throw new InvalidOperationException("Sell ownership could not be committed."); + } + } + + private bool CanRefresh( + ExtractionProfileSaveData profile, + string rotationId, + int seed, + int expectedCost) + { + if (profile == null + || ExtractionMerchantRotationService.GetPaidRefreshCost( + profile.Merchant, + config.MerchantRotation) != expectedCost) + { + return false; + } + var offers = new System.Collections.Generic.List(); + return ExtractionMerchantRotationService.TryBuildOffers( + config, + rotationId, + seed, + offers); + } + + private void ApplyRefresh( + ExtractionProfileSaveData profile, + string rotationId, + int seed, + int cost) + { + if (!ExtractionMerchantRotationService.TryReplaceRotation( + profile.Merchant, + config, + rotationId, + seed, + resetPaidRefresh: false)) + { + throw new InvalidOperationException("Paid refresh generation failed."); + } + + profile.Merchant.PaidRefreshCount++; + int increment = Math.Max(0, config.MerchantRotation.PaidRefreshCostIncrement); + profile.Merchant.NextPaidRefreshCost = Math.Min(int.MaxValue, cost + increment); + } + + private static bool TryGetOfferState( + ExtractionMerchantState merchant, + string offerId, + out ExtractionMerchantOfferState state) + { + state = null; + if (merchant?.Offers == null) return false; + foreach (var candidate in merchant.Offers) + { + if (candidate?.OfferId != offerId) continue; + if (state != null) return false; + state = candidate; + } + return state != null; + } + + private static bool TryFindOwnership( + ExtractionProfileSaveData profile, + string itemInstanceId, + out ExtractionOwnershipEntry entry) + { + entry = null; + foreach (var candidate in profile.Ownership.Entries) + { + if (candidate?.ItemInstanceId != itemInstanceId) continue; + if (entry != null) return false; + entry = candidate; + } + return entry != null; + } + + private static bool IsSellableBaseLocation(ExtractionOwnershipEntry entry) + { + return entry.Container == ExtractionInventoryContainerType.Stash + || entry.Container == ExtractionInventoryContainerType.Loadout + || entry.Container == ExtractionInventoryContainerType.SecureContainer + || entry.Container == ExtractionInventoryContainerType.Holding + || (entry.Container == ExtractionInventoryContainerType.EquipmentSlot + && entry.LocationSubtype == ExtractionItemLocationService.BaseEquipmentLocationSubtype); + } + + private static ExtractionMerchantWalletTransactionResult Invalid() + { + return new ExtractionMerchantWalletTransactionResult( + null, + new SharedMetaWalletProfileSagaResult( + SharedMetaWalletProfileSagaStatus.InvalidRequest, + 0)); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantWalletTransactionService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantWalletTransactionService.cs.meta new file mode 100644 index 0000000..e11ece4 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMerchantWalletTransactionService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ecf4ed9a59c246ec870d09bd4e207a3d diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMetaExchangeDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMetaExchangeDefinition.cs new file mode 100644 index 0000000..4415182 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMetaExchangeDefinition.cs @@ -0,0 +1,40 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + // D10 单向兑换白名单条目:Extraction 物品(货币) → 主线 meta 资源,汇率 = ExtractionQuantity + // 换 MainlineQuantity。Core 层禁止引用主线 ResourceType(程序集边界),资源用字符串名承载, + // 解析与"只允许 meta 通道"的强制都在 Adapter 侧 applier。 + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionMetaExchangeDefinition + { + public string ExchangeId; + public string ExtractionItemDefinitionId; + public int ExtractionQuantity; + public string MainlineResourceName; + public int MainlineQuantity; + + public bool IsValid => + !string.IsNullOrEmpty(ExchangeId) + && !string.IsNullOrEmpty(ExtractionItemDefinitionId) + && ExtractionQuantity > 0 + && !string.IsNullOrEmpty(MainlineResourceName) + && MainlineQuantity > 0; + + public ExtractionMetaExchangeDefinition( + string exchangeId, + string extractionItemDefinitionId, + int extractionQuantity, + string mainlineResourceName, + int mainlineQuantity) + { + ExchangeId = exchangeId; + ExtractionItemDefinitionId = extractionItemDefinitionId; + ExtractionQuantity = extractionQuantity; + MainlineResourceName = mainlineResourceName; + MainlineQuantity = mainlineQuantity; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMetaExchangeDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMetaExchangeDefinition.cs.meta new file mode 100644 index 0000000..81c08bc --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionMetaExchangeDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7ebcfe2c0de67364b840a6855dec6e70 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionOperationJournal.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionOperationJournal.cs new file mode 100644 index 0000000..eaffe41 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionOperationJournal.cs @@ -0,0 +1,257 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + public enum ExtractionOperationState + { + Prepared = 0, + Committed = 1, + Completed = 2, + Compensated = 3, + CompensationPending = 4 + } + + public enum ExtractionOperationReplayAction + { + Conflict = 0, + ApplyDomain = 1, + ReplayPresentation = 2, + NoOp = 3, + ResumeCompensation = 4 + } + + [Serializable] + public class ExtractionOperationJournal + { + public List Entries = new(); + + internal void EnsureInitialized() + { + Entries ??= new List(); + } + } + + [Serializable] + public class ExtractionOperationJournalEntry + { + public string OperationId; + public string OperationType; + public string State; + public string PayloadHash; + + public ExtractionOperationJournalEntry( + string operationId, + string operationType, + string state, + string payloadHash) + { + OperationId = operationId; + OperationType = operationType; + State = state; + PayloadHash = payloadHash; + } + + public ExtractionOperationJournalEntry( + string operationId, + string operationType, + ExtractionOperationState state, + string payloadHash) + : this( + operationId, + operationType, + ExtractionOperationStateCodec.ToSerializedValue(state), + payloadHash) + { + } + + public bool TryGetState(out ExtractionOperationState state) + { + return ExtractionOperationStateCodec.TryParse(State, out state); + } + } + + public static class ExtractionOperationJournalService + { + public static bool TryPrepare( + ExtractionOperationJournal journal, + string operationId, + string operationType, + string payloadHash, + out ExtractionOperationJournalEntry entry, + out ExtractionOperationReplayAction replayAction) + { + entry = null; + replayAction = ExtractionOperationReplayAction.Conflict; + if (journal == null + || string.IsNullOrEmpty(operationId) + || string.IsNullOrEmpty(operationType) + || string.IsNullOrEmpty(payloadHash)) + { + return false; + } + + journal.EnsureInitialized(); + if (!TryFindUniqueEntry(journal, operationId, out entry)) + return false; + + if (entry == null) + { + entry = new ExtractionOperationJournalEntry( + operationId, + operationType, + ExtractionOperationState.Prepared, + payloadHash); + journal.Entries.Add(entry); + replayAction = ExtractionOperationReplayAction.ApplyDomain; + return true; + } + + if (!string.Equals(entry.OperationType, operationType, StringComparison.Ordinal) + || !string.Equals(entry.PayloadHash, payloadHash, StringComparison.Ordinal) + || !entry.TryGetState(out var state)) + { + return false; + } + + switch (state) + { + case ExtractionOperationState.Prepared: + replayAction = ExtractionOperationReplayAction.ApplyDomain; + return true; + case ExtractionOperationState.Committed: + replayAction = ExtractionOperationReplayAction.ReplayPresentation; + return true; + case ExtractionOperationState.Completed: + case ExtractionOperationState.Compensated: + replayAction = ExtractionOperationReplayAction.NoOp; + return true; + case ExtractionOperationState.CompensationPending: + replayAction = ExtractionOperationReplayAction.ResumeCompensation; + return true; + default: + return false; + } + } + + public static bool TryMarkCommitted(ExtractionOperationJournalEntry entry) + { + if (entry == null || !entry.TryGetState(out var state)) return false; + if (state == ExtractionOperationState.Compensated) return false; + if (state == ExtractionOperationState.Committed || state == ExtractionOperationState.Completed) + return true; + if (state != ExtractionOperationState.Prepared) return false; + + entry.State = ExtractionOperationStateCodec.ToSerializedValue(ExtractionOperationState.Committed); + return true; + } + + public static bool TryMarkCompleted(ExtractionOperationJournalEntry entry) + { + if (entry == null || !entry.TryGetState(out var state)) return false; + if (state == ExtractionOperationState.Completed) return true; + if (state != ExtractionOperationState.Committed) return false; + + entry.State = ExtractionOperationStateCodec.ToSerializedValue(ExtractionOperationState.Completed); + return true; + } + + public static bool TryMarkCompensated(ExtractionOperationJournalEntry entry) + { + if (entry == null || !entry.TryGetState(out var state)) return false; + if (state == ExtractionOperationState.Compensated) return true; + if (state == ExtractionOperationState.Completed) return false; + if (state != ExtractionOperationState.Prepared + && state != ExtractionOperationState.Committed + && state != ExtractionOperationState.CompensationPending) + return false; + + entry.State = ExtractionOperationStateCodec.ToSerializedValue(ExtractionOperationState.Compensated); + return true; + } + + public static bool TryMarkCompensationPending(ExtractionOperationJournalEntry entry) + { + if (entry == null || !entry.TryGetState(out var state)) return false; + if (state == ExtractionOperationState.CompensationPending) return true; + if (state != ExtractionOperationState.Prepared && state != ExtractionOperationState.Committed) + return false; + + entry.State = ExtractionOperationStateCodec.ToSerializedValue(ExtractionOperationState.CompensationPending); + return true; + } + + private static bool TryFindUniqueEntry( + ExtractionOperationJournal journal, + string operationId, + out ExtractionOperationJournalEntry result) + { + result = null; + foreach (var candidate in journal.Entries) + { + if (candidate == null + || !string.Equals(candidate.OperationId, operationId, StringComparison.Ordinal)) + { + continue; + } + + if (result != null) + { + result = null; + return false; + } + + result = candidate; + } + + return true; + } + } + + internal static class ExtractionOperationStateCodec + { + internal static string ToSerializedValue(ExtractionOperationState state) + { + switch (state) + { + case ExtractionOperationState.Prepared: + return "Prepared"; + case ExtractionOperationState.Committed: + return "Committed"; + case ExtractionOperationState.Completed: + return "Completed"; + case ExtractionOperationState.Compensated: + return "Compensated"; + case ExtractionOperationState.CompensationPending: + return "CompensationPending"; + default: + throw new ArgumentOutOfRangeException(nameof(state), state, null); + } + } + + internal static bool TryParse(string value, out ExtractionOperationState state) + { + switch (value) + { + case "Prepared": + state = ExtractionOperationState.Prepared; + return true; + case "Committed": + state = ExtractionOperationState.Committed; + return true; + case "Completed": + state = ExtractionOperationState.Completed; + return true; + case "Compensated": + state = ExtractionOperationState.Compensated; + return true; + case "CompensationPending": + state = ExtractionOperationState.CompensationPending; + return true; + default: + state = default; + return false; + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionOperationJournal.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionOperationJournal.cs.meta new file mode 100644 index 0000000..9768755 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionOperationJournal.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7e8cd8888b4c486099a413fece7f86de +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionOwnershipLedger.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionOwnershipLedger.cs new file mode 100644 index 0000000..99ac287 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionOwnershipLedger.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionOwnershipLedger + { + public List Entries = new(); + + public int Count => Entries.Count; + + public bool Register(string itemInstanceId, ExtractionInventoryContainerType container) + { + return Register(itemInstanceId, container, null, null); + } + + public bool Register( + string itemInstanceId, + ExtractionInventoryContainerType container, + string locationSubtype, + string locationId) + { + if (string.IsNullOrEmpty(itemInstanceId)) return false; + if (FindEntry(itemInstanceId) != null) return false; + Entries.Add(new ExtractionOwnershipEntry(itemInstanceId, container, locationSubtype, locationId)); + return true; + } + + public bool TryGetContainer(string itemInstanceId, out ExtractionInventoryContainerType container) + { + var entry = FindEntry(itemInstanceId); + if (entry == null) + { + container = default; + return false; + } + + container = entry.Container; + return true; + } + + public ExtractionInventoryContainerType GetRequiredContainer(string itemInstanceId) + { + if (!TryGetContainer(itemInstanceId, out var container)) + throw new InvalidOperationException($"Extraction item '{itemInstanceId}' is not tracked."); + return container; + } + + public bool TryMove( + string itemInstanceId, + ExtractionInventoryContainerType expected, + ExtractionInventoryContainerType target) + { + return TryMove(itemInstanceId, expected, target, null, null); + } + + public bool TryMove( + string itemInstanceId, + ExtractionInventoryContainerType expected, + ExtractionInventoryContainerType target, + string targetLocationSubtype, + string targetLocationId) + { + var entry = FindEntry(itemInstanceId); + if (entry == null) return false; + if (entry.Container != expected) return false; + entry.Container = target; + entry.LocationSubtype = targetLocationSubtype; + entry.LocationId = targetLocationId; + return true; + } + + public bool TryRemove(string itemInstanceId) + { + if (string.IsNullOrEmpty(itemInstanceId)) return false; + + for (int i = 0; i < Entries.Count; i++) + { + if (Entries[i].ItemInstanceId != itemInstanceId) continue; + Entries.RemoveAt(i); + return true; + } + + return false; + } + + private ExtractionOwnershipEntry FindEntry(string itemInstanceId) + { + foreach (var entry in Entries) + { + if (entry.ItemInstanceId == itemInstanceId) return entry; + } + + return null; + } + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionOwnershipEntry + { + public string ItemInstanceId; + public ExtractionInventoryContainerType Container; + public string LocationSubtype; + public string LocationId; + + public ExtractionOwnershipEntry(string itemInstanceId, ExtractionInventoryContainerType container) + : this(itemInstanceId, container, null, null) + { + } + + public ExtractionOwnershipEntry( + string itemInstanceId, + ExtractionInventoryContainerType container, + string locationSubtype, + string locationId) + { + ItemInstanceId = itemInstanceId; + Container = container; + LocationSubtype = locationSubtype; + LocationId = locationId; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionOwnershipLedger.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionOwnershipLedger.cs.meta new file mode 100644 index 0000000..a06de4a --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionOwnershipLedger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9bc272df08f75fc4989129ed057b8ad9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPickupAdapterRequest.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPickupAdapterRequest.cs new file mode 100644 index 0000000..823cf97 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPickupAdapterRequest.cs @@ -0,0 +1,38 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionPickupAdapterRequest + { + public string InstanceId; + public string DefinitionId; + public int Quantity; + public bool CanEnterSecureContainer; + public string SourceKind; + public string SourceId; + + public bool IsValid => + !string.IsNullOrEmpty(InstanceId) + && !string.IsNullOrEmpty(DefinitionId) + && Quantity > 0; + + public ExtractionPickupAdapterRequest( + string instanceId, + string definitionId, + int quantity, + bool canEnterSecureContainer, + string sourceKind, + string sourceId) + { + InstanceId = instanceId; + DefinitionId = definitionId; + Quantity = quantity; + CanEnterSecureContainer = canEnterSecureContainer; + SourceKind = sourceKind; + SourceId = sourceId; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPickupAdapterRequest.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPickupAdapterRequest.cs.meta new file mode 100644 index 0000000..f2a98b9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPickupAdapterRequest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1a88a2d3c8336124682008ef98ddb3a5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPickupTarget.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPickupTarget.cs new file mode 100644 index 0000000..02bee97 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPickupTarget.cs @@ -0,0 +1,8 @@ +namespace POB.Extraction +{ + public enum ExtractionPickupTarget + { + RaidBackpack, + SecureContainer + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPickupTarget.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPickupTarget.cs.meta new file mode 100644 index 0000000..96db659 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPickupTarget.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aea459bf48dc4fd99399ee8fd3d04eb9 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPlayableConfig.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPlayableConfig.cs new file mode 100644 index 0000000..1644bbd --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPlayableConfig.cs @@ -0,0 +1,418 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public class ExtractionPlayableConfig : IExtractionItemCatalog, IExtractionLootTableCatalog + { + public int RaidBackpackWidth; + public int RaidBackpackHeight; + public int SecureContainerWidth; + public int SecureContainerHeight; + public ExtractionCharacterConfig Character = new(); + public List Maps = new(); + public List ExtractionPoints = new(); + public List ItemDefinitions = new(); + public List LootTables = new(); + public List SafeDefinitions = new(); + public List HostileExplorerEncounters = new(); + public List BuybackQuoteDefinitions = new(); + public List FacilityDefinitions = new(); + public List IntelHintDefinitions = new(); + public List MerchantOfferDefinitions = new(); + public List MerchantOfferPools = new(); + public List MerchantSlots = new(); + public ExtractionMerchantRotationSettings MerchantRotation = new(); + public List CraftingRecipeDefinitions = new(); + public List TrainingPassiveDefinitions = new(); + public List MedicalTreatmentDefinitions = new(); + public List BlueprintDefinitions = new(); + public List MetaExchangeDefinitions = new(); + + // C2 内容配置:旧配置不声明任何一项时继续走既有地图 lootTableIds 路径; + // 只要开始声明,就由 ExtractionLootContentConfigValidator 校验整套引用。 + public List ContentTiers = new(); + public List LootProfiles = new(); + public List LootRegions = new(); + public List ContainerDefinitions = new(); + public List ContainerSpawns = new(); + public List LockDefinitions = new(); + public List LeverDefinitions = new(); + public List WeaponGrowthDefinitions = new(); + + // M2 SW.2:负重容量(总重达到此值=100%负重)+档位表;容量默认 0(=不参与负重系统, + // TrySelectTier 对 capacity<=0 直接返回 false),向后兼容不配负重的旧配置/mod。 + public int EncumbranceCapacity; + public List EncumbranceTiers = new(); + + // M2 SD2.1:难度档位数值。Default/Normal 两档语义是现状行为,SD2.2 直接按枚举分支硬编码 + // 判定,不需要额外可调数值;这里只保存"非默认档"真正需要读取的数值。默认值与 D-M2-6 + // 决策一致(宽松丢一半、敌伤×0.5/×1.5),向后兼容不配这些字段的旧配置/mod。 + public float DifficultyCorpseLossLenientFraction = 0.5f; + public float DifficultyEnemyDamageHalfMultiplier = 0.5f; + public float DifficultyEnemyDamageHardMultiplier = 1.5f; + + public ExtractionPlayableConfig( + int raidBackpackWidth, + int raidBackpackHeight, + int secureContainerWidth, + int secureContainerHeight) + { + RaidBackpackWidth = raidBackpackWidth; + RaidBackpackHeight = raidBackpackHeight; + SecureContainerWidth = secureContainerWidth; + SecureContainerHeight = secureContainerHeight; + } + + public bool TryGetMap(string mapId, out ExtractionMapDefinition map) + { + foreach (var candidate in Maps) + { + if (candidate != null && candidate.MapId == mapId) + { + map = candidate; + return true; + } + } + + map = null; + return false; + } + + public bool TryGetExtractionPoint(string pointId, out ExtractionPointDefinition point) + { + return TryGetExtractionPoint(pointId, string.Empty, out point); + } + + public bool TryGetExtractionPoint( + string pointId, + string mapId, + out ExtractionPointDefinition point) + { + foreach (var candidate in ExtractionPoints) + { + if (candidate == null || !candidate.IsValid) continue; + if (candidate.PointId != pointId) continue; + if (candidate.MapId == mapId && !string.IsNullOrEmpty(candidate.MapId)) + { + point = candidate; + return true; + } + } + + foreach (var candidate in ExtractionPoints) + { + if (candidate == null || !candidate.IsValid) continue; + if (candidate.PointId != pointId) continue; + if (!string.IsNullOrEmpty(candidate.MapId)) continue; + + point = candidate; + return true; + } + + point = null; + return false; + } + + public bool TryGetMapExtractionPoints(string mapId, List results) + { + if (results == null) return false; + results.Clear(); + if (!TryGetMap(mapId, out var map) || map == null || !map.IsValid) return false; + + foreach (var candidate in ExtractionPoints) + { + if (candidate == null || !candidate.IsValid) continue; + if (candidate.MapId != mapId || string.IsNullOrEmpty(candidate.MapId)) continue; + AddUniqueExtractionPoint(candidate, results); + } + + foreach (var candidate in ExtractionPoints) + { + if (candidate == null || !candidate.IsValid) continue; + if (!string.IsNullOrEmpty(candidate.MapId)) continue; + AddUniqueExtractionPoint(candidate, results); + } + + return results.Count > 0; + } + + public bool TryGetItemDefinition(string definitionId, out ExtractionItemDefinition definition) + { + foreach (var candidate in ItemDefinitions) + { + if (candidate != null && candidate.DefinitionId == definitionId) + { + definition = candidate; + return true; + } + } + + definition = null; + return false; + } + + public bool TryGetBlueprintDefinition(string blueprintId, out ExtractionBlueprintDefinition blueprint) + { + foreach (var candidate in BlueprintDefinitions) + { + if (candidate != null && candidate.BlueprintId == blueprintId) + { + blueprint = candidate; + return true; + } + } + + blueprint = null; + return false; + } + + public bool TryGetLootTable(string tableId, out ExtractionLootTableDefinition table) + { + table = null; + if (string.IsNullOrEmpty(tableId)) return false; + + foreach (var candidate in LootTables) + { + if (candidate == null || !candidate.IsValid) continue; + if (candidate.TableId != tableId) continue; + + table = candidate; + return true; + } + + return false; + } + + public bool TryGetMapLootTables(string mapId, List results) + { + if (results == null) return false; + results.Clear(); + if (!TryGetMap(mapId, out var map) || map == null || !map.IsValid) return false; + if (map.LootTableIds == null) return false; + + foreach (var tableId in map.LootTableIds) + { + if (!TryGetLootTable(tableId, out var table)) continue; + if (ContainsLootTable(results, table.TableId)) continue; + results.Add(table); + } + + return results.Count > 0; + } + + private static void AddUniqueExtractionPoint( + ExtractionPointDefinition point, + List results) + { + foreach (var result in results) + { + if (result != null && result.PointId == point.PointId) return; + } + + results.Add(point); + } + + private static bool ContainsLootTable( + List results, + string tableId) + { + foreach (var table in results) + { + if (table != null && table.TableId == tableId) return true; + } + + return false; + } + + public bool TryGetMapSpawnPointIds(string mapId, List results) + { + if (!TryGetValidMapIdList(mapId, results, out var map)) return false; + AddUniqueIds(map.SpawnPointIds, results); + return results.Count > 0; + } + + public bool TryGetMapCorpseRegionIds(string mapId, List results) + { + if (!TryGetValidMapIdList(mapId, results, out var map)) return false; + AddUniqueIds(map.CorpseRegionIds, results); + return results.Count > 0; + } + + public bool TrySelectMapCorpseRegionId(string mapId, int seed, out string regionId) + { + regionId = null; + var results = new List(); + if (!TryGetMapCorpseRegionIds(mapId, results)) return false; + + int index = seed % results.Count; + if (index < 0) index += results.Count; + regionId = results[index]; + return true; + } + + private bool TryGetValidMapIdList( + string mapId, + List results, + out ExtractionMapDefinition map) + { + map = null; + if (results == null) return false; + results.Clear(); + return TryGetMap(mapId, out map) && map != null && map.IsValid; + } + + private static void AddUniqueIds(List source, List results) + { + if (source == null) return; + + foreach (var id in source) + { + if (string.IsNullOrEmpty(id)) continue; + if (results.Contains(id)) continue; + results.Add(id); + } + } + + public bool TryGetFacilityDefinitions( + ExtractionFacilityType facilityType, + List results) + { + if (results == null) return false; + results.Clear(); + + foreach (var candidate in FacilityDefinitions) + { + if (candidate == null || !candidate.IsValid) continue; + if (candidate.FacilityType != facilityType) continue; + results.Add(candidate); + } + + return results.Count > 0; + } + + public bool TryGetNextFacilityUpgradePlan( + ExtractionProfileSaveData profile, + ExtractionFacilityType facilityType, + IExtractionMetaProgressProvider metaProgress, + out ExtractionFacilityUpgradePlan plan) + { + return ExtractionFacilityUpgradePlanService.TryGetNextUpgradePlan( + profile, + FacilityDefinitions, + facilityType, + metaProgress, + out plan); + } + + public bool TryGetAvailableIntelHints( + ExtractionProfileSaveData profile, + string mapId, + IExtractionMetaProgressProvider metaProgress, + List results) + { + return ExtractionIntelHintService.TryGetAvailableHints( + profile, + IntelHintDefinitions, + mapId, + metaProgress, + results); + } + + public bool TryGetAvailableMerchantOffers( + ExtractionProfileSaveData profile, + IExtractionMetaProgressProvider metaProgress, + List results) + { + return ExtractionMerchantOfferService.TryGetAvailableOffers( + profile, + MerchantOfferDefinitions, + this, + metaProgress, + results); + } + + public bool TryGetAvailableCraftingRecipes( + ExtractionProfileSaveData profile, + IExtractionMetaProgressProvider metaProgress, + List results) + { + return ExtractionCraftingRecipeService.TryGetAvailableRecipes( + profile, + CraftingRecipeDefinitions, + this, + metaProgress, + results); + } + + public bool TryGetAvailableTrainingPassives( + ExtractionProfileSaveData profile, + IExtractionMetaProgressProvider metaProgress, + List results) + { + return ExtractionTrainingPassiveService.TryGetAvailablePassives( + profile, + TrainingPassiveDefinitions, + metaProgress, + results); + } + + public bool TryGetAvailableMedicalTreatments( + ExtractionProfileSaveData profile, + string conditionId, + IExtractionMetaProgressProvider metaProgress, + List results) + { + return ExtractionMedicalTreatmentService.TryGetAvailableTreatments( + profile, + MedicalTreatmentDefinitions, + this, + conditionId, + metaProgress, + results); + } + + public bool TryGetSafeDefinitions(string mapId, List results) + { + return ExtractionSafeLootService.TryGetSafeDefinitions(SafeDefinitions, mapId, results); + } + + public bool TryGetBuybackQuote( + ExtractionProfileSaveData profile, + string itemInstanceId, + out ExtractionBuybackQuote quote) + { + return ExtractionBuybackQuoteService.TryGetQuote( + profile, + BuybackQuoteDefinitions, + itemInstanceId, + out quote); + } + + public bool TryGetHostileExplorerCandidates( + string mapId, + int threatLevel, + List results) + { + return ExtractionHostileExplorerSelectionService.TryGetCandidates( + HostileExplorerEncounters, + mapId, + threatLevel, + results); + } + + public bool TrySelectHostileExplorerEncounter( + string mapId, + int threatLevel, + int seed, + out ExtractionHostileExplorerDefinition encounter) + { + encounter = null; + var candidates = new List(); + if (!TryGetHostileExplorerCandidates(mapId, threatLevel, candidates)) return false; + return ExtractionHostileExplorerSelectionService.TrySelectCandidateBySeed( + candidates, + seed, + out encounter); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPlayableConfig.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPlayableConfig.cs.meta new file mode 100644 index 0000000..2159940 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPlayableConfig.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a19c7fb55f9845079b6a9903a432b78f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPointDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPointDefinition.cs new file mode 100644 index 0000000..17e6d7a --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPointDefinition.cs @@ -0,0 +1,63 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionPointDefinition + { + public string PointId; + public string MapId; + public int ChannelSeconds; + public bool DefaultOpen; + public int OpenAtElapsedSeconds; + public bool SingleUse; + public bool AllowEmergencyExtractionOverride; + public string RequiredItemDefinitionId; + public bool ConsumeRequiredItemOnExtraction; + public string RequiredRaidFlagId; + + public bool IsValid => + !string.IsNullOrEmpty(PointId) + && ChannelSeconds > 0 + && OpenAtElapsedSeconds >= 0 + && (!ConsumeRequiredItemOnExtraction || !string.IsNullOrEmpty(RequiredItemDefinitionId)); + + public ExtractionPointDefinition( + string pointId, + int channelSeconds, + bool defaultOpen, + int openAtElapsedSeconds, + bool singleUse, + bool allowEmergencyExtractionOverride) + : this( + pointId, + string.Empty, + channelSeconds, + defaultOpen, + openAtElapsedSeconds, + singleUse, + allowEmergencyExtractionOverride) + { + } + + public ExtractionPointDefinition( + string pointId, + string mapId, + int channelSeconds, + bool defaultOpen, + int openAtElapsedSeconds, + bool singleUse, + bool allowEmergencyExtractionOverride) + { + PointId = pointId; + MapId = mapId; + ChannelSeconds = channelSeconds; + DefaultOpen = defaultOpen; + OpenAtElapsedSeconds = openAtElapsedSeconds; + SingleUse = singleUse; + AllowEmergencyExtractionOverride = allowEmergencyExtractionOverride; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPointDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPointDefinition.cs.meta new file mode 100644 index 0000000..eb38945 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionPointDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e1a8a1b5b5344c14aee3f98e3c38a479 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileBlobStore.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileBlobStore.cs new file mode 100644 index 0000000..fe1fa5c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileBlobStore.cs @@ -0,0 +1,59 @@ +namespace POB.Extraction +{ + public interface IExtractionProfileBlobStore + { + ExtractionProfileBlobLoadResult Load(); + bool TryLoad(out string json); + ExtractionProfileBlobCommitResult Save(string json); + ExtractionProfileBlobCommitResult TryCommit(string expectedRevision, string json); + } + + public class ExtractionInMemoryProfileBlobStore : IExtractionProfileBlobStore + { + private string json; + private bool hasProfile; + + public bool HasProfile => hasProfile; + + public ExtractionProfileBlobLoadResult Load() + { + return !hasProfile + ? ExtractionProfileBlobLoadResult.Missing() + : ExtractionProfileBlobLoadResult.Loaded(json); + } + + public bool TryLoad(out string json) + { + var result = Load(); + json = result.Json; + return result.Success && result.Found; + } + + public ExtractionProfileBlobCommitResult Save(string json) + { + return TryCommit(Load().Revision, json); + } + + public ExtractionProfileBlobCommitResult TryCommit(string expectedRevision, string json) + { + string currentRevision = Load().Revision; + if (!string.Equals(expectedRevision, currentRevision, System.StringComparison.Ordinal)) + { + return ExtractionProfileBlobCommitResult.Failed( + ExtractionProfileCommitFailure.RevisionConflict, + "The profile blob changed after it was loaded."); + } + + if (json == null) + { + return ExtractionProfileBlobCommitResult.Failed( + ExtractionProfileCommitFailure.PrepareFailed, + "Profile JSON is required."); + } + + this.json = json; + hasProfile = true; + return ExtractionProfileBlobCommitResult.Succeeded(ExtractionProfileRevision.FromJson(json)); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileBlobStore.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileBlobStore.cs.meta new file mode 100644 index 0000000..9d7debc --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileBlobStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6f4c46c5327447539961ffd981086214 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileInventoryService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileInventoryService.cs new file mode 100644 index 0000000..42f95f7 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileInventoryService.cs @@ -0,0 +1,58 @@ +namespace POB.Extraction +{ + public static class ExtractionProfileInventoryService + { + public static bool TryMoveItem( + ExtractionProfileSaveData profile, + string itemInstanceId, + ExtractionInventoryContainerType source, + ExtractionInventoryContainerType target, + ExtractionItemDefinition definition, + int x, + int y, + bool rotated) + { + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || string.IsNullOrEmpty(itemInstanceId) || definition == null) return false; + if (source == target) return false; + + profile.EnsureInitialized(); + if (!profile.Items.TryGet(itemInstanceId, out var item)) return false; + if (!profile.Ownership.TryGetContainer(itemInstanceId, out var current) || current != source) + return false; + if (!TryGetGrid(profile, source, out var sourceGrid)) return false; + if (!TryGetGrid(profile, target, out var targetGrid)) return false; + if (!sourceGrid.TryGetPlacement(itemInstanceId, out var sourcePlacement)) return false; + if (!targetGrid.TryPlace(item, definition, x, y, rotated)) return false; + + if (!sourceGrid.TryRemove(itemInstanceId)) + { + targetGrid.TryRemove(itemInstanceId); + return false; + } + + if (profile.Ownership.TryMove(itemInstanceId, source, target)) return true; + + targetGrid.TryRemove(itemInstanceId); + sourceGrid.TryPlace(item, definition, sourcePlacement.X, sourcePlacement.Y, sourcePlacement.Rotated); + return false; + } + + private static bool TryGetGrid( + ExtractionProfileSaveData profile, + ExtractionInventoryContainerType container, + out ExtractionItemGrid grid) + { + grid = container switch + { + ExtractionInventoryContainerType.Stash => profile.Stash, + ExtractionInventoryContainerType.Loadout => profile.Loadout, + ExtractionInventoryContainerType.SecureContainer => profile.SecureContainer, + ExtractionInventoryContainerType.Holding => profile.RecoveryHolding, + _ => null + }; + + return grid != null; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileInventoryService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileInventoryService.cs.meta new file mode 100644 index 0000000..cc53896 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileInventoryService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 100f8e9442ed4a41bf4ecdc6091b6833 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileMigration.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileMigration.cs new file mode 100644 index 0000000..ffd74cc --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileMigration.cs @@ -0,0 +1,82 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionProfileMigration + { + public const string CarryGridLocationSubtype = "carry-grid"; + + public static void MigrateToCurrent(ExtractionProfileSaveData profile) + { + if (profile == null || profile.SchemaVersion > ExtractionProfileSaveData.CurrentSchemaVersion) + return; + + EnsureRoots(profile); + EnsureItemState(profile); + EnsureOwnershipLocations(profile); + profile.ActiveRaid?.EnsureInitialized(); + profile.ActiveRaidInventory?.EnsureInitialized(); + + if (string.IsNullOrEmpty(profile.activeRaidId)) + { + profile.ActiveRaid = null; + profile.ActiveRaidElapsedSeconds = 0f; + profile.ActiveRaidInventory = null; + } + + profile.SchemaVersion = ExtractionProfileSaveData.CurrentSchemaVersion; + } + + private static void EnsureRoots(ExtractionProfileSaveData profile) + { + profile.Items ??= new ExtractionItemRegistry(); + profile.Items.Entries ??= new List(); + profile.Stash ??= new ExtractionItemGrid(10, 6); + profile.CarryGrid ??= new ExtractionItemGrid(6, 4); + profile.SecureContainer ??= new ExtractionItemGrid(2, 2); + profile.RecoveryHolding ??= new ExtractionItemGrid(10, 4); + profile.Ownership ??= new ExtractionOwnershipLedger(); + profile.Ownership.Entries ??= new List(); + profile.Recovery ??= new ExtractionRecoveryLedger(); + profile.CorpseLoot ??= new ExtractionCorpseLootLedger(); + profile.Facilities ??= new ExtractionFacilityProfile(); + profile.MedicalTreatments ??= new ExtractionMedicalTreatmentLedger(); + profile.UnlockedMapIds ??= new List(); + profile.UnlockedBlueprintIds ??= new List(); + profile.DifficultySettings ??= new ExtractionDifficultySettings(); + profile.Character ??= new ExtractionCharacterProfile(); + profile.Character.EnsureInitialized(); + profile.Equipment ??= new ExtractionEquipmentState(); + profile.Equipment.EnsureInitialized(); + profile.Merchant ??= new ExtractionMerchantState(); + profile.Merchant.EnsureInitialized(); + profile.OperationJournal ??= new ExtractionOperationJournal(); + profile.OperationJournal.EnsureInitialized(); + profile.ItemActionReceiptIds ??= new List(); + } + + private static void EnsureItemState(ExtractionProfileSaveData profile) + { + foreach (var item in profile.Items.Entries) + item?.EnsureInitialized(); + } + + private static void EnsureOwnershipLocations(ExtractionProfileSaveData profile) + { + foreach (var entry in profile.Ownership.Entries) + { + if (entry == null + || entry.Container != ExtractionInventoryContainerType.Loadout + || !string.IsNullOrEmpty(entry.LocationSubtype)) + { + continue; + } + + // v0/v1 的 Loadout 语义无法判断具体装备位;只标记为安全携行网格, + // Equipment 保持空,后续 C3 只能通过显式装备事务安装效果。 + entry.LocationSubtype = CarryGridLocationSubtype; + entry.LocationId = null; + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileMigration.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileMigration.cs.meta new file mode 100644 index 0000000..dd8ad5f --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileMigration.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2398ad0e5c014fa5945f1d9e84672849 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileSaveData.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileSaveData.cs new file mode 100644 index 0000000..ed898e9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileSaveData.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Serialization; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionProfileSaveData + { + public const int CurrentSchemaVersion = 2; + + public int SchemaVersion; + public string activeRaidId; + public ExtractionRaidSession ActiveRaid; + public float ActiveRaidElapsedSeconds; + public ExtractionRaidInventoryState ActiveRaidInventory; + public ExtractionItemRegistry Items = new(); + public ExtractionItemGrid Stash = new(10, 6); + [FormerlySerializedAs("Loadout")] + public ExtractionItemGrid CarryGrid = new(6, 4); + public ExtractionItemGrid SecureContainer = new(2, 2); + public ExtractionItemGrid RecoveryHolding = new(10, 4); + public ExtractionOwnershipLedger Ownership = new(); + public ExtractionRecoveryLedger Recovery = new(); + public ExtractionCorpseLootLedger CorpseLoot = new(); + public ExtractionFacilityProfile Facilities = new(); + public ExtractionMedicalTreatmentLedger MedicalTreatments = new(); + public List UnlockedMapIds = new(); + public List UnlockedBlueprintIds = new(); + public ExtractionDifficultySettings DifficultySettings = new(); + public ExtractionCharacterProfile Character = new(); + public ExtractionEquipmentState Equipment = new(); + public ExtractionMerchantState Merchant = new(); + public ExtractionOperationJournal OperationJournal = new(); + public List ItemActionReceiptIds = new(); + + // 兼容既有调用方;v2 JSON 只序列化 CarryGrid。旧 Unity 序列化数据由 + // FormerlySerializedAs 兼容,旧 JSON 则由 ExtractionProfileSerialization 的 + // v1 envelope 确定性读入同一网格,不保留第二份位置状态。 + public ExtractionItemGrid Loadout + { + get => CarryGrid; + set => CarryGrid = value; + } + + public static ExtractionProfileSaveData CreateEmpty() + { + var profile = new ExtractionProfileSaveData(); + profile.EnsureInitialized(); + return profile; + } + + public void EnsureInitialized() + { + ExtractionProfileMigration.MigrateToCurrent(this); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileSaveData.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileSaveData.cs.meta new file mode 100644 index 0000000..540570c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileSaveData.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: b235985fc616ca546b124b9dff60c2a6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileSerialization.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileSerialization.cs new file mode 100644 index 0000000..8c36f44 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileSerialization.cs @@ -0,0 +1,37 @@ +using System; +using UnityEngine; + +namespace POB.Extraction +{ + public static class ExtractionProfileSerialization + { + public static string ToJson(ExtractionProfileSaveData profile) + { + return JsonUtility.ToJson(profile); + } + + public static ExtractionProfileSaveData FromJson(string json) + { + var profile = JsonUtility.FromJson(json); + if (profile == null) return null; + + // JsonUtility 不会为普通 JSON 应用 FormerlySerializedAs。v0/v1 先用仅含旧字段的 + // envelope 读取 Loadout,再交给唯一 migrator;v2 只保存 CarryGrid,不保留双份网格。 + if (profile.SchemaVersion < 2) + { + var legacy = JsonUtility.FromJson(json); + if (legacy?.Loadout != null) + profile.CarryGrid = legacy.Loadout; + } + + profile.EnsureInitialized(); + return profile; + } + + [Serializable] + private sealed class LegacyProfileV1Envelope + { + public ExtractionItemGrid Loadout; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileSerialization.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileSerialization.cs.meta new file mode 100644 index 0000000..83efe2a --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileSerialization.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9f8d485250294b12ba2eb94d0e370988 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileStoreContracts.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileStoreContracts.cs new file mode 100644 index 0000000..da4cbde --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileStoreContracts.cs @@ -0,0 +1,316 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace POB.Extraction +{ + public enum ExtractionProfileLoadFailure + { + None = 0, + IoFailure = 1, + CorruptData = 2, + UnsupportedSchema = 3 + } + + public enum ExtractionProfileCommitFailure + { + None = 0, + InvalidDraft = 1, + ReadOnly = 2, + PrepareFailed = 3, + WriteFailed = 4, + ReadbackFailed = 5, + ReplaceFailed = 6, + RevisionConflict = 7 + } + + public enum ExtractionProfileInMemoryCommitFault + { + None = 0, + Prepare = 1, + Commit = 2, + Readback = 3 + } + + public enum ExtractionProfileBlobCommitStage + { + Prepare = 0, + Write = 1, + Readback = 2, + Replace = 3 + } + + public sealed class ExtractionProfileLoadResult + { + private readonly ExtractionProfileSaveData profile; + + private ExtractionProfileLoadResult( + ExtractionProfileSaveData profile, + string revision, + bool isMissing, + bool isReadOnly, + ExtractionProfileLoadFailure failure, + string message) + { + this.profile = ExtractionProfileCloneUtility.Clone(profile); + Revision = string.IsNullOrEmpty(revision) ? ExtractionProfileRevision.Missing : revision; + IsMissing = isMissing; + IsReadOnly = isReadOnly; + Failure = failure; + Message = message ?? string.Empty; + } + + public ExtractionProfileSaveData Profile => ExtractionProfileCloneUtility.Clone(profile); + public string Revision { get; } + public bool IsMissing { get; } + public bool IsReadOnly { get; } + public ExtractionProfileLoadFailure Failure { get; } + public string Message { get; } + public bool Success => Failure == ExtractionProfileLoadFailure.None; + public bool CanCommit => Success && !IsReadOnly; + + public bool TryCreateDraft(out ExtractionProfileDraft draft) + { + return TryCreateDraft(profile, out draft); + } + + public bool TryCreateDraft(ExtractionProfileSaveData source, out ExtractionProfileDraft draft) + { + if (!CanCommit) + { + draft = null; + return false; + } + + draft = new ExtractionProfileDraft(Revision, source); + return true; + } + + public static ExtractionProfileLoadResult Loaded( + ExtractionProfileSaveData profile, + string revision, + bool isMissing = false) + { + return new ExtractionProfileLoadResult( + profile, + revision, + isMissing, + false, + ExtractionProfileLoadFailure.None, + string.Empty); + } + + public static ExtractionProfileLoadResult Failed( + ExtractionProfileSaveData profile, + string revision, + ExtractionProfileLoadFailure failure, + string message, + bool isReadOnly = true) + { + if (failure == ExtractionProfileLoadFailure.None) + throw new ArgumentException("A failed load requires a failure type.", nameof(failure)); + + return new ExtractionProfileLoadResult( + profile, + revision, + false, + isReadOnly, + failure, + message); + } + } + + public sealed class ExtractionProfileDraft + { + internal ExtractionProfileDraft(string baseRevision, ExtractionProfileSaveData profile) + { + BaseRevision = string.IsNullOrEmpty(baseRevision) + ? ExtractionProfileRevision.Missing + : baseRevision; + Profile = ExtractionProfileCloneUtility.Clone(profile); + } + + public string BaseRevision { get; } + public ExtractionProfileSaveData Profile { get; } + } + + public sealed class ExtractionProfileCommitResult + { + private ExtractionProfileCommitResult( + bool success, + ExtractionProfileCommitFailure failure, + string message, + ExtractionProfileLoadResult snapshot) + { + Success = success; + Failure = failure; + Message = message ?? string.Empty; + Snapshot = snapshot; + } + + public bool Success { get; } + public ExtractionProfileCommitFailure Failure { get; } + public string Message { get; } + public ExtractionProfileLoadResult Snapshot { get; } + public string Revision => Snapshot?.Revision ?? string.Empty; + + public static ExtractionProfileCommitResult Succeeded(ExtractionProfileLoadResult snapshot) + { + if (snapshot == null || !snapshot.Success) + throw new ArgumentException("A successful commit requires a successful snapshot.", nameof(snapshot)); + + return new ExtractionProfileCommitResult( + true, + ExtractionProfileCommitFailure.None, + string.Empty, + snapshot); + } + + public static ExtractionProfileCommitResult Failed( + ExtractionProfileCommitFailure failure, + string message, + ExtractionProfileLoadResult currentSnapshot = null) + { + if (failure == ExtractionProfileCommitFailure.None) + throw new ArgumentException("A failed commit requires a failure type.", nameof(failure)); + + return new ExtractionProfileCommitResult( + false, + failure, + message, + currentSnapshot); + } + } + + public sealed class ExtractionProfileBlobLoadResult + { + private ExtractionProfileBlobLoadResult( + bool success, + bool found, + string json, + string revision, + ExtractionProfileLoadFailure failure, + string message) + { + Success = success; + Found = found; + Json = json; + Revision = string.IsNullOrEmpty(revision) ? ExtractionProfileRevision.Missing : revision; + Failure = failure; + Message = message ?? string.Empty; + } + + public bool Success { get; } + public bool Found { get; } + public string Json { get; } + public string Revision { get; } + public ExtractionProfileLoadFailure Failure { get; } + public string Message { get; } + + public static ExtractionProfileBlobLoadResult Missing() + { + return new ExtractionProfileBlobLoadResult( + true, + false, + null, + ExtractionProfileRevision.Missing, + ExtractionProfileLoadFailure.None, + string.Empty); + } + + public static ExtractionProfileBlobLoadResult Loaded(string json) + { + return new ExtractionProfileBlobLoadResult( + true, + true, + json, + ExtractionProfileRevision.FromJson(json), + ExtractionProfileLoadFailure.None, + string.Empty); + } + + public static ExtractionProfileBlobLoadResult Failed( + ExtractionProfileLoadFailure failure, + string message) + { + return new ExtractionProfileBlobLoadResult( + false, + false, + null, + ExtractionProfileRevision.Missing, + failure, + message); + } + } + + public sealed class ExtractionProfileBlobCommitResult + { + private ExtractionProfileBlobCommitResult( + bool success, + ExtractionProfileCommitFailure failure, + string revision, + string message) + { + Success = success; + Failure = failure; + Revision = revision ?? string.Empty; + Message = message ?? string.Empty; + } + + public bool Success { get; } + public ExtractionProfileCommitFailure Failure { get; } + public string Revision { get; } + public string Message { get; } + + public static ExtractionProfileBlobCommitResult Succeeded(string revision) + { + return new ExtractionProfileBlobCommitResult( + true, + ExtractionProfileCommitFailure.None, + revision, + string.Empty); + } + + public static ExtractionProfileBlobCommitResult Failed( + ExtractionProfileCommitFailure failure, + string message) + { + return new ExtractionProfileBlobCommitResult( + false, + failure, + string.Empty, + message); + } + } + + public static class ExtractionProfileRevision + { + public const string Missing = "missing"; + + public static string FromJson(string json) + { + if (json == null) + return Missing; + + using var sha = SHA256.Create(); + byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(json)); + var builder = new StringBuilder("sha256:", 7 + hash.Length * 2); + foreach (byte value in hash) + builder.Append(value.ToString("x2")); + return builder.ToString(); + } + } + + internal static class ExtractionProfileCloneUtility + { + public static ExtractionProfileSaveData Clone(ExtractionProfileSaveData profile) + { + profile ??= ExtractionProfileSaveData.CreateEmpty(); + string json = ExtractionProfileSerialization.ToJson(profile); + var clone = ExtractionProfileSerialization.FromJson(json) + ?? ExtractionProfileSaveData.CreateEmpty(); + clone.EnsureInitialized(); + return clone; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileStoreContracts.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileStoreContracts.cs.meta new file mode 100644 index 0000000..b1663b5 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionProfileStoreContracts.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 46b8e903813b4b1cbe75fcb4241a2f06 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidInventoryState.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidInventoryState.cs new file mode 100644 index 0000000..2957f15 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidInventoryState.cs @@ -0,0 +1,30 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionRaidInventoryState + { + public ExtractionItemGrid RaidBackpack; + public ExtractionItemGrid SecureContainer; + public ExtractionEquipmentState Equipment = new(); + + public ExtractionRaidInventoryState( + int raidBackpackWidth, + int raidBackpackHeight, + int secureWidth, + int secureHeight) + { + RaidBackpack = new ExtractionItemGrid(raidBackpackWidth, raidBackpackHeight); + SecureContainer = new ExtractionItemGrid(secureWidth, secureHeight); + } + + internal void EnsureInitialized() + { + Equipment ??= new ExtractionEquipmentState(); + Equipment.EnsureInitialized(); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidInventoryState.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidInventoryState.cs.meta new file mode 100644 index 0000000..76e7b96 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidInventoryState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3da670f3f71547ef9fd157f9dc5cd8e4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidLootManifest.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidLootManifest.cs new file mode 100644 index 0000000..7b0cf76 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidLootManifest.cs @@ -0,0 +1,159 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + public enum ExtractionRaidLootManifestFailure + { + None = 0, + InvalidInput = 1, + MissingContentConfiguration = 2, + NoSpawnedContainers = 3, + InsufficientGuaranteedCapacity = 4, + MissingRarityCandidate = 5 + } + + public enum ExtractionContainerLootEntryState + { + CommittedHidden = 0, + Revealed = 1, + Transferred = 2 + } + + [Serializable] + public class ExtractionLootPityState + { + public int ConsecutiveMisses; + } + + [Serializable] + public class ExtractionContainerSearchState + { + public string ContainerId; + public string ResultReceiptId; + public int CurrentRevealOrder; + public float CurrentEntryElapsedSeconds; + public bool Paused = true; + public bool Completed; + + public ExtractionContainerSearchState(string containerId) + { + ContainerId = containerId; + } + } + + [Serializable] + public class ExtractionContainerLootEntry + { + public string EntryId; + public string ItemInstanceId; + public string DefinitionId; + public int Quantity; + public ExtractionItemRarity Rarity; + public bool Guaranteed; + public int RevealOrder = -1; + public ExtractionContainerLootEntryState State = ExtractionContainerLootEntryState.CommittedHidden; + public string RevealReceiptId; + public string TransferReceiptId; + + public ExtractionContainerLootEntry( + string entryId, + string itemInstanceId, + string definitionId, + int quantity, + ExtractionItemRarity rarity, + bool guaranteed) + { + EntryId = entryId; + ItemInstanceId = itemInstanceId; + DefinitionId = definitionId; + Quantity = quantity; + Rarity = rarity; + Guaranteed = guaranteed; + } + } + + [Serializable] + public class ExtractionRaidContainerManifest + { + public string ContainerId; + public string RegionId; + public string ContainerTypeId; + public int Capacity; + public int TargetContentCount; + public int MaximumContentCount; + public float SearchTimeMultiplier = 1f; + public string BonusGroupId; + public bool Active = true; + public bool Opened; + public int OpenSequence = -1; + public string OpenReceiptId; + public List Entries = new(); + public ExtractionContainerSearchState SearchState; + + public ExtractionRaidContainerManifest( + string containerId, + string regionId, + string containerTypeId, + int capacity, + int targetContentCount, + int maximumContentCount, + float searchTimeMultiplier) + { + ContainerId = containerId; + RegionId = regionId; + ContainerTypeId = containerTypeId; + Capacity = capacity; + TargetContentCount = targetContentCount; + MaximumContentCount = maximumContentCount; + SearchTimeMultiplier = searchTimeMultiplier; + SearchState = new ExtractionContainerSearchState(containerId); + } + + internal void EnsureInitialized() + { + Entries ??= new List(); + SearchState ??= new ExtractionContainerSearchState(ContainerId); + } + } + + [Serializable] + public class ExtractionRaidLootManifest + { + public string ManifestId; + public string LootProfileId; + public string ContentTierId; + public int RaidSeed; + public bool RareLootDisabled; + public int NextOpenSequence; + public string ActiveSearchContainerId; + public ExtractionLootPityState PityState = new(); + public List Containers = new(); + + public bool TryGetContainer(string containerId, out ExtractionRaidContainerManifest container) + { + if (!string.IsNullOrEmpty(containerId) && Containers != null) + { + foreach (var candidate in Containers) + { + if (candidate != null && candidate.ContainerId == containerId) + { + container = candidate; + return true; + } + } + } + + container = null; + return false; + } + + internal void EnsureInitialized() + { + PityState ??= new ExtractionLootPityState(); + Containers ??= new List(); + foreach (var container in Containers) + container?.EnsureInitialized(); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidLootManifest.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidLootManifest.cs.meta new file mode 100644 index 0000000..d4af967 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidLootManifest.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 3a52e71ea11849a4a8fedc4b1b0e7601 +timeCreated: 1784385600 diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidLootManifestGenerator.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidLootManifestGenerator.cs new file mode 100644 index 0000000..73a68d9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidLootManifestGenerator.cs @@ -0,0 +1,502 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace POB.Extraction +{ + public static class ExtractionRaidLootManifestGenerator + { + private const string ManifestHashDomain = "zeroengine.extraction.loot-manifest:v1"; + private const string SpawnHashDomain = "zeroengine.extraction.container-spawn:v1"; + private const string CandidateHashDomain = "zeroengine.extraction.container-candidate:v1"; + private const string CountHashDomain = "zeroengine.extraction.container-count:v1"; + private const string GuaranteeOrderHashDomain = "zeroengine.extraction.guarantee-order:v1"; + private const string GuaranteeItemHashDomain = "zeroengine.extraction.guarantee-item:v1"; + private const string EntryHashDomain = "zeroengine.extraction.loot-entry:v1"; + private const string InstanceHashDomain = "zeroengine.extraction.loot-instance:v1"; + + public static bool TryGenerate( + ExtractionPlayableConfig config, + ExtractionMapDefinition map, + int raidSeed, + bool rareLootDisabled, + out ExtractionRaidLootManifest manifest, + out ExtractionRaidLootManifestFailure failure) + { + manifest = null; + failure = ExtractionRaidLootManifestFailure.None; + if (config == null || map == null || !map.IsValid) + return Fail(ExtractionRaidLootManifestFailure.InvalidInput, out failure); + + if (string.IsNullOrEmpty(map.ContentTierId) && string.IsNullOrEmpty(map.LootProfileId)) + return true; + + if (!TryResolveContent(config, map, out var tier, out var profile)) + return Fail(ExtractionRaidLootManifestFailure.MissingContentConfiguration, out failure); + + var result = new ExtractionRaidLootManifest + { + ManifestId = ExtractionStableHash.ComputeSha256( + ManifestHashDomain, + map.MapId, + raidSeed.ToString(CultureInfo.InvariantCulture), + profile.LootProfileId, + rareLootDisabled ? "1" : "0"), + LootProfileId = profile.LootProfileId, + ContentTierId = tier.ContentTierId, + RaidSeed = raidSeed, + RareLootDisabled = rareLootDisabled + }; + + var spawns = ResolveProfileSpawns(config, profile); + foreach (var spawn in spawns) + { + if (!ShouldSpawn(spawn, raidSeed)) continue; + if (!TrySelectContainer(config, spawn, raidSeed, out var definition)) + return Fail(ExtractionRaidLootManifestFailure.MissingContentConfiguration, out failure); + + int maximum = Math.Min(definition.Capacity, definition.MaximumContentCount); + int target = RollInclusive( + definition.MinimumContentCount, + maximum, + CountHashDomain, + raidSeed, + spawn.SpawnId); + var manifestContainer = new ExtractionRaidContainerManifest( + spawn.SpawnId, + spawn.RegionId, + definition.ContainerTypeId, + definition.Capacity, + target, + maximum, + definition.SearchTimeMultiplier) + { + BonusGroupId = spawn.BonusGroupId, + Active = string.IsNullOrEmpty(spawn.BonusGroupId) + }; + result.Containers.Add(manifestContainer); + } + + if (result.Containers.Count == 0) + return Fail(ExtractionRaidLootManifestFailure.NoSpawnedContainers, out failure); + + if (!TryAssignGuarantees(config, tier, profile, result, out failure)) + return false; + + manifest = result; + return true; + } + + private static bool TryAssignGuarantees( + ExtractionPlayableConfig config, + ExtractionContentTierDefinition tier, + ExtractionLootProfileDefinition profile, + ExtractionRaidLootManifest manifest, + out ExtractionRaidLootManifestFailure failure) + { + failure = ExtractionRaidLootManifestFailure.None; + for (int rarityValue = (int)ExtractionItemRarity.Mythic; + rarityValue >= (int)ExtractionItemRarity.Common; + rarityValue--) + { + var rarity = (ExtractionItemRarity)rarityValue; + int required = ExtractionLootContentPolicy.GetMinimumGeneratedDrops( + profile, + rarity, + manifest.RareLootDisabled); + for (int index = 0; index < required; index++) + { + var candidates = GetGuaranteeContainerCandidates(config, manifest, rarity, index); + if (candidates.Count == 0) + { + return Fail( + HasAnyCandidateForRarity(config, manifest, rarity) + ? ExtractionRaidLootManifestFailure.InsufficientGuaranteedCapacity + : ExtractionRaidLootManifestFailure.MissingRarityCandidate, + out failure); + } + + var container = candidates[0]; + if (!TrySelectLootEntry( + config, + tier, + container.RegionId, + container.ContainerTypeId, + rarity, + manifest.RaidSeed, + GuaranteeItemHashDomain, + container.ContainerId, + index.ToString(CultureInfo.InvariantCulture), + out var tableEntry, + out var itemDefinition, + rareLootDisabled: manifest.RareLootDisabled)) + { + return Fail(ExtractionRaidLootManifestFailure.MissingRarityCandidate, out failure); + } + + string entryId = CreateStableId( + "entry:v1:", + EntryHashDomain, + manifest.ManifestId, + container.ContainerId, + ((int)rarity).ToString(CultureInfo.InvariantCulture), + index.ToString(CultureInfo.InvariantCulture)); + string instanceId = CreateStableId( + "item:v1:", + InstanceHashDomain, + manifest.ManifestId, + entryId); + container.Entries.Add(new ExtractionContainerLootEntry( + entryId, + instanceId, + tableEntry.DefinitionId, + tableEntry.Quantity, + itemDefinition.Rarity, + true)); + if (container.TargetContentCount < container.Entries.Count) + container.TargetContentCount = container.Entries.Count; + } + } + + return true; + } + + private static List GetGuaranteeContainerCandidates( + ExtractionPlayableConfig config, + ExtractionRaidLootManifest manifest, + ExtractionItemRarity rarity, + int guaranteeIndex) + { + var results = new List(); + foreach (var container in manifest.Containers) + { + if (container == null || !container.Active || container.Entries.Count >= container.MaximumContentCount) continue; + if (!ContainerHasRarityCandidate(config, container.ContainerTypeId, rarity, manifest.RareLootDisabled)) + continue; + results.Add(container); + } + + results.Sort((left, right) => + { + int leftHash = ExtractionStableHash.ComputeInt32( + GuaranteeOrderHashDomain, + manifest.RaidSeed.ToString(CultureInfo.InvariantCulture), + ((int)rarity).ToString(CultureInfo.InvariantCulture), + guaranteeIndex.ToString(CultureInfo.InvariantCulture), + left.ContainerId); + int rightHash = ExtractionStableHash.ComputeInt32( + GuaranteeOrderHashDomain, + manifest.RaidSeed.ToString(CultureInfo.InvariantCulture), + ((int)rarity).ToString(CultureInfo.InvariantCulture), + guaranteeIndex.ToString(CultureInfo.InvariantCulture), + right.ContainerId); + int comparison = unchecked((uint)leftHash).CompareTo(unchecked((uint)rightHash)); + return comparison != 0 + ? comparison + : string.CompareOrdinal(left.ContainerId, right.ContainerId); + }); + return results; + } + + private static bool HasAnyCandidateForRarity( + ExtractionPlayableConfig config, + ExtractionRaidLootManifest manifest, + ExtractionItemRarity rarity) + { + foreach (var container in manifest.Containers) + { + if (container != null + && ContainerHasRarityCandidate( + config, + container.ContainerTypeId, + rarity, + manifest.RareLootDisabled)) + { + return true; + } + } + + return false; + } + + internal static bool TrySelectLootEntry( + ExtractionPlayableConfig config, + ExtractionContentTierDefinition tier, + string regionId, + string containerTypeId, + ExtractionItemRarity? exactRarity, + int raidSeed, + string hashDomain, + string identityA, + string identityB, + out ExtractionLootTableEntry selectedEntry, + out ExtractionItemDefinition selectedDefinition, + int pityMisses = 0, + ExtractionLootPityDefinition pity = null, + bool rareLootDisabled = false) + { + selectedEntry = null; + selectedDefinition = null; + if (!TryGetContainer(config, containerTypeId, out var container) + || !TryGetRegion(config, regionId, out var region)) + { + return false; + } + + var candidates = new List(); + double totalWeight = 0d; + foreach (string tableId in container.LootTableIds) + { + if (!config.TryGetLootTable(tableId, out var table) + || !ExtractionLootContentPolicy.IsLootTableEnabled(table, rareLootDisabled)) + { + continue; + } + + foreach (var entry in table.Entries) + { + if (entry == null || !entry.IsValid) continue; + if (!config.TryGetItemDefinition(entry.DefinitionId, out var definition) || definition == null) + continue; + if (exactRarity.HasValue && definition.Rarity != exactRarity.Value) continue; + if (!ExtractionLootContentPolicy.IsRarityEnabled(definition.Rarity, rareLootDisabled)) continue; + + double weight = entry.Weight + * Math.Max(0d, tier?.RarityWeightMultipliers?.Get(definition.Rarity) ?? 1d) + * Math.Max(0d, region.RarityWeightMultipliers?.Get(definition.Rarity) ?? 1d); + if (pity != null && definition.Rarity >= pity.TargetRarity) + { + double pityMultiplier = Math.Min( + pity.MaximumWeightMultiplier, + 1d + pity.WeightMultiplierIncrementPerMiss * Math.Max(0, pityMisses)); + weight *= Math.Max(1d, pityMultiplier); + } + + if (weight <= 0d) continue; + totalWeight += weight; + candidates.Add(new WeightedLootCandidate(entry, definition, totalWeight)); + } + } + + if (candidates.Count == 0 || totalWeight <= 0d) return false; + double target = StableUnit( + hashDomain, + raidSeed.ToString(CultureInfo.InvariantCulture), + identityA, + identityB) * totalWeight; + foreach (var candidate in candidates) + { + if (target >= candidate.CumulativeWeight) continue; + selectedEntry = candidate.Entry; + selectedDefinition = candidate.Definition; + return true; + } + + var fallback = candidates[candidates.Count - 1]; + selectedEntry = fallback.Entry; + selectedDefinition = fallback.Definition; + return true; + } + + private static bool ContainerHasRarityCandidate( + ExtractionPlayableConfig config, + string containerTypeId, + ExtractionItemRarity rarity, + bool rareLootDisabled) + { + if (!TryGetContainer(config, containerTypeId, out var container)) return false; + foreach (string tableId in container.LootTableIds) + { + if (!config.TryGetLootTable(tableId, out var table) + || !ExtractionLootContentPolicy.IsLootTableEnabled(table, rareLootDisabled)) + { + continue; + } + + foreach (var entry in table.Entries) + { + if (entry != null + && entry.IsValid + && config.TryGetItemDefinition(entry.DefinitionId, out var definition) + && definition != null + && definition.Rarity == rarity + && ExtractionLootContentPolicy.IsRarityEnabled(rarity, rareLootDisabled)) + { + return true; + } + } + } + + return false; + } + + private static List ResolveProfileSpawns( + ExtractionPlayableConfig config, + ExtractionLootProfileDefinition profile) + { + var regionIds = new HashSet(profile.RegionIds ?? new List()); + var results = new List(); + foreach (var spawn in config.ContainerSpawns) + { + if (spawn != null && regionIds.Contains(spawn.RegionId)) + results.Add(spawn); + } + + results.Sort((left, right) => string.CompareOrdinal(left.SpawnId, right.SpawnId)); + return results; + } + + private static bool ShouldSpawn(ExtractionContainerSpawnDefinition spawn, int raidSeed) + { + if (spawn.Always) return true; + if (!spawn.ChancePerRaid) return false; + return StableUnit( + SpawnHashDomain, + raidSeed.ToString(CultureInfo.InvariantCulture), + spawn.SpawnId) + < spawn.Chance; + } + + private static bool TrySelectContainer( + ExtractionPlayableConfig config, + ExtractionContainerSpawnDefinition spawn, + int raidSeed, + out ExtractionContainerDefinition definition) + { + definition = null; + long totalWeight = 0; + foreach (var candidate in spawn.Candidates) + if (candidate != null && candidate.Weight > 0) totalWeight += candidate.Weight; + if (totalWeight <= 0) return false; + + long target = unchecked((uint)ExtractionStableHash.ComputeInt32( + CandidateHashDomain, + raidSeed.ToString(CultureInfo.InvariantCulture), + spawn.SpawnId)) % totalWeight; + long cursor = 0; + foreach (var candidate in spawn.Candidates) + { + if (candidate == null || candidate.Weight <= 0) continue; + cursor += candidate.Weight; + if (target >= cursor) continue; + return TryGetContainer(config, candidate.ContainerTypeId, out definition); + } + + return false; + } + + private static int RollInclusive(int minimum, int maximum, string domain, int seed, string identity) + { + if (maximum <= minimum) return minimum; + uint value = unchecked((uint)ExtractionStableHash.ComputeInt32( + domain, + seed.ToString(CultureInfo.InvariantCulture), + identity)); + return minimum + (int)(value % (uint)(maximum - minimum + 1)); + } + + private static double StableUnit(string domain, params string[] values) + { + uint value = unchecked((uint)ExtractionStableHash.ComputeInt32(domain, values)); + return value / ((double)uint.MaxValue + 1d); + } + + private static bool TryResolveContent( + ExtractionPlayableConfig config, + ExtractionMapDefinition map, + out ExtractionContentTierDefinition tier, + out ExtractionLootProfileDefinition profile) + { + tier = null; + profile = null; + foreach (var candidate in config.ContentTiers) + if (candidate != null && candidate.ContentTierId == map.ContentTierId) tier = candidate; + foreach (var candidate in config.LootProfiles) + if (candidate != null && candidate.LootProfileId == map.LootProfileId) profile = candidate; + return tier != null && profile != null && profile.ContentTierId == tier.ContentTierId; + } + + internal static bool TryGetContentDefinitions( + ExtractionPlayableConfig config, + ExtractionRaidLootManifest manifest, + out ExtractionContentTierDefinition tier, + out ExtractionLootProfileDefinition profile) + { + tier = null; + profile = null; + if (config == null || manifest == null) return false; + foreach (var candidate in config.ContentTiers) + if (candidate != null && candidate.ContentTierId == manifest.ContentTierId) tier = candidate; + foreach (var candidate in config.LootProfiles) + if (candidate != null && candidate.LootProfileId == manifest.LootProfileId) profile = candidate; + return tier != null && profile != null; + } + + internal static bool TryGetContainer( + ExtractionPlayableConfig config, + string containerTypeId, + out ExtractionContainerDefinition definition) + { + foreach (var candidate in config.ContainerDefinitions) + { + if (candidate != null && candidate.ContainerTypeId == containerTypeId) + { + definition = candidate; + return true; + } + } + + definition = null; + return false; + } + + internal static bool TryGetRegion( + ExtractionPlayableConfig config, + string regionId, + out ExtractionLootRegionDefinition definition) + { + foreach (var candidate in config.LootRegions) + { + if (candidate != null && candidate.RegionId == regionId) + { + definition = candidate; + return true; + } + } + + definition = null; + return false; + } + + internal static string CreateStableId(string prefix, string domain, params string[] values) + { + string hash = ExtractionStableHash.ComputeSha256(domain, values); + return prefix + hash.Substring("sha256:".Length); + } + + private static bool Fail( + ExtractionRaidLootManifestFailure value, + out ExtractionRaidLootManifestFailure failure) + { + failure = value; + return false; + } + + private sealed class WeightedLootCandidate + { + public readonly ExtractionLootTableEntry Entry; + public readonly ExtractionItemDefinition Definition; + public readonly double CumulativeWeight; + + public WeightedLootCandidate( + ExtractionLootTableEntry entry, + ExtractionItemDefinition definition, + double cumulativeWeight) + { + Entry = entry; + Definition = definition; + CumulativeWeight = cumulativeWeight; + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidLootManifestGenerator.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidLootManifestGenerator.cs.meta new file mode 100644 index 0000000..8b94e8f --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidLootManifestGenerator.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 51da3d921ef04f00a9b597dbe35dd7fd +timeCreated: 1784385600 diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidPressureService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidPressureService.cs new file mode 100644 index 0000000..1a9be1b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidPressureService.cs @@ -0,0 +1,96 @@ +using System; + +namespace POB.Extraction +{ + public static class ExtractionRaidPressureService + { + public static int GetRemainingSeconds(ExtractionRaidSession session, long currentUnixSeconds) + { + if (session == null || session.DurationSeconds <= 0) return 0; + + long elapsed = GetElapsedSeconds(session, currentUnixSeconds); + long remaining = GetEffectiveDurationSeconds(session) - elapsed; + if (remaining <= 0) return 0; + return remaining > int.MaxValue ? int.MaxValue : (int)remaining; + } + + // graceSeconds:M2 SS.1 上涌危险的安全网——地图配了上涌时,硬判负阈值后移 + // Duration+graceSeconds,给"全程骑在水面之上"的极端情形留出退场时间。默认 0=既有行为, + // CanStartExtraction/CanCompleteExtraction 内部调用不传 grace,撤离点判定口径不受影响。 + public static bool ShouldFailForTimeout(ExtractionRaidSession session, long currentUnixSeconds, int graceSeconds = 0) + { + if (session == null || session.DurationSeconds <= 0) return false; + long deadline = (long)GetEffectiveDurationSeconds(session) + Math.Max(0, graceSeconds); + return GetElapsedSeconds(session, currentUnixSeconds) >= deadline; + } + + public static bool CanStartExtraction( + ExtractionRaidSession session, + ExtractionPointDefinition point, + long currentUnixSeconds, + bool hasEmergencyOverride) + { + if (session == null || point == null || !point.IsValid) return false; + if (ShouldFailForTimeout(session, currentUnixSeconds)) return false; + + long elapsed = GetElapsedSeconds(session, currentUnixSeconds); + if (point.SingleUse && session.Content.UsedExtractionPointIds.Contains(point.PointId)) return false; + if (point.DefaultOpen) return true; + if (elapsed >= GetEffectiveOpenAtElapsedSeconds(session, point)) return true; + + return hasEmergencyOverride + && session.AllowEmergencyExtraction + && point.AllowEmergencyExtractionOverride; + } + + public static bool CanCompleteExtraction( + ExtractionRaidSession session, + ExtractionPointDefinition point, + long channelStartedAtUnixSeconds, + long currentUnixSeconds, + bool hasEmergencyOverride) + { + if (ShouldFailForTimeout(session, currentUnixSeconds)) return false; + if (!CanStartExtraction(session, point, channelStartedAtUnixSeconds, hasEmergencyOverride)) return false; + + long channelElapsed = currentUnixSeconds - channelStartedAtUnixSeconds; + return channelElapsed >= point.ChannelSeconds; + } + + // M2 SS.1:上涌危险的高度公式需要同一份 elapsed 口径,改成 public 复用, + // 不让 ExtractionRisingHazard 另起一套(currentUnixSeconds - StartedAtUnixSeconds)算法漂移。 + public static long GetElapsedSeconds(ExtractionRaidSession session, long currentUnixSeconds) + { + long elapsed = currentUnixSeconds - session.StartedAtUnixSeconds; + return elapsed < 0 ? 0 : elapsed; + } + + public static int GetEffectiveDurationSeconds(ExtractionRaidSession session) + { + if (session == null) return 0; + long duration = (long)session.DurationSeconds + (session.Content?.DeadlineExtensionSeconds ?? 0); + return duration <= 0 ? 0 : duration > int.MaxValue ? int.MaxValue : (int)duration; + } + + public static int GetEffectiveThreatLevel(ExtractionRaidSession session) + { + if (session == null) return 0; + long threat = (long)session.ThreatLevel + (session.Content?.ThreatLevelDelta ?? 0); + return threat <= 0 ? 0 : threat > int.MaxValue ? int.MaxValue : (int)threat; + } + + public static int GetEffectiveOpenAtElapsedSeconds( + ExtractionRaidSession session, + ExtractionPointDefinition point) + { + if (session?.Content?.ExtractionPointStates != null && point != null) + { + foreach (var state in session.Content.ExtractionPointStates) + if (state != null && state.PointId == point.PointId) + return Math.Max(0, state.EffectiveOpenAtElapsedSeconds); + } + + return Math.Max(0, point?.OpenAtElapsedSeconds ?? 0); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidPressureService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidPressureService.cs.meta new file mode 100644 index 0000000..3030d0b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidPressureService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a27a45fd21694718b3cc6f4bb4e50ab7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSession.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSession.cs new file mode 100644 index 0000000..8c32475 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSession.cs @@ -0,0 +1,121 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionRaidSession + { + public string RaidId; + public string MapId; + public string RaidRoomName; + public int Seed; + public long StartedAtUnixSeconds; + public int DurationSeconds; + public int ThreatLevel; + public bool AllowEmergencyExtraction; + public List LoadoutItemInstanceIds = new(); + public List SecureItemInstanceIds = new(); + public List EquipmentSlots = new(); + public List OpenedSafeIds = new(); + public List RaidFlagIds = new(); + public List LootedSourceIds = new(); + public ExtractionActiveRaidContentState Content = new(); + + public ExtractionRaidSession( + ExtractionMapDefinition map, + ExtractionRaidStartRequest request) + { + RaidId = request.RaidId; + MapId = map.MapId; + RaidRoomName = map.RaidRoomName; + Seed = request.Seed; + StartedAtUnixSeconds = request.StartedAtUnixSeconds; + DurationSeconds = map.DurationSeconds; + ThreatLevel = map.ThreatLevel; + AllowEmergencyExtraction = map.AllowEmergencyExtraction; + LoadoutItemInstanceIds.AddRange(request.LoadoutItemInstanceIds); + SecureItemInstanceIds.AddRange(request.SecureItemInstanceIds); + if (request.EquipmentSlots != null) + { + foreach (var slot in request.EquipmentSlots) + { + if (slot == null) continue; + EquipmentSlots.Add( + new ExtractionEquipmentSlotState( + slot.SlotId, + slot.ItemInstanceId, + slot.EffectReceiptId)); + } + } + } + + public bool HasOpenedSafe(string safeId) + { + return !string.IsNullOrEmpty(safeId) + && OpenedSafeIds != null + && OpenedSafeIds.Contains(safeId); + } + + public void MarkSafeOpened(string safeId) + { + if (string.IsNullOrEmpty(safeId)) return; + + if (OpenedSafeIds == null) + OpenedSafeIds = new List(); + + if (!OpenedSafeIds.Contains(safeId)) + OpenedSafeIds.Add(safeId); + } + + public bool HasLootedSource(string sourceId) + { + return !string.IsNullOrEmpty(sourceId) + && LootedSourceIds != null + && LootedSourceIds.Contains(sourceId); + } + + public void MarkLootedSource(string sourceId) + { + if (string.IsNullOrEmpty(sourceId)) return; + + if (LootedSourceIds == null) + LootedSourceIds = new List(); + + if (!LootedSourceIds.Contains(sourceId)) + LootedSourceIds.Add(sourceId); + } + + public bool HasRaidFlag(string flagId) + { + return !string.IsNullOrEmpty(flagId) + && RaidFlagIds != null + && RaidFlagIds.Contains(flagId); + } + + public void MarkRaidFlag(string flagId) + { + if (string.IsNullOrEmpty(flagId)) return; + + if (RaidFlagIds == null) + RaidFlagIds = new List(); + + if (!RaidFlagIds.Contains(flagId)) + RaidFlagIds.Add(flagId); + } + + internal void EnsureInitialized() + { + LoadoutItemInstanceIds ??= new List(); + SecureItemInstanceIds ??= new List(); + EquipmentSlots ??= new List(); + OpenedSafeIds ??= new List(); + RaidFlagIds ??= new List(); + LootedSourceIds ??= new List(); + Content ??= new ExtractionActiveRaidContentState(); + Content.EnsureInitialized(); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSession.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSession.cs.meta new file mode 100644 index 0000000..99c75fe --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSession.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 81b00e718fb6da2439adc5766515c1bb +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSessionFactory.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSessionFactory.cs new file mode 100644 index 0000000..9242184 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSessionFactory.cs @@ -0,0 +1,154 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionRaidSessionFactory + { + public static bool TryCreate( + ExtractionProfileSaveData profile, + ExtractionMapDefinition map, + ExtractionRaidStartRequest request, + out ExtractionRaidSession session) + { + return TryCreateInternal(profile, map, request, null, out session); + } + + public static bool TryCreate( + ExtractionProfileSaveData profile, + ExtractionPlayableConfig config, + ExtractionMapDefinition map, + ExtractionRaidStartRequest request, + bool rareLootDisabled, + out ExtractionRaidSession session, + out ExtractionRaidLootManifestFailure manifestFailure) + { + session = null; + manifestFailure = ExtractionRaidLootManifestFailure.None; + if (profile == null || config == null || map == null || request == null) return false; + + if (!ExtractionRaidLootManifestGenerator.TryGenerate( + config, + map, + request.Seed, + rareLootDisabled, + out var manifest, + out manifestFailure)) + { + return false; + } + + return TryCreateInternal(profile, map, request, manifest, out session); + } + + private static bool TryCreateInternal( + ExtractionProfileSaveData profile, + ExtractionMapDefinition map, + ExtractionRaidStartRequest request, + ExtractionRaidLootManifest manifest, + out ExtractionRaidSession session) + { + session = null; + if (profile == null || map == null || request == null) return false; + if (!map.IsValid || !request.IsValid) return false; + if (!string.IsNullOrEmpty(profile.activeRaidId) || profile.ActiveRaid != null) return false; + + var movedLoadout = new List(); + var movedSecure = new List(); + var movedEquipment = new List(); + + var loadoutTarget = request.UseUnifiedItemLocations + ? ExtractionInventoryContainerType.RaidBackpack + : ExtractionInventoryContainerType.InRaid; + + foreach (var itemId in request.LoadoutItemInstanceIds) + { + if (!profile.Ownership.TryMove( + itemId, + ExtractionInventoryContainerType.Loadout, + loadoutTarget)) + { + Rollback(profile, movedLoadout, movedSecure, movedEquipment, loadoutTarget); + return false; + } + + movedLoadout.Add(itemId); + } + + foreach (var itemId in request.SecureItemInstanceIds) + { + if (!profile.Ownership.TryMove( + itemId, + ExtractionInventoryContainerType.SecureContainer, + ExtractionInventoryContainerType.InSecureContainer)) + { + Rollback(profile, movedLoadout, movedSecure, movedEquipment, loadoutTarget); + return false; + } + + movedSecure.Add(itemId); + } + + if (request.EquipmentSlots != null) + { + foreach (var slot in request.EquipmentSlots) + { + if (slot == null + || string.IsNullOrEmpty(slot.SlotId) + || string.IsNullOrEmpty(slot.ItemInstanceId) + || !profile.Ownership.TryMove( + slot.ItemInstanceId, + ExtractionInventoryContainerType.EquipmentSlot, + ExtractionInventoryContainerType.EquipmentSlot, + ExtractionItemLocationService.RaidEquipmentLocationSubtype, + slot.SlotId)) + { + Rollback(profile, movedLoadout, movedSecure, movedEquipment, loadoutTarget); + return false; + } + + movedEquipment.Add(slot); + } + } + + session = new ExtractionRaidSession(map, request); + session.Content.LootManifest = manifest; + profile.ActiveRaid = session; + profile.activeRaidId = session.RaidId; + return true; + } + + private static void Rollback( + ExtractionProfileSaveData profile, + List movedLoadout, + List movedSecure, + List movedEquipment, + ExtractionInventoryContainerType loadoutTarget) + { + foreach (var slot in movedEquipment) + { + profile.Ownership.TryMove( + slot.ItemInstanceId, + ExtractionInventoryContainerType.EquipmentSlot, + ExtractionInventoryContainerType.EquipmentSlot, + ExtractionItemLocationService.BaseEquipmentLocationSubtype, + slot.SlotId); + } + + foreach (var itemId in movedSecure) + { + profile.Ownership.TryMove( + itemId, + ExtractionInventoryContainerType.InSecureContainer, + ExtractionInventoryContainerType.SecureContainer); + } + + foreach (var itemId in movedLoadout) + { + profile.Ownership.TryMove( + itemId, + loadoutTarget, + ExtractionInventoryContainerType.Loadout); + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSessionFactory.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSessionFactory.cs.meta new file mode 100644 index 0000000..f6b5ffb --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSessionFactory.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 64b7d2d331af8d14094265cc3bca3070 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSettlementReport.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSettlementReport.cs new file mode 100644 index 0000000..bfc8eb4 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSettlementReport.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + // M1 SD.3:一局 raid 结束时的物品变化清单——成功时是"这次带回 Stash 的物品",失败时是 + // "这次丢进 Lost 的物品"(直接复用 ExtractionSettlementService 内部早就算好、之前被调用方 + // out _ 丢掉的那份数据)。同时作为 OnRaidEnded 的事件载荷和 ExtractionRuntimeService 内部 + // "上一局战报"缓存的唯一数据形状,避免两处各维护一份。 + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionRaidSettlementReport + { + public bool Success; + public string Reason; + public List ItemInstanceIds = new(); + + public ExtractionRaidSettlementReport(bool success, string reason, IEnumerable itemInstanceIds) + { + Success = success; + Reason = reason; + if (itemInstanceIds != null) + ItemInstanceIds.AddRange(itemInstanceIds); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSettlementReport.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSettlementReport.cs.meta new file mode 100644 index 0000000..9819d67 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidSettlementReport.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 94343e69dc4f5cd4299adbd1af647d35 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidStartRequest.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidStartRequest.cs new file mode 100644 index 0000000..5031d8b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidStartRequest.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionRaidStartRequest + { + public string RaidId; + public int Seed; + public long StartedAtUnixSeconds; + public List LoadoutItemInstanceIds = new(); + public List SecureItemInstanceIds = new(); + public List EquipmentSlots = new(); + public bool UseUnifiedItemLocations; + + public bool IsValid => !string.IsNullOrEmpty(RaidId); + + public ExtractionRaidStartRequest(string raidId, int seed, long startedAtUnixSeconds) + { + RaidId = raidId; + Seed = seed; + StartedAtUnixSeconds = startedAtUnixSeconds; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidStartRequest.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidStartRequest.cs.meta new file mode 100644 index 0000000..e114ac6 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRaidStartRequest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3343a34edb81a2e40965937108dff2c3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRecoveryLedger.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRecoveryLedger.cs new file mode 100644 index 0000000..95a3729 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRecoveryLedger.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + public enum ExtractionRecoveryItemState + { + Recoverable, + Recovered, + Destroyed, + Expired + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionRecoveryLedger + { + public List Entries = new(); + + public void RegisterLostItem(string snapshotId, string itemInstanceId) + { + if (string.IsNullOrEmpty(snapshotId) || string.IsNullOrEmpty(itemInstanceId)) return; + if (FindEntry(itemInstanceId) != null) return; + Entries.Add(new ExtractionRecoveryEntry(snapshotId, itemInstanceId, ExtractionRecoveryItemState.Recoverable)); + } + + public ExtractionRecoveryItemState GetState(string itemInstanceId) + { + var entry = FindEntry(itemInstanceId); + if (entry == null) + throw new InvalidOperationException($"Extraction recovery item '{itemInstanceId}' is not tracked."); + return entry.State; + } + + public bool TryGetState(string itemInstanceId, out ExtractionRecoveryItemState state) + { + var entry = FindEntry(itemInstanceId); + if (entry == null) + { + state = default; + return false; + } + + state = entry.State; + return true; + } + + public bool TryMarkRecovered(string itemInstanceId) + { + var entry = FindEntry(itemInstanceId); + if (entry == null) return false; + if (entry.State != ExtractionRecoveryItemState.Recoverable) return false; + entry.State = ExtractionRecoveryItemState.Recovered; + return true; + } + + public bool TryMarkDestroyed(string itemInstanceId) + { + return TrySetTerminalState(itemInstanceId, ExtractionRecoveryItemState.Destroyed); + } + + public bool TryMarkExpired(string itemInstanceId) + { + return TrySetTerminalState(itemInstanceId, ExtractionRecoveryItemState.Expired); + } + + public bool TryGetRecoverableItems(string snapshotId, List results) + { + if (results == null) return false; + results.Clear(); + if (string.IsNullOrEmpty(snapshotId)) return false; + + foreach (var entry in Entries) + { + if (entry == null) continue; + if (entry.SnapshotId != snapshotId) continue; + if (entry.State != ExtractionRecoveryItemState.Recoverable) continue; + results.Add(entry.ItemInstanceId); + } + + return results.Count > 0; + } + + private bool TrySetTerminalState( + string itemInstanceId, + ExtractionRecoveryItemState terminalState) + { + var entry = FindEntry(itemInstanceId); + if (entry == null) return false; + if (entry.State != ExtractionRecoveryItemState.Recoverable) return false; + entry.State = terminalState; + return true; + } + + private ExtractionRecoveryEntry FindEntry(string itemInstanceId) + { + foreach (var entry in Entries) + { + if (entry.ItemInstanceId == itemInstanceId) return entry; + } + + return null; + } + } + + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionRecoveryEntry + { + public string SnapshotId; + public string ItemInstanceId; + public ExtractionRecoveryItemState State; + + public ExtractionRecoveryEntry( + string snapshotId, + string itemInstanceId, + ExtractionRecoveryItemState state) + { + SnapshotId = snapshotId; + ItemInstanceId = itemInstanceId; + State = state; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRecoveryLedger.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRecoveryLedger.cs.meta new file mode 100644 index 0000000..2236102 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRecoveryLedger.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bdec5806793640a42a9f62a7abcf1e5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRecoveryService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRecoveryService.cs new file mode 100644 index 0000000..8c8e8ce --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRecoveryService.cs @@ -0,0 +1,39 @@ +namespace POB.Extraction +{ + public static class ExtractionRecoveryService + { + public static bool TryRecoverCorpseItem(ExtractionProfileSaveData profile, string itemInstanceId) + { + return TryRecoverItem(profile, itemInstanceId); + } + + public static bool TryRecoverBuybackItem(ExtractionProfileSaveData profile, string itemInstanceId) + { + return TryRecoverItem(profile, itemInstanceId); + } + + private static bool TryRecoverItem(ExtractionProfileSaveData profile, string itemInstanceId) + { + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || string.IsNullOrEmpty(itemInstanceId)) return false; + if (!profile.Recovery.TryGetState(itemInstanceId, out var state)) return false; + if (state != ExtractionRecoveryItemState.Recoverable) return false; + + if (!profile.Ownership.TryMove( + itemInstanceId, + ExtractionInventoryContainerType.Lost, + ExtractionInventoryContainerType.Holding)) + { + return false; + } + + if (profile.Recovery.TryMarkRecovered(itemInstanceId)) return true; + + profile.Ownership.TryMove( + itemInstanceId, + ExtractionInventoryContainerType.Holding, + ExtractionInventoryContainerType.Lost); + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRecoveryService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRecoveryService.cs.meta new file mode 100644 index 0000000..cafb0aa --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRecoveryService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f082368f2d4745478fe014b6cc3a30ed +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRisingHazardService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRisingHazardService.cs new file mode 100644 index 0000000..acb0ca4 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRisingHazardService.cs @@ -0,0 +1,29 @@ +namespace POB.Extraction +{ + // M2 SS.1:上涌危险的纯函数核心——高度公式 + 淹没判定,不碰 Unity 引擎类型, + // 方便 EditMode 直接喂数字断言,不需要场景/物理系统。 + public static class ExtractionRisingHazardService + { + // 倒计时归零(elapsedSeconds >= durationSeconds)前按 riseRate 匀速上升; + // 归零后速率翻倍,用归零那一刻的高度接着算,不产生跳变。 + public static float GetCurrentHeight( + int elapsedSeconds, + int durationSeconds, + float startY, + float riseRate) + { + if (elapsedSeconds <= durationSeconds) + return startY + riseRate * elapsedSeconds; + + float heightAtTimeout = startY + riseRate * durationSeconds; + int secondsPastTimeout = elapsedSeconds - durationSeconds; + return heightAtTimeout + riseRate * 2f * secondsPastTimeout; + } + + // 玩家 Y 落在当前水位及以下即视为淹没。 + public static bool IsSubmerged(float entityY, float currentHeight) + { + return entityY <= currentHeight; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRisingHazardService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRisingHazardService.cs.meta new file mode 100644 index 0000000..cb946dd --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRisingHazardService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ba0b197a454275d4fb49bf4f4566c9ba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRisingHazardState.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRisingHazardState.cs new file mode 100644 index 0000000..24302ef --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRisingHazardState.cs @@ -0,0 +1,27 @@ +namespace POB.Extraction +{ + // M2 SS.1:喂给上涌危险组件的一次性快照——纯数据,不含 Unity 引擎类型, + // 方便 facade 一次查询打包返回,调用方不用分开查 5 个值。 + public readonly struct ExtractionRisingHazardState + { + public readonly int ElapsedSeconds; + public readonly int DurationSeconds; + public readonly float StartY; + public readonly float RiseRate; + public readonly float DamagePerSecond; + + public ExtractionRisingHazardState( + int elapsedSeconds, + int durationSeconds, + float startY, + float riseRate, + float damagePerSecond) + { + ElapsedSeconds = elapsedSeconds; + DurationSeconds = durationSeconds; + StartY = startY; + RiseRate = riseRate; + DamagePerSecond = damagePerSecond; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRisingHazardState.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRisingHazardState.cs.meta new file mode 100644 index 0000000..0c32bfa --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionRisingHazardState.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1c43bae103b6fb445994ef67541219fd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSafeDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSafeDefinition.cs new file mode 100644 index 0000000..fb9e958 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSafeDefinition.cs @@ -0,0 +1,39 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionSafeDefinition + { + public string SafeId; + public string MapId; + public string LootTableId; + public string RequiredKeyItemDefinitionId; + + public bool IsValid => + !string.IsNullOrEmpty(SafeId) + && !string.IsNullOrEmpty(MapId) + && !string.IsNullOrEmpty(LootTableId); + + public bool RequiresKey => !string.IsNullOrEmpty(RequiredKeyItemDefinitionId); + + public ExtractionSafeDefinition( + string safeId, + string mapId, + string lootTableId, + string requiredKeyItemDefinitionId) + { + SafeId = safeId; + MapId = mapId; + LootTableId = lootTableId; + RequiredKeyItemDefinitionId = requiredKeyItemDefinitionId; + } + + public bool CanOpenWith(string keyItemDefinitionId) + { + return !RequiresKey || keyItemDefinitionId == RequiredKeyItemDefinitionId; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSafeDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSafeDefinition.cs.meta new file mode 100644 index 0000000..b5d0e6b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSafeDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0a4144e19f84dc545aa41c4908dd03ca +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSafeLootService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSafeLootService.cs new file mode 100644 index 0000000..f77e3f0 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSafeLootService.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionSafeLootService + { + public static bool TryGetSafeDefinitions( + List safes, + string mapId, + List results) + { + if (results == null) return false; + results.Clear(); + if (safes == null || string.IsNullOrEmpty(mapId)) return false; + + foreach (var safe in safes) + { + if (safe == null || !safe.IsValid) continue; + if (safe.MapId != mapId) continue; + results.Add(safe); + } + + return results.Count > 0; + } + + public static bool TryRollSafeLoot( + ExtractionSafeDefinition safe, + IExtractionLootTableCatalog lootTables, + IExtractionItemCatalog itemCatalog, + string keyItemDefinitionId, + string instanceId, + int rollValue, + out ExtractionLootPickup pickup) + { + pickup = null; + if (safe == null || !safe.IsValid) return false; + if (lootTables == null || itemCatalog == null) return false; + if (!safe.CanOpenWith(keyItemDefinitionId)) return false; + if (!lootTables.TryGetLootTable(safe.LootTableId, out var table)) return false; + + return ExtractionLootRollService.TryRollPickup( + table, + itemCatalog, + instanceId, + rollValue, + out pickup); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSafeLootService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSafeLootService.cs.meta new file mode 100644 index 0000000..0045c94 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSafeLootService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 91a2127f28675fd40bc404190963c77f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSellTransactionService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSellTransactionService.cs new file mode 100644 index 0000000..442bf15 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSellTransactionService.cs @@ -0,0 +1,127 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionSellTransactionService + { + public const float SellValueMultiplier = 0.5f; + + public static bool TrySellItem( + ExtractionProfileSaveData profile, + string itemInstanceId, + List sourceContainers, + IExtractionItemCatalog itemCatalog, + string proceedsItemDefinitionId, + string grantedProceedsInstanceId, + ExtractionInventoryContainerType proceedsTargetContainer, + int x, + int y, + bool rotated, + out int grantedQuantity) + { + grantedQuantity = 0; + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || itemCatalog == null) return false; + if (string.IsNullOrEmpty(itemInstanceId) || string.IsNullOrEmpty(grantedProceedsInstanceId)) return false; + if (sourceContainers == null || sourceContainers.Count == 0) return false; + profile.EnsureInitialized(); + + if (!profile.Items.TryGet(itemInstanceId, out var soldItem)) return false; + if (!itemCatalog.TryGetItemDefinition(soldItem.DefinitionId, out var soldDefinition)) return false; + if (!ExtractionItemActionPolicyService.CanSell(soldDefinition, soldItem)) return false; + if (!profile.Ownership.TryGetContainer(itemInstanceId, out var sourceContainer)) return false; + if (!sourceContainers.Contains(sourceContainer)) return false; + if (!TryGetGrid(profile, sourceContainer, out var sourceGrid)) return false; + if (!sourceGrid.TryGetPlacement(itemInstanceId, out var soldPlacement)) return false; + if (!itemCatalog.TryGetItemDefinition(proceedsItemDefinitionId, out var proceedsDefinition)) return false; + if (!TryGetGrid(profile, proceedsTargetContainer, out var targetGrid)) return false; + if (profile.Items.TryGet(grantedProceedsInstanceId, out _)) return false; + if (profile.Ownership.TryGetContainer(grantedProceedsInstanceId, out _)) return false; + + // Value 是每单位基准单价(见 ExtractionItemDefinition 注释),出售按 Quantity 线性折算; + // 半价后不足 1 视为不可出售,不生成 0 数量的收益物。 + grantedQuantity = (int)(soldDefinition.Value * soldItem.Quantity * SellValueMultiplier); + if (grantedQuantity <= 0) return false; + + var grantedItem = new ExtractionItemInstance(grantedProceedsInstanceId, proceedsItemDefinitionId, grantedQuantity); + if (!CanPlace(targetGrid, grantedItem, proceedsDefinition, x, y, rotated)) return false; + + if (!sourceGrid.TryRemove(itemInstanceId)) return false; + if (!profile.Items.TryRemove(itemInstanceId)) + { + sourceGrid.TryPlace(soldItem, soldDefinition, soldPlacement.X, soldPlacement.Y, soldPlacement.Rotated); + return false; + } + if (!profile.Ownership.TryRemove(itemInstanceId)) + { + profile.Items.Register(soldItem); + sourceGrid.TryPlace(soldItem, soldDefinition, soldPlacement.X, soldPlacement.Y, soldPlacement.Rotated); + return false; + } + + if (!profile.Items.Register(grantedItem)) + { + RestoreSoldItem(profile, soldItem, soldDefinition, sourceGrid, sourceContainer, soldPlacement); + return false; + } + if (!targetGrid.TryPlace(grantedItem, proceedsDefinition, x, y, rotated)) + { + profile.Items.TryRemove(grantedProceedsInstanceId); + RestoreSoldItem(profile, soldItem, soldDefinition, sourceGrid, sourceContainer, soldPlacement); + return false; + } + if (!profile.Ownership.Register(grantedProceedsInstanceId, proceedsTargetContainer)) + { + targetGrid.TryRemove(grantedProceedsInstanceId); + profile.Items.TryRemove(grantedProceedsInstanceId); + RestoreSoldItem(profile, soldItem, soldDefinition, sourceGrid, sourceContainer, soldPlacement); + return false; + } + + return true; + } + + private static void RestoreSoldItem( + ExtractionProfileSaveData profile, + ExtractionItemInstance soldItem, + ExtractionItemDefinition soldDefinition, + ExtractionItemGrid sourceGrid, + ExtractionInventoryContainerType sourceContainer, + ExtractionItemPlacement soldPlacement) + { + profile.Items.Register(soldItem); + sourceGrid.TryPlace(soldItem, soldDefinition, soldPlacement.X, soldPlacement.Y, soldPlacement.Rotated); + profile.Ownership.Register(soldItem.InstanceId, sourceContainer); + } + + private static bool CanPlace( + ExtractionItemGrid grid, + ExtractionItemInstance item, + ExtractionItemDefinition definition, + int x, + int y, + bool rotated) + { + if (!grid.TryPlace(item, definition, x, y, rotated)) return false; + grid.TryRemove(item.InstanceId); + return true; + } + + private static bool TryGetGrid( + ExtractionProfileSaveData profile, + ExtractionInventoryContainerType container, + out ExtractionItemGrid grid) + { + grid = container switch + { + ExtractionInventoryContainerType.Stash => profile.Stash, + ExtractionInventoryContainerType.Loadout => profile.Loadout, + ExtractionInventoryContainerType.SecureContainer => profile.SecureContainer, + ExtractionInventoryContainerType.Holding => profile.RecoveryHolding, + _ => null + }; + + return grid != null; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSellTransactionService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSellTransactionService.cs.meta new file mode 100644 index 0000000..5f2cc22 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSellTransactionService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e8117ecf069e20c4ba31c646130a3536 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSettlementService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSettlementService.cs new file mode 100644 index 0000000..793e64d --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSettlementService.cs @@ -0,0 +1,283 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionSettlementService + { + public static bool CompleteSuccess(ExtractionProfileSaveData profile, IEnumerable extractedItemIds) + { + if (!CanSettle(profile) || extractedItemIds == null) return false; + + var movedItems = new List(); + foreach (var itemId in extractedItemIds) + { + if (!TryGetSuccessSource(profile, itemId, out var source) + || !profile.Ownership.TryMove(itemId, source, ExtractionInventoryContainerType.Stash)) + { + Rollback(profile, movedItems); + return false; + } + + movedItems.Add(new OwnershipMove(itemId, source, ExtractionInventoryContainerType.Stash)); + } + + ClearActiveRaid(profile); + return true; + } + + public static ExtractionLostRunSnapshot CompleteFailure( + ExtractionProfileSaveData profile, + string snapshotId, + string mapId, + IEnumerable lostItemIds, + IEnumerable secureItemIds) + { + return TryCompleteFailure(profile, snapshotId, mapId, lostItemIds, secureItemIds, null, out var snapshot) + ? snapshot + : null; + } + + public static bool TryCompleteFailure( + ExtractionProfileSaveData profile, + string snapshotId, + string mapId, + IEnumerable lostItemIds, + IEnumerable secureItemIds, + out ExtractionLostRunSnapshot snapshot) + { + return TryCompleteFailure( + profile, + snapshotId, + mapId, + lostItemIds, + secureItemIds, + null, + out snapshot); + } + + public static bool TryCompleteFailure( + ExtractionProfileSaveData profile, + string snapshotId, + string mapId, + IEnumerable lostItemIds, + IEnumerable secureItemIds, + ExtractionFailureContext failureContext, + out ExtractionLostRunSnapshot snapshot) + { + return TryCompleteFailure( + profile, snapshotId, mapId, lostItemIds, secureItemIds, + failureContext, null, null, out snapshot); + } + + public static bool TryCompleteFailure( + ExtractionProfileSaveData profile, + string snapshotId, + string mapId, + IEnumerable lostItemIds, + IEnumerable secureItemIds, + string corpseId, + ExtractionCorpseLootLedger corpseLoot, + out ExtractionLostRunSnapshot snapshot) + { + return TryCompleteFailure( + profile, snapshotId, mapId, lostItemIds, secureItemIds, + null, corpseId, corpseLoot, out snapshot); + } + + public static bool TryCompleteFailure( + ExtractionProfileSaveData profile, + string snapshotId, + string mapId, + IEnumerable lostItemIds, + IEnumerable secureItemIds, + ExtractionFailureContext failureContext, + string corpseId, + ExtractionCorpseLootLedger corpseLoot, + out ExtractionLostRunSnapshot snapshot) + { + return TryCompleteFailure( + profile, snapshotId, mapId, lostItemIds, secureItemIds, + null, failureContext, corpseId, corpseLoot, out snapshot); + } + + // M2 SD2.2a:尸袋宽松档新增的"保留一部分 raid/背包物品"通道。keptRaidItemIds 里的物品从 + // InRaid/RaidBackpack 直接移交 Stash(不进 Lost/不进尸体账本),同源校验复用 + // TryGetFailureLostSource——两者本来就该是"同一个来源池的两种去向"。null/空表=功能关闭, + // 等价于旧版 8 参数重载的行为,向后兼容其余全部既有重载与调用方。 + public static bool TryCompleteFailure( + ExtractionProfileSaveData profile, + string snapshotId, + string mapId, + IEnumerable lostItemIds, + IEnumerable secureItemIds, + IEnumerable keptRaidItemIds, + ExtractionFailureContext failureContext, + string corpseId, + ExtractionCorpseLootLedger corpseLoot, + out ExtractionLostRunSnapshot snapshot) + { + snapshot = null; + if (!CanSettle(profile) + || string.IsNullOrEmpty(snapshotId) + || lostItemIds == null + || secureItemIds == null) + { + return false; + } + + bool registersCorpse = !string.IsNullOrEmpty(corpseId); + if (registersCorpse && corpseLoot == null) return false; + + var movedItems = new List(); + var registeredLostItems = new List(); + var loadoutItems = new List(); + var backpackItems = new List(); + var secureItems = new List(); + foreach (var itemId in lostItemIds) + { + if (!TryGetFailureLostSource(profile, itemId, out var source) + || !profile.Ownership.TryMove(itemId, source, ExtractionInventoryContainerType.Lost)) + { + Rollback(profile, movedItems); + return false; + } + + movedItems.Add(new OwnershipMove(itemId, source, ExtractionInventoryContainerType.Lost)); + registeredLostItems.Add(itemId); + if (source == ExtractionInventoryContainerType.InRaid) + loadoutItems.Add(itemId); + if (source == ExtractionInventoryContainerType.RaidBackpack) + backpackItems.Add(itemId); + } + + foreach (var itemId in secureItemIds) + { + if (!profile.Ownership.TryMove( + itemId, + ExtractionInventoryContainerType.InSecureContainer, + ExtractionInventoryContainerType.SecureContainer)) + { + Rollback(profile, movedItems); + return false; + } + + movedItems.Add( + new OwnershipMove( + itemId, + ExtractionInventoryContainerType.InSecureContainer, + ExtractionInventoryContainerType.SecureContainer)); + secureItems.Add(itemId); + } + + if (keptRaidItemIds != null) + { + foreach (var itemId in keptRaidItemIds) + { + if (!TryGetFailureLostSource(profile, itemId, out var source) + || !profile.Ownership.TryMove(itemId, source, ExtractionInventoryContainerType.Stash)) + { + Rollback(profile, movedItems); + return false; + } + + movedItems.Add(new OwnershipMove(itemId, source, ExtractionInventoryContainerType.Stash)); + } + } + + snapshot = new ExtractionLostRunSnapshot(snapshotId, mapId); + snapshot.RaidSeed = profile.ActiveRaid?.Seed ?? 0; + snapshot.ApplyContext(failureContext); + snapshot.LoadoutItemInstanceIds.AddRange(loadoutItems); + snapshot.BackpackItemInstanceIds.AddRange(backpackItems); + snapshot.SecureItemInstanceIds.AddRange(secureItems); + foreach (var itemId in registeredLostItems) + { + snapshot.LostItemInstanceIds.Add(itemId); + snapshot.RecoverableCandidateItemInstanceIds.Add(itemId); + } + + // 原子闸门:尸体注册失败则回滚 ownership、不碰 Recovery / ActiveRaid。 + if (registersCorpse && !corpseLoot.RegisterCorpse(corpseId, snapshot)) + { + Rollback(profile, movedItems); + snapshot = null; + return false; + } + + foreach (var itemId in registeredLostItems) + profile.Recovery.RegisterLostItem(snapshotId, itemId); + + ClearActiveRaid(profile); + return true; + } + + private static bool CanSettle(ExtractionProfileSaveData profile) + { + return ExtractionFeatureSwitch.Enabled + && profile != null + && profile.ActiveRaid != null + && !string.IsNullOrEmpty(profile.activeRaidId); + } + + private static bool TryGetSuccessSource( + ExtractionProfileSaveData profile, + string itemId, + out ExtractionInventoryContainerType source) + { + if (!profile.Ownership.TryGetContainer(itemId, out source)) return false; + return source == ExtractionInventoryContainerType.InRaid + || source == ExtractionInventoryContainerType.RaidBackpack + || source == ExtractionInventoryContainerType.InSecureContainer + || source == ExtractionInventoryContainerType.EquipmentSlot; + } + + private static bool TryGetFailureLostSource( + ExtractionProfileSaveData profile, + string itemId, + out ExtractionInventoryContainerType source) + { + if (!profile.Ownership.TryGetContainer(itemId, out source)) return false; + // M2 SD2.2a 硬核尸袋档:调用方把 secure 容器物品也并入 lostItemIds(一起判定丢失), + // 因此这里同时接受 InSecureContainer 作为有效来源;既有调用方从不把 secure 物品塞进 + // lostItemIds(它们走独立的 secureItemIds 参数),这里放宽不影响任何既有行为。 + return source == ExtractionInventoryContainerType.InRaid + || source == ExtractionInventoryContainerType.RaidBackpack + || source == ExtractionInventoryContainerType.InSecureContainer + || source == ExtractionInventoryContainerType.EquipmentSlot; + } + + private static void Rollback(ExtractionProfileSaveData profile, List movedItems) + { + for (int i = movedItems.Count - 1; i >= 0; i--) + { + var move = movedItems[i]; + profile.Ownership.TryMove(move.ItemId, move.Target, move.Source); + } + } + + private static void ClearActiveRaid(ExtractionProfileSaveData profile) + { + profile.activeRaidId = null; + profile.ActiveRaid = null; + profile.ActiveRaidElapsedSeconds = 0f; + profile.ActiveRaidInventory = null; + } + + private struct OwnershipMove + { + public string ItemId; + public ExtractionInventoryContainerType Source; + public ExtractionInventoryContainerType Target; + + public OwnershipMove( + string itemId, + ExtractionInventoryContainerType source, + ExtractionInventoryContainerType target) + { + ItemId = itemId; + Source = source; + Target = target; + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSettlementService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSettlementService.cs.meta new file mode 100644 index 0000000..0f54758 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionSettlementService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e7a6f90392a4fd3488d6b9c404057a2a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionStableHash.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionStableHash.cs new file mode 100644 index 0000000..34cc75f --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionStableHash.cs @@ -0,0 +1,99 @@ +using System; +using System.Security.Cryptography; +using System.Text; + +namespace POB.Extraction +{ + /// + /// Cross-process deterministic hashing for persisted gameplay decisions. + /// Fields are UTF-8 length-prefixed so null, empty and field boundaries remain distinct. + /// + public static class ExtractionStableHash + { + private const string CanonicalPrefix = "zeroengine.extraction.stable-hash:v1;"; + + public static string ComputeSha256(string domain, params string[] values) + { + byte[] digest = ComputeDigest(domain, values); + var builder = new StringBuilder("sha256:", 7 + digest.Length * 2); + foreach (byte value in digest) + builder.Append(value.ToString("x2")); + return builder.ToString(); + } + + public static int ComputeInt32(string domain, params string[] values) + { + byte[] digest = ComputeDigest(domain, values); + uint value = ((uint)digest[0] << 24) + | ((uint)digest[1] << 16) + | ((uint)digest[2] << 8) + | digest[3]; + return unchecked((int)value); + } + + private static byte[] ComputeDigest(string domain, string[] values) + { + if (string.IsNullOrEmpty(domain)) + throw new ArgumentException("A stable-hash domain is required.", nameof(domain)); + + values ??= Array.Empty(); + var canonical = new StringBuilder(CanonicalPrefix); + canonical.Append(values.Length + 1).Append(';'); + AppendField(canonical, domain); + foreach (string value in values) + AppendField(canonical, value); + + using var sha = SHA256.Create(); + return sha.ComputeHash(Encoding.UTF8.GetBytes(canonical.ToString())); + } + + private static void AppendField(StringBuilder builder, string value) + { + if (value == null) + { + builder.Append("-1:"); + return; + } + + builder.Append(Encoding.UTF8.GetByteCount(value)); + builder.Append(':'); + builder.Append(value); + } + } + + public static class ExtractionOperationId + { + private const string HashDomain = "zeroengine.extraction.operation-id:v1"; + private const string Prefix = "operation:v1:"; + + public static string Create(string operationType, params string[] identityParts) + { + if (string.IsNullOrEmpty(operationType)) + throw new ArgumentException("An operation type is required.", nameof(operationType)); + + identityParts ??= Array.Empty(); + var hashParts = new string[identityParts.Length + 1]; + hashParts[0] = operationType; + Array.Copy(identityParts, 0, hashParts, 1, identityParts.Length); + string hash = ExtractionStableHash.ComputeSha256(HashDomain, hashParts); + return Prefix + hash.Substring("sha256:".Length); + } + } + + public static class ExtractionReceiptId + { + private const string HashDomain = "zeroengine.extraction.receipt-id:v1"; + private const string Prefix = "receipt:v1:"; + + public static string Create(string operationId, string receiptType) + { + if (string.IsNullOrEmpty(operationId)) + throw new ArgumentException("An operation id is required.", nameof(operationId)); + if (string.IsNullOrEmpty(receiptType)) + throw new ArgumentException("A receipt type is required.", nameof(receiptType)); + + string hash = ExtractionStableHash.ComputeSha256(HashDomain, operationId, receiptType); + return Prefix + hash.Substring("sha256:".Length); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionStableHash.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionStableHash.cs.meta new file mode 100644 index 0000000..8318121 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionStableHash.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 88c6c4c6456e42cc9f949ffcab2260e0 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionStaticItemCatalog.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionStaticItemCatalog.cs new file mode 100644 index 0000000..00d4b2f --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionStaticItemCatalog.cs @@ -0,0 +1,28 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionStaticItemCatalog : IExtractionItemCatalog + { + public List ItemDefinitions = new(); + + public bool TryGetItemDefinition(string definitionId, out ExtractionItemDefinition definition) + { + foreach (var candidate in ItemDefinitions) + { + if (candidate != null && candidate.DefinitionId == definitionId) + { + definition = candidate; + return true; + } + } + + definition = null; + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionStaticItemCatalog.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionStaticItemCatalog.cs.meta new file mode 100644 index 0000000..b5b40cc --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionStaticItemCatalog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 68a9a864f8184b819fc9d15d26a8a866 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionTrainingPassiveDefinition.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionTrainingPassiveDefinition.cs new file mode 100644 index 0000000..1f08db9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionTrainingPassiveDefinition.cs @@ -0,0 +1,31 @@ +using System; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionTrainingPassiveDefinition + { + public string PassiveId; + public int RequiredTrainingLevel; + public int IntValue; + public string RequiredSharedUnlockId; + public int RequiredSharedUnlockLevel; + public string RequiredSharedTalentId; + + public bool IsValid => + !string.IsNullOrEmpty(PassiveId) + && RequiredTrainingLevel > 0; + + public ExtractionTrainingPassiveDefinition( + string passiveId, + int requiredTrainingLevel, + int intValue) + { + PassiveId = passiveId; + RequiredTrainingLevel = requiredTrainingLevel; + IntValue = intValue; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionTrainingPassiveDefinition.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionTrainingPassiveDefinition.cs.meta new file mode 100644 index 0000000..8451424 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionTrainingPassiveDefinition.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 990c940975ac3cc42b116883b99bf398 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionTrainingPassiveService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionTrainingPassiveService.cs new file mode 100644 index 0000000..803569f --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionTrainingPassiveService.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; + +namespace POB.Extraction +{ + public static class ExtractionTrainingPassiveService + { + public static bool TryGetAvailablePassives( + ExtractionProfileSaveData profile, + List definitions, + IExtractionMetaProgressProvider metaProgress, + List results) + { + if (results == null) return false; + results.Clear(); + if (!ExtractionFeatureSwitch.Enabled) return false; + if (profile == null || definitions == null) return false; + profile.EnsureInitialized(); + + int trainingLevel = profile.Facilities.GetLevel(ExtractionFacilityType.Training); + foreach (var definition in definitions) + { + if (definition == null || !definition.IsValid) continue; + if (trainingLevel < definition.RequiredTrainingLevel) continue; + if (!MeetsMetaRequirements(definition, metaProgress)) continue; + + results.Add(definition); + } + + return results.Count > 0; + } + + private static bool MeetsMetaRequirements( + ExtractionTrainingPassiveDefinition definition, + IExtractionMetaProgressProvider metaProgress) + { + metaProgress ??= new ExtractionNoOpMetaProgressProvider(); + + if (!string.IsNullOrEmpty(definition.RequiredSharedUnlockId)) + { + if (definition.RequiredSharedUnlockLevel <= 0) return false; + if (metaProgress.GetSharedUnlockLevel(definition.RequiredSharedUnlockId) < definition.RequiredSharedUnlockLevel) + return false; + } + + if (!string.IsNullOrEmpty(definition.RequiredSharedTalentId)) + { + if (!metaProgress.HasSharedTalent(definition.RequiredSharedTalentId)) return false; + } + + return true; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionTrainingPassiveService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionTrainingPassiveService.cs.meta new file mode 100644 index 0000000..b8ce453 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionTrainingPassiveService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 54d9eef0df81b754d93aa884f5e01643 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionWeaponGrowthService.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionWeaponGrowthService.cs new file mode 100644 index 0000000..cd9f17b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionWeaponGrowthService.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; + +namespace POB.Extraction +{ + [Serializable] + public class ExtractionWeaponGrowthDefinition + { + public string ItemDefinitionId; + public List EnhancementSteps = new(); + public List ForgeSteps = new(); + public List Affixes = new(); + } + + [Serializable] + public class ExtractionWeaponGrowthStepDefinition + { + public int TargetLevel; + public List Costs = new(); + + public ExtractionWeaponGrowthStepDefinition(int targetLevel) + { + TargetLevel = targetLevel; + } + } + + [Serializable] + public class ExtractionWeaponAffixDefinition + { + public string AffixId; + public List Costs = new(); + + public ExtractionWeaponAffixDefinition(string affixId) + { + AffixId = affixId; + } + } + + public enum ExtractionWeaponGrowthResult + { + Succeeded = 0, + AlreadyApplied = 1, + Cancelled = 2, + InvalidRequest = 3, + WeaponNotFound = 4, + NotAWeapon = 5, + StepUnavailable = 6, + InsufficientMaterials = 7, + InvalidAffix = 8, + CommitFailed = 9 + } + + public static class ExtractionWeaponGrowthService + { + public static bool TryEnhance( + ExtractionProfileSaveData profile, + IExtractionItemCatalog itemCatalog, + ExtractionWeaponGrowthDefinition growth, + string weaponItemInstanceId, + List materialContainers, + string receiptId, + out ExtractionWeaponGrowthResult result) + { + return TryApplyStep( + profile, + itemCatalog, + growth, + weaponItemInstanceId, + materialContainers, + receiptId, + isForge: false, + out result); + } + + public static bool TryForge( + ExtractionProfileSaveData profile, + IExtractionItemCatalog itemCatalog, + ExtractionWeaponGrowthDefinition growth, + string weaponItemInstanceId, + List materialContainers, + string receiptId, + out ExtractionWeaponGrowthResult result) + { + return TryApplyStep( + profile, + itemCatalog, + growth, + weaponItemInstanceId, + materialContainers, + receiptId, + isForge: true, + out result); + } + + public static bool TryReplaceAffix( + ExtractionProfileSaveData profile, + IExtractionItemCatalog itemCatalog, + ExtractionWeaponGrowthDefinition growth, + string weaponItemInstanceId, + int affixIndex, + string newAffixId, + bool confirmed, + List materialContainers, + string receiptId, + out ExtractionWeaponGrowthResult result) + { + result = confirmed + ? ExtractionWeaponGrowthResult.InvalidRequest + : ExtractionWeaponGrowthResult.Cancelled; + if (!confirmed) return false; + if (!TryGetWeapon( + profile, + itemCatalog, + growth, + weaponItemInstanceId, + receiptId, + out var weapon, + out result)) + { + return result == ExtractionWeaponGrowthResult.AlreadyApplied; + } + if (affixIndex < 0 || affixIndex > weapon.AffixIds.Count) + { + result = ExtractionWeaponGrowthResult.InvalidAffix; + return false; + } + if (!TryGetAffix(growth, newAffixId, out var affix)) + { + result = ExtractionWeaponGrowthResult.InvalidAffix; + return false; + } + if (!TryConsume(profile, itemCatalog, affix.Costs, materialContainers, out var receipt)) + { + result = ExtractionWeaponGrowthResult.InsufficientMaterials; + return false; + } + + try + { + if (affixIndex == weapon.AffixIds.Count) weapon.AffixIds.Add(newAffixId); + else weapon.AffixIds[affixIndex] = newAffixId; + profile.ItemActionReceiptIds.Add(receiptId); + } + catch + { + ExtractionItemCostService.RestoreCosts(profile, receipt, itemCatalog); + throw; + } + + result = ExtractionWeaponGrowthResult.Succeeded; + return true; + } + + private static bool TryApplyStep( + ExtractionProfileSaveData profile, + IExtractionItemCatalog itemCatalog, + ExtractionWeaponGrowthDefinition growth, + string weaponItemInstanceId, + List materialContainers, + string receiptId, + bool isForge, + out ExtractionWeaponGrowthResult result) + { + if (!TryGetWeapon( + profile, + itemCatalog, + growth, + weaponItemInstanceId, + receiptId, + out var weapon, + out result)) + { + return result == ExtractionWeaponGrowthResult.AlreadyApplied; + } + + int current = isForge ? weapon.ForgeTier : weapon.EnhancementLevel; + var steps = isForge ? growth.ForgeSteps : growth.EnhancementSteps; + if (!TryGetStep(steps, current + 1, out var step)) + { + result = ExtractionWeaponGrowthResult.StepUnavailable; + return false; + } + if (!TryConsume(profile, itemCatalog, step.Costs, materialContainers, out var receipt)) + { + result = ExtractionWeaponGrowthResult.InsufficientMaterials; + return false; + } + + if (isForge) weapon.ForgeTier = step.TargetLevel; + else weapon.EnhancementLevel = step.TargetLevel; + profile.ItemActionReceiptIds.Add(receiptId); + result = ExtractionWeaponGrowthResult.Succeeded; + return true; + } + + private static bool TryGetWeapon( + ExtractionProfileSaveData profile, + IExtractionItemCatalog itemCatalog, + ExtractionWeaponGrowthDefinition growth, + string weaponItemInstanceId, + string receiptId, + out ExtractionItemInstance weapon, + out ExtractionWeaponGrowthResult result) + { + weapon = null; + result = ExtractionWeaponGrowthResult.InvalidRequest; + if (!ExtractionFeatureSwitch.Enabled + || profile == null + || itemCatalog == null + || growth == null + || string.IsNullOrEmpty(growth.ItemDefinitionId) + || string.IsNullOrEmpty(weaponItemInstanceId) + || string.IsNullOrEmpty(receiptId) + || materialListsInvalid(growth)) + { + return false; + } + + profile.EnsureInitialized(); + if (profile.ItemActionReceiptIds.Contains(receiptId)) + { + result = ExtractionWeaponGrowthResult.AlreadyApplied; + return false; + } + if (!profile.Items.TryGet(weaponItemInstanceId, out weapon) + || weapon.DefinitionId != growth.ItemDefinitionId + || !itemCatalog.TryGetItemDefinition(weapon.DefinitionId, out var definition)) + { + result = ExtractionWeaponGrowthResult.WeaponNotFound; + return false; + } + + if (!ExtractionItemActionPolicyService.CanEquip(definition) + || definition.ActionPolicy.EquipmentSlotType != ExtractionEquipmentSlotType.Weapon) + { + result = ExtractionWeaponGrowthResult.NotAWeapon; + return false; + } + + return true; + } + + private static bool TryConsume( + ExtractionProfileSaveData profile, + IExtractionItemCatalog itemCatalog, + List costs, + List containers, + out ExtractionCostConsumptionReceipt receipt) + { + receipt = null; + if (costs == null || costs.Count == 0) return false; + if (!ExtractionItemCostService.HasEnoughItems(profile, costs, containers)) return false; + return ExtractionItemCostService.TryConsumeCosts(profile, costs, containers, out receipt); + } + + private static bool TryGetStep( + List steps, + int targetLevel, + out ExtractionWeaponGrowthStepDefinition step) + { + step = null; + if (steps == null) return false; + foreach (var candidate in steps) + { + if (candidate?.TargetLevel != targetLevel) continue; + if (step != null) return false; + step = candidate; + } + return step != null; + } + + private static bool TryGetAffix( + ExtractionWeaponGrowthDefinition growth, + string affixId, + out ExtractionWeaponAffixDefinition affix) + { + affix = null; + if (growth?.Affixes == null || string.IsNullOrEmpty(affixId)) return false; + foreach (var candidate in growth.Affixes) + { + if (candidate?.AffixId != affixId) continue; + if (affix != null) return false; + affix = candidate; + } + return affix != null; + } + + private static bool materialListsInvalid(ExtractionWeaponGrowthDefinition growth) + { + return growth.EnhancementSteps == null || growth.ForgeSteps == null || growth.Affixes == null; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionWeaponGrowthService.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionWeaponGrowthService.cs.meta new file mode 100644 index 0000000..d44b1eb --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ExtractionWeaponGrowthService.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 0897844b55354cd6873df7f5876f9d4e diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionItemCatalog.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionItemCatalog.cs new file mode 100644 index 0000000..63e618d --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionItemCatalog.cs @@ -0,0 +1,7 @@ +namespace POB.Extraction +{ + public interface IExtractionItemCatalog + { + bool TryGetItemDefinition(string definitionId, out ExtractionItemDefinition definition); + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionItemCatalog.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionItemCatalog.cs.meta new file mode 100644 index 0000000..b05556a --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionItemCatalog.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d485180ebc0944d599de4be36d1ef924 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionMetaProgressProvider.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionMetaProgressProvider.cs new file mode 100644 index 0000000..812a4eb --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionMetaProgressProvider.cs @@ -0,0 +1,21 @@ +namespace POB.Extraction +{ + public interface IExtractionMetaProgressProvider + { + int GetSharedUnlockLevel(string unlockId); + bool HasSharedTalent(string talentId); + } + + public sealed class ExtractionNoOpMetaProgressProvider : IExtractionMetaProgressProvider + { + public int GetSharedUnlockLevel(string unlockId) + { + return 0; + } + + public bool HasSharedTalent(string talentId) + { + return false; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionMetaProgressProvider.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionMetaProgressProvider.cs.meta new file mode 100644 index 0000000..d5b07c3 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionMetaProgressProvider.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: ba759630e974466c9b9dc5e4fbce8b2a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionPickupAdapter.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionPickupAdapter.cs new file mode 100644 index 0000000..847dccb --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionPickupAdapter.cs @@ -0,0 +1,34 @@ +namespace POB.Extraction +{ + public interface IExtractionPickupAdapter + { + bool TryCreatePickup( + ExtractionPickupAdapterRequest request, + IExtractionItemCatalog catalog, + out ExtractionLootPickup pickup); + } + + public class ExtractionCatalogPickupAdapter : IExtractionPickupAdapter + { + public bool TryCreatePickup( + ExtractionPickupAdapterRequest request, + IExtractionItemCatalog catalog, + out ExtractionLootPickup pickup) + { + pickup = null; + if (request == null || catalog == null || !request.IsValid) return false; + if (!catalog.TryGetItemDefinition(request.DefinitionId, out var definition)) return false; + if (definition == null || request.Quantity > definition.MaxStack) return false; + + var item = new ExtractionItemInstance( + request.InstanceId, + request.DefinitionId, + request.Quantity, + request.SourceKind, + request.SourceId); + ExtractionItemActionPolicyService.ApplyDefinitionPolicyToInstance(definition, item); + pickup = new ExtractionLootPickup(item, definition, request.CanEnterSecureContainer); + return pickup.IsValid; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionPickupAdapter.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionPickupAdapter.cs.meta new file mode 100644 index 0000000..0022e54 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionPickupAdapter.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6b22dcaa0c7ce864baff3683c0d966a7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionProfileStore.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionProfileStore.cs new file mode 100644 index 0000000..45cb574 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionProfileStore.cs @@ -0,0 +1,10 @@ +namespace POB.Extraction +{ + public interface IExtractionProfileStore + { + ExtractionProfileLoadResult Load(); + ExtractionProfileCommitResult Commit(ExtractionProfileDraft draft); + ExtractionProfileSaveData LoadProfile(); + ExtractionProfileCommitResult SaveProfile(ExtractionProfileSaveData profile); + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionProfileStore.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionProfileStore.cs.meta new file mode 100644 index 0000000..02596d3 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionProfileStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c300a8251edc4977978122330c3413a7 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionRewardApplier.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionRewardApplier.cs new file mode 100644 index 0000000..9573048 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionRewardApplier.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using UnityEngine.Scripting.APIUpdating; + +namespace POB.Extraction +{ + [Serializable] + [MovedFrom(true, sourceAssembly: "POB.Runtime")] + public class ExtractionRewardGrant + { + public string RewardId; + public int Quantity; + + public bool IsValid => !string.IsNullOrEmpty(RewardId) && Quantity > 0; + + public ExtractionRewardGrant(string rewardId, int quantity) + { + RewardId = rewardId; + Quantity = quantity; + } + } + + public class ExtractionRewardApplyReport + { + public int AppliedCount { get; } + + public ExtractionRewardApplyReport(int appliedCount) + { + AppliedCount = appliedCount < 0 ? 0 : appliedCount; + } + } + + public interface IExtractionRewardApplier + { + bool TryApplyRewards( + List rewards, + out ExtractionRewardApplyReport report); + } + + public sealed class ExtractionNoOpRewardApplier : IExtractionRewardApplier + { + public bool TryApplyRewards( + List rewards, + out ExtractionRewardApplyReport report) + { + report = new ExtractionRewardApplyReport(0); + return rewards == null || rewards.Count == 0; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionRewardApplier.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionRewardApplier.cs.meta new file mode 100644 index 0000000..c6eb5fe --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/IExtractionRewardApplier.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 57e0193663a8e0b42b0351e328a14d20 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ISharedMetaWallet.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/ISharedMetaWallet.cs new file mode 100644 index 0000000..6bc30d0 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ISharedMetaWallet.cs @@ -0,0 +1,85 @@ +using System; + +namespace POB.Extraction +{ + public enum SharedMetaWalletTransactionStatus + { + Succeeded = 0, + AlreadyApplied = 1, + InsufficientFunds = 2, + InvalidRequest = 3, + StoreUnavailable = 4, + Conflict = 5 + } + + [Serializable] + public sealed class SharedMetaWalletBalance + { + public string ResourceId; + public int Balance; + + public SharedMetaWalletBalance(string resourceId, int balance) + { + ResourceId = resourceId; + Balance = balance; + } + } + + [Serializable] + public sealed class SharedMetaWalletTransactionRequest + { + public string TransactionId; + public string ResourceId; + public int Quantity; + public string PayloadHash; + + public SharedMetaWalletTransactionRequest( + string transactionId, + string resourceId, + int quantity, + string payloadHash) + { + TransactionId = transactionId; + ResourceId = resourceId; + Quantity = quantity; + PayloadHash = payloadHash; + } + + public bool IsValid => + !string.IsNullOrEmpty(TransactionId) + && !string.IsNullOrEmpty(ResourceId) + && Quantity > 0 + && !string.IsNullOrEmpty(PayloadHash); + } + + [Serializable] + public sealed class SharedMetaWalletTransactionResult + { + public SharedMetaWalletTransactionStatus Status; + public int Balance; + public string Message; + + public bool IsSuccess => + Status == SharedMetaWalletTransactionStatus.Succeeded + || Status == SharedMetaWalletTransactionStatus.AlreadyApplied; + + public SharedMetaWalletTransactionResult( + SharedMetaWalletTransactionStatus status, + int balance, + string message = null) + { + Status = status; + Balance = balance; + Message = message; + } + } + + public interface ISharedMetaWallet + { + bool TryQuery(string resourceId, out SharedMetaWalletBalance balance); + + SharedMetaWalletTransactionResult TryDebit(SharedMetaWalletTransactionRequest request); + + SharedMetaWalletTransactionResult TryCredit(SharedMetaWalletTransactionRequest request); + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/ISharedMetaWallet.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/ISharedMetaWallet.cs.meta new file mode 100644 index 0000000..40fd7d1 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/ISharedMetaWallet.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a9e42fc2e59e4e228fb3e7b045e0d8b4 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/POB.Extraction.Core.asmdef b/com.zerogamestudio.zeroengine.extraction/Runtime/POB.Extraction.Core.asmdef new file mode 100644 index 0000000..e8a132c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/POB.Extraction.Core.asmdef @@ -0,0 +1,14 @@ +{ + "name": "POB.Extraction.Core", + "rootNamespace": "POB.Extraction", + "references": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/POB.Extraction.Core.asmdef.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/POB.Extraction.Core.asmdef.meta new file mode 100644 index 0000000..d7c44eb --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/POB.Extraction.Core.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 00088d56e16baca4a90523f8ad0bf2e2 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/SharedMetaWalletProfileSaga.cs b/com.zerogamestudio.zeroengine.extraction/Runtime/SharedMetaWalletProfileSaga.cs new file mode 100644 index 0000000..bcda08c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/SharedMetaWalletProfileSaga.cs @@ -0,0 +1,484 @@ +using System; +using System.Globalization; + +namespace POB.Extraction +{ + public enum SharedMetaWalletProfileFlow + { + DebitThenProfile = 0, + ProfileThenCredit = 1 + } + + public enum SharedMetaWalletProfileSagaStatus + { + Succeeded = 0, + PresentationPending = 1, + AlreadyCompleted = 2, + Compensated = 3, + InsufficientFunds = 4, + InvalidRequest = 5, + StoreUnavailable = 6, + Conflict = 7, + DomainRejected = 8, + RecoveryPending = 9 + } + + [Serializable] + public sealed class SharedMetaWalletProfileSagaRequest + { + private const string JournalHashDomain = + "zeroengine.extraction.shared-wallet-profile-saga:v1"; + private const string CompensationHashDomain = + "zeroengine.extraction.shared-wallet-compensation:v1"; + + public string TransactionId; + public string OperationType; + public string ResourceId; + public int Quantity; + public string PayloadHash; + public SharedMetaWalletProfileFlow Flow; + + public SharedMetaWalletProfileSagaRequest( + string transactionId, + string operationType, + string resourceId, + int quantity, + string payloadHash, + SharedMetaWalletProfileFlow flow) + { + TransactionId = transactionId; + OperationType = operationType; + ResourceId = resourceId; + Quantity = quantity; + PayloadHash = payloadHash; + Flow = flow; + } + + public bool IsValid => + !string.IsNullOrEmpty(OperationType) + && Enum.IsDefined(typeof(SharedMetaWalletProfileFlow), Flow) + && CreateWalletRequest().IsValid; + + internal string CreateJournalPayloadHash() + { + return ExtractionStableHash.ComputeSha256( + JournalHashDomain, + PayloadHash, + ((int)Flow).ToString(CultureInfo.InvariantCulture), + ResourceId, + Quantity.ToString(CultureInfo.InvariantCulture)); + } + + internal SharedMetaWalletTransactionRequest CreateWalletRequest() + { + return new SharedMetaWalletTransactionRequest( + TransactionId, + ResourceId, + Quantity, + PayloadHash); + } + + internal SharedMetaWalletTransactionRequest CreateCompensationRequest() + { + string transactionId = ExtractionReceiptId.Create( + TransactionId, + "shared-wallet.compensation"); + string payloadHash = ExtractionStableHash.ComputeSha256( + CompensationHashDomain, + TransactionId, + ResourceId, + Quantity.ToString(CultureInfo.InvariantCulture), + PayloadHash); + return new SharedMetaWalletTransactionRequest( + transactionId, + ResourceId, + Quantity, + payloadHash); + } + } + + [Serializable] + public sealed class SharedMetaWalletProfileSagaResult + { + public SharedMetaWalletProfileSagaStatus Status; + public int Balance; + + public SharedMetaWalletProfileSagaResult( + SharedMetaWalletProfileSagaStatus status, + int balance) + { + Status = status; + Balance = balance; + } + + public bool IsSuccess => + Status == SharedMetaWalletProfileSagaStatus.Succeeded + || Status == SharedMetaWalletProfileSagaStatus.PresentationPending + || Status == SharedMetaWalletProfileSagaStatus.AlreadyCompleted; + + public bool NeedsPresentation => + Status == SharedMetaWalletProfileSagaStatus.Succeeded + || Status == SharedMetaWalletProfileSagaStatus.PresentationPending; + } + + /// + /// Coordinates one profile mutation with one idempotent wallet receipt. + /// Debit writes Prepared before charging. Credit commits the profile before paying. + /// Compensation is itself persisted before the reversing wallet receipt is applied. + /// + public sealed class SharedMetaWalletProfileSaga + { + private readonly IExtractionProfileStore store; + private readonly ISharedMetaWallet wallet; + + public SharedMetaWalletProfileSaga( + IExtractionProfileStore store, + ISharedMetaWallet wallet) + { + this.store = store; + this.wallet = wallet; + } + + public SharedMetaWalletProfileSagaResult Execute( + SharedMetaWalletProfileSagaRequest request, + Func canApplyDomain, + Action applyDomain) + { + if (!IsValid(request, canApplyDomain, applyDomain)) + return Result(SharedMetaWalletProfileSagaStatus.InvalidRequest, request); + if (store == null || wallet == null) + return Result(SharedMetaWalletProfileSagaStatus.StoreUnavailable, request); + if (!TryOpen(request, out var draft, out var entry, out var action, out bool isNew, out var failure)) + return Result(failure, request); + + switch (action) + { + case ExtractionOperationReplayAction.ResumeCompensation: + return RecoverCompensation(request); + case ExtractionOperationReplayAction.ReplayPresentation: + return ReplayCommitted(request); + case ExtractionOperationReplayAction.NoOp: + return ResolveTerminal(entry, request); + case ExtractionOperationReplayAction.ApplyDomain: + return request.Flow == SharedMetaWalletProfileFlow.DebitThenProfile + ? ExecuteDebit(request, canApplyDomain, applyDomain, draft, isNew) + : ExecuteCredit(request, canApplyDomain, applyDomain, draft, entry); + default: + return Result(SharedMetaWalletProfileSagaStatus.Conflict, request); + } + } + + public SharedMetaWalletProfileSagaResult Complete(SharedMetaWalletProfileSagaRequest request) + { + if (request == null || !request.IsValid) + return Result(SharedMetaWalletProfileSagaStatus.InvalidRequest, request); + if (store == null || wallet == null) + return Result(SharedMetaWalletProfileSagaStatus.StoreUnavailable, request); + if (!TryOpen(request, out var draft, out var entry, out var action, out bool isNew, out var failure) + || isNew) + { + return Result(isNew ? SharedMetaWalletProfileSagaStatus.Conflict : failure, request); + } + + if (action == ExtractionOperationReplayAction.ResumeCompensation) + return RecoverCompensation(request); + if (action == ExtractionOperationReplayAction.NoOp) + return ResolveTerminal(entry, request); + if (action != ExtractionOperationReplayAction.ReplayPresentation) + return Result(SharedMetaWalletProfileSagaStatus.RecoveryPending, request); + + var walletResult = ApplyWallet(request); + if (!walletResult.IsSuccess) + return MapWalletFailure(walletResult, request, committedProfile: true); + if (!ExtractionOperationJournalService.TryMarkCompleted(entry) || !Commit(draft)) + return Result(SharedMetaWalletProfileSagaStatus.RecoveryPending, request, walletResult.Balance); + + return Result(SharedMetaWalletProfileSagaStatus.Succeeded, request, walletResult.Balance); + } + + private SharedMetaWalletProfileSagaResult ExecuteDebit( + SharedMetaWalletProfileSagaRequest request, + Func canApplyDomain, + Action applyDomain, + ExtractionProfileDraft preparedDraft, + bool isNew) + { + if (isNew) + { + if (!CanApply(canApplyDomain, preparedDraft.Profile)) + return Result(SharedMetaWalletProfileSagaStatus.DomainRejected, request); + if (!Commit(preparedDraft)) + return Result(SharedMetaWalletProfileSagaStatus.StoreUnavailable, request); + } + + var walletResult = wallet.TryDebit(request.CreateWalletRequest()); + if (!walletResult.IsSuccess) + { + return walletResult.Status == SharedMetaWalletTransactionStatus.StoreUnavailable + ? MapWalletFailure(walletResult, request, committedProfile: false) + : TerminateRejectedDebit(request, walletResult); + } + + if (!TryOpen(request, out var draft, out var entry, out var action, out _, out _)) + return Result(SharedMetaWalletProfileSagaStatus.RecoveryPending, request, walletResult.Balance); + if (action == ExtractionOperationReplayAction.ResumeCompensation) + return RecoverCompensation(request); + if (action == ExtractionOperationReplayAction.ReplayPresentation) + return Result(SharedMetaWalletProfileSagaStatus.PresentationPending, request, walletResult.Balance); + if (action == ExtractionOperationReplayAction.NoOp) + return ResolveTerminal(entry, request, walletResult.Balance); + if (action != ExtractionOperationReplayAction.ApplyDomain) + return Result(SharedMetaWalletProfileSagaStatus.Conflict, request, walletResult.Balance); + + if (!CanApply(canApplyDomain, draft.Profile)) + return BeginCompensation(request, walletResult.Balance); + + try + { + applyDomain(draft.Profile); + } + catch (Exception) + { + return BeginCompensation(request, walletResult.Balance); + } + + if (!ExtractionOperationJournalService.TryMarkCommitted(entry) || !Commit(draft)) + return Result(SharedMetaWalletProfileSagaStatus.RecoveryPending, request, walletResult.Balance); + + return Result(SharedMetaWalletProfileSagaStatus.Succeeded, request, walletResult.Balance); + } + + private SharedMetaWalletProfileSagaResult ExecuteCredit( + SharedMetaWalletProfileSagaRequest request, + Func canApplyDomain, + Action applyDomain, + ExtractionProfileDraft draft, + ExtractionOperationJournalEntry entry) + { + if (!wallet.TryQuery(request.ResourceId, out _)) + return Result(SharedMetaWalletProfileSagaStatus.StoreUnavailable, request); + if (!CanApply(canApplyDomain, draft.Profile)) + return Result(SharedMetaWalletProfileSagaStatus.DomainRejected, request); + + try + { + applyDomain(draft.Profile); + } + catch (Exception) + { + return Result(SharedMetaWalletProfileSagaStatus.DomainRejected, request); + } + + if (!ExtractionOperationJournalService.TryMarkCommitted(entry) || !Commit(draft)) + return Result(SharedMetaWalletProfileSagaStatus.StoreUnavailable, request); + + var walletResult = wallet.TryCredit(request.CreateWalletRequest()); + return walletResult.IsSuccess + ? Result(SharedMetaWalletProfileSagaStatus.Succeeded, request, walletResult.Balance) + : MapWalletFailure(walletResult, request, committedProfile: true); + } + + private SharedMetaWalletProfileSagaResult ReplayCommitted( + SharedMetaWalletProfileSagaRequest request) + { + var walletResult = ApplyWallet(request); + return walletResult.IsSuccess + ? Result(SharedMetaWalletProfileSagaStatus.PresentationPending, request, walletResult.Balance) + : MapWalletFailure(walletResult, request, committedProfile: true); + } + + private SharedMetaWalletProfileSagaResult TerminateRejectedDebit( + SharedMetaWalletProfileSagaRequest request, + SharedMetaWalletTransactionResult walletResult) + { + if (!TryOpen(request, out var draft, out var entry, out var action, out _, out _) + || action != ExtractionOperationReplayAction.ApplyDomain + || !ExtractionOperationJournalService.TryMarkCompensated(entry) + || !Commit(draft)) + { + return Result(SharedMetaWalletProfileSagaStatus.RecoveryPending, request, walletResult.Balance); + } + + return MapWalletFailure(walletResult, request, committedProfile: false); + } + + private SharedMetaWalletProfileSagaResult BeginCompensation( + SharedMetaWalletProfileSagaRequest request, + int balance) + { + if (!TryOpen(request, out var draft, out var entry, out var action, out _, out _)) + return Result(SharedMetaWalletProfileSagaStatus.RecoveryPending, request, balance); + if (action == ExtractionOperationReplayAction.ResumeCompensation) + return RecoverCompensation(request); + if (action == ExtractionOperationReplayAction.NoOp) + return ResolveTerminal(entry, request, balance); + if (action != ExtractionOperationReplayAction.ApplyDomain) + return Result(SharedMetaWalletProfileSagaStatus.Conflict, request, balance); + if (!ExtractionOperationJournalService.TryMarkCompensationPending(entry) || !Commit(draft)) + return Result(SharedMetaWalletProfileSagaStatus.RecoveryPending, request, balance); + + return RecoverCompensation(request); + } + + private SharedMetaWalletProfileSagaResult RecoverCompensation( + SharedMetaWalletProfileSagaRequest request) + { + var walletResult = wallet.TryCredit(request.CreateCompensationRequest()); + if (!walletResult.IsSuccess) + { + return Result( + walletResult.Status == SharedMetaWalletTransactionStatus.Conflict + ? SharedMetaWalletProfileSagaStatus.Conflict + : SharedMetaWalletProfileSagaStatus.RecoveryPending, + request, + walletResult.Balance); + } + + if (!TryOpen(request, out var draft, out var entry, out var action, out _, out _)) + return Result(SharedMetaWalletProfileSagaStatus.RecoveryPending, request, walletResult.Balance); + if (action == ExtractionOperationReplayAction.NoOp) + return ResolveTerminal(entry, request, walletResult.Balance); + if (action != ExtractionOperationReplayAction.ResumeCompensation + || !ExtractionOperationJournalService.TryMarkCompensated(entry) + || !Commit(draft)) + { + return Result(SharedMetaWalletProfileSagaStatus.RecoveryPending, request, walletResult.Balance); + } + + return Result(SharedMetaWalletProfileSagaStatus.Compensated, request, walletResult.Balance); + } + + private SharedMetaWalletTransactionResult ApplyWallet( + SharedMetaWalletProfileSagaRequest request) + { + return request.Flow == SharedMetaWalletProfileFlow.DebitThenProfile + ? wallet.TryDebit(request.CreateWalletRequest()) + : wallet.TryCredit(request.CreateWalletRequest()); + } + + private SharedMetaWalletProfileSagaResult MapWalletFailure( + SharedMetaWalletTransactionResult walletResult, + SharedMetaWalletProfileSagaRequest request, + bool committedProfile) + { + SharedMetaWalletProfileSagaStatus status; + switch (walletResult.Status) + { + case SharedMetaWalletTransactionStatus.InsufficientFunds: + status = committedProfile + ? SharedMetaWalletProfileSagaStatus.Conflict + : SharedMetaWalletProfileSagaStatus.InsufficientFunds; + break; + case SharedMetaWalletTransactionStatus.InvalidRequest: + status = SharedMetaWalletProfileSagaStatus.InvalidRequest; + break; + case SharedMetaWalletTransactionStatus.Conflict: + status = SharedMetaWalletProfileSagaStatus.Conflict; + break; + default: + status = committedProfile + ? SharedMetaWalletProfileSagaStatus.RecoveryPending + : SharedMetaWalletProfileSagaStatus.StoreUnavailable; + break; + } + + return Result(status, request, walletResult.Balance); + } + + private bool TryOpen( + SharedMetaWalletProfileSagaRequest request, + out ExtractionProfileDraft draft, + out ExtractionOperationJournalEntry entry, + out ExtractionOperationReplayAction action, + out bool isNew, + out SharedMetaWalletProfileSagaStatus failure) + { + draft = null; + entry = null; + action = ExtractionOperationReplayAction.Conflict; + isNew = false; + failure = SharedMetaWalletProfileSagaStatus.StoreUnavailable; + + var loaded = store?.Load(); + if (loaded == null || !loaded.TryCreateDraft(out draft)) return false; + + int entryCount = draft.Profile.OperationJournal.Entries.Count; + if (!ExtractionOperationJournalService.TryPrepare( + draft.Profile.OperationJournal, + request.TransactionId, + request.OperationType, + request.CreateJournalPayloadHash(), + out entry, + out action)) + { + failure = SharedMetaWalletProfileSagaStatus.Conflict; + return false; + } + + isNew = draft.Profile.OperationJournal.Entries.Count != entryCount; + return true; + } + + private bool Commit(ExtractionProfileDraft draft) + { + return draft != null && store?.Commit(draft)?.Success == true; + } + + private SharedMetaWalletProfileSagaResult ResolveTerminal( + ExtractionOperationJournalEntry entry, + SharedMetaWalletProfileSagaRequest request, + int? balance = null) + { + if (entry != null && entry.TryGetState(out var state)) + { + if (state == ExtractionOperationState.Completed) + return Result(SharedMetaWalletProfileSagaStatus.AlreadyCompleted, request, balance); + if (state == ExtractionOperationState.Compensated) + return Result(SharedMetaWalletProfileSagaStatus.Compensated, request, balance); + } + + return Result(SharedMetaWalletProfileSagaStatus.Conflict, request, balance); + } + + private SharedMetaWalletProfileSagaResult Result( + SharedMetaWalletProfileSagaStatus status, + SharedMetaWalletProfileSagaRequest request, + int? balance = null) + { + if (!balance.HasValue + && request != null + && wallet != null + && wallet.TryQuery(request.ResourceId, out var current)) + { + balance = current.Balance; + } + + return new SharedMetaWalletProfileSagaResult(status, balance ?? 0); + } + + private bool IsValid( + SharedMetaWalletProfileSagaRequest request, + Func canApplyDomain, + Action applyDomain) + { + return request != null + && request.IsValid + && canApplyDomain != null + && applyDomain != null; + } + + private static bool CanApply( + Func canApplyDomain, + ExtractionProfileSaveData profile) + { + try + { + return canApplyDomain(profile); + } + catch (Exception) + { + return false; + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Runtime/SharedMetaWalletProfileSaga.cs.meta b/com.zerogamestudio.zeroengine.extraction/Runtime/SharedMetaWalletProfileSaga.cs.meta new file mode 100644 index 0000000..91a3ed9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Runtime/SharedMetaWalletProfileSaga.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 2945bc27166f453984d65cc7f32a0105 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Tests.meta b/com.zerogamestudio.zeroengine.extraction/Tests.meta new file mode 100644 index 0000000..6064c44 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a2b30f48131547f4090a6fc5a158afcb +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor.meta new file mode 100644 index 0000000..afa27c9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: f572600df5bc3e84f866004ac8415445 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionCharacterProfileValidatorTests.cs b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionCharacterProfileValidatorTests.cs new file mode 100644 index 0000000..a175c47 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionCharacterProfileValidatorTests.cs @@ -0,0 +1,158 @@ +using NUnit.Framework; + +namespace POB.Extraction.Core.Package.Tests.Editor +{ + public class ExtractionCharacterProfileValidatorTests + { + [Test] + public void TryInitializeAndValidate_NewProfile_AppliesDeterministicDefaults() + { + var profile = ExtractionProfileSaveData.CreateEmpty(); + var config = CreateConfig(); + + bool valid = ExtractionCharacterProfileValidator.TryInitializeAndValidate( + profile.Character, + profile.Equipment, + config, + out string issue); + + Assert.IsTrue(valid, issue); + Assert.AreEqual(1f, profile.Character.SearchSpeedMultiplier); + Assert.AreEqual(2, profile.Character.AttributeValues.Count); + Assert.AreEqual("health-flat", profile.Character.AttributeValues[0].AttributeId); + Assert.AreEqual(0f, profile.Character.AttributeValues[0].Value); + CollectionAssert.AreEqual(new[] { "move", "jump", "dash" }, profile.Character.BaselineAbilityIds); + } + + [Test] + public void TryInitializeAndValidate_LegacyProfileWithoutCharacterFields_AppliesDefaults() + { + var profile = ExtractionProfileSerialization.FromJson("{\"SchemaVersion\":2}"); + + bool valid = ExtractionCharacterProfileValidator.TryInitializeAndValidate( + profile.Character, + profile.Equipment, + CreateConfig(), + out string issue); + + Assert.IsTrue(valid, issue); + Assert.AreEqual(2, profile.Character.AttributeValues.Count); + CollectionAssert.AreEqual(new[] { "move", "jump", "dash" }, profile.Character.BaselineAbilityIds); + } + + [Test] + public void TryValidateProfile_NegativeGrowth_ReturnsFalse() + { + var profile = CreateInitializedProfile(out var config); + profile.Character.AttributeValues[0].Value = -1f; + + Assert.IsFalse(ExtractionCharacterProfileValidator.TryValidateProfile( + profile.Character, + profile.Equipment, + config, + out string issue)); + StringAssert.Contains("outside its configured range", issue); + } + + [TestCase(0.24f)] + [TestCase(4.01f)] + public void TryValidateProfile_InvalidSearchSpeedMultiplier_ReturnsFalse(float multiplier) + { + var profile = CreateInitializedProfile(out var config); + profile.Character.SearchSpeedMultiplier = multiplier; + + Assert.IsFalse(ExtractionCharacterProfileValidator.TryValidateProfile( + profile.Character, + profile.Equipment, + config, + out _)); + } + + [Test] + public void TryValidateProfile_UnknownAttributeTalentAndSlotIds_ReturnFalse() + { + var attributeProfile = CreateInitializedProfile(out var config); + attributeProfile.Character.AttributeValues.Add(new ExtractionCharacterValue("unknown-stat", 1f)); + Assert.IsFalse(ExtractionCharacterProfileValidator.TryValidateProfile( + attributeProfile.Character, + attributeProfile.Equipment, + config, + out string attributeIssue)); + StringAssert.Contains("unknown-stat", attributeIssue); + + var talentProfile = CreateInitializedProfile(out config); + talentProfile.Character.UnlockedTalentIds.Add("unknown-talent"); + Assert.IsFalse(ExtractionCharacterProfileValidator.TryValidateProfile( + talentProfile.Character, + talentProfile.Equipment, + config, + out string talentIssue)); + StringAssert.Contains("unknown-talent", talentIssue); + + var slotProfile = CreateInitializedProfile(out config); + slotProfile.Equipment.Slots.Add(new ExtractionEquipmentSlotState("unknown-slot", "item-1")); + Assert.IsFalse(ExtractionCharacterProfileValidator.TryValidateProfile( + slotProfile.Character, + slotProfile.Equipment, + config, + out string slotIssue)); + StringAssert.Contains("unknown-slot", slotIssue); + } + + [Test] + public void CharacterProfile_KnownFutureExtension_RoundTripsWithoutChangingValues() + { + var config = CreateConfig(); + config.AttributeDefinitions.Add( + new ExtractionCharacterAttributeDefinition("future-luck", 0f, 0f, 10f)); + var profile = ExtractionProfileSaveData.CreateEmpty(); + Assert.IsTrue(ExtractionCharacterProfileValidator.TryInitializeAndValidate( + profile.Character, + profile.Equipment, + config, + out string issue), issue); + profile.Character.AttributeValues[2].Value = 7f; + + string json = ExtractionProfileSerialization.ToJson(profile); + var roundTripped = ExtractionProfileSerialization.FromJson(json); + + Assert.IsTrue(ExtractionCharacterProfileValidator.TryValidateProfile( + roundTripped.Character, + roundTripped.Equipment, + config, + out issue), issue); + Assert.AreEqual("future-luck", roundTripped.Character.AttributeValues[2].AttributeId); + Assert.AreEqual(7f, roundTripped.Character.AttributeValues[2].Value); + } + + private static ExtractionProfileSaveData CreateInitializedProfile( + out ExtractionCharacterConfig config) + { + config = CreateConfig(); + var profile = ExtractionProfileSaveData.CreateEmpty(); + Assert.IsTrue(ExtractionCharacterProfileValidator.TryInitializeAndValidate( + profile.Character, + profile.Equipment, + config, + out string issue), issue); + return profile; + } + + private static ExtractionCharacterConfig CreateConfig() + { + var config = new ExtractionCharacterConfig + { + DefaultSearchSpeedMultiplier = 1f + }; + config.AttributeDefinitions.Add( + new ExtractionCharacterAttributeDefinition("health-flat", 0f, 0f, 1000f)); + config.AttributeDefinitions.Add( + new ExtractionCharacterAttributeDefinition("movement-percent", 0f, 0f, 1f)); + config.TalentDefinitions.Add(new ExtractionCharacterTalentDefinition("double-jump")); + config.InjuryDefinitions.Add(new ExtractionCharacterInjuryDefinition("wounded", 3)); + config.EquipmentSlotIds.Add("weapon-primary"); + config.BaselineAbilityIds.AddRange(new[] { "move", "jump", "dash" }); + return config; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionCharacterProfileValidatorTests.cs.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionCharacterProfileValidatorTests.cs.meta new file mode 100644 index 0000000..fdbd0d9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionCharacterProfileValidatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 84100bd1cd904a4a8c627a5570b10c93 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionCorePackageBoundaryTests.cs b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionCorePackageBoundaryTests.cs new file mode 100644 index 0000000..1456073 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionCorePackageBoundaryTests.cs @@ -0,0 +1,56 @@ +using System.IO; +using NUnit.Framework; +using UnityEditor.PackageManager; + +namespace POB.Extraction.Core.Package.Tests.Editor +{ + public class ExtractionCorePackageBoundaryTests + { + private static string PackageRoot + { + get + { + PackageInfo packageInfo = PackageInfo.FindForAssembly(typeof(ExtractionCorePackageBoundaryTests).Assembly); + Assert.That(packageInfo, Is.Not.Null, "The extraction test assembly must resolve to an installed UPM package."); + return packageInfo.resolvedPath; + } + } + + [Test] + public void PackageManifest_UsesZeroEnginePackageName() + { + string json = File.ReadAllText($"{PackageRoot}/package.json"); + + StringAssert.Contains("\"name\": \"com.zerogamestudio.zeroengine.extraction\"", json); + StringAssert.Contains("\"version\": \"0.1.0\"", json); + } + + [Test] + public void RuntimeAsmdef_PreservesCompatibleCoreAssemblyAndAvoidsPobRuntime() + { + string json = File.ReadAllText($"{PackageRoot}/Runtime/POB.Extraction.Core.asmdef"); + + StringAssert.Contains("\"name\": \"POB.Extraction.Core\"", json); + StringAssert.Contains("\"rootNamespace\": \"POB.Extraction\"", json); + StringAssert.DoesNotContain("POB.Runtime", json); + StringAssert.DoesNotContain("QFSW.QC", json); + } + + [Test] + public void RuntimeSources_DoNotReferenceAdapterOnlySystems() + { + foreach (string path in Directory.GetFiles($"{PackageRoot}/Runtime", "*.cs", SearchOption.AllDirectories)) + { + string source = File.ReadAllText(path); + StringAssert.DoesNotContain("POBSceneManager", source, path); + StringAssert.DoesNotContain("LevelType", source, path); + StringAssert.DoesNotContain("RoomType", source, path); + StringAssert.DoesNotContain("MonoBehaviour", source, path); + StringAssert.DoesNotContain("QFSW.QC", source, path); + StringAssert.DoesNotContain("ConsoleCommands", source, path); + StringAssert.DoesNotContain("IExtractionRoomGateway", source, path); + StringAssert.DoesNotContain("ExtractionRoomRoute", source, path); + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionCorePackageBoundaryTests.cs.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionCorePackageBoundaryTests.cs.meta new file mode 100644 index 0000000..6d285f2 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionCorePackageBoundaryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 86e73625796c36f439cbac601ac35382 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionItemActionPolicyTests.cs b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionItemActionPolicyTests.cs new file mode 100644 index 0000000..823fb72 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionItemActionPolicyTests.cs @@ -0,0 +1,62 @@ +using NUnit.Framework; + +namespace POB.Extraction.Core.Package.Tests.Editor +{ + public class ExtractionItemActionPolicyTests + { + [Test] + public void DefaultPolicy_OldAndNewItemsRemainDroppableSellableAndDeathDroppable() + { + var definition = new ExtractionItemDefinition("cargo", 1, 1, false, 1); + definition.ActionPolicy = new ExtractionItemActionPolicy(); + + var policy = ExtractionItemActionPolicyService.GetPolicy(definition); + + Assert.IsTrue(policy.PolicyInitialized); + Assert.IsTrue(policy.CanDrop); + Assert.IsTrue(policy.CanSell); + Assert.IsTrue(policy.DropOnDeath); + Assert.IsTrue(policy.CanPlaceInSecure); + Assert.IsFalse(policy.CanEquip); + Assert.IsFalse(policy.CanUse); + } + + [TestCase(ExtractionItemActionPreset.NormalLoot, true, true, true, false, false)] + [TestCase(ExtractionItemActionPreset.Equippable, true, true, true, true, false)] + [TestCase(ExtractionItemActionPreset.Consumable, true, true, true, false, true)] + [TestCase(ExtractionItemActionPreset.QuestKeepOnDeath, false, false, false, false, false)] + [TestCase(ExtractionItemActionPreset.QuestDropOnDeath, false, false, true, false, false)] + [TestCase(ExtractionItemActionPreset.DurableTool, true, true, true, false, true)] + public void ApplyPreset_WritesExplicitExpectedFields( + ExtractionItemActionPreset preset, + bool canDrop, + bool canSell, + bool dropOnDeath, + bool canEquip, + bool canUse) + { + var definition = new ExtractionItemDefinition("item", 1, 1, false, 1); + + Assert.IsTrue(ExtractionItemActionPresetService.TryApply(definition, preset)); + + Assert.AreEqual(canDrop, definition.ActionPolicy.CanDrop); + Assert.AreEqual(canSell, definition.ActionPolicy.CanSell); + Assert.AreEqual(dropOnDeath, definition.ActionPolicy.DropOnDeath); + Assert.AreEqual(canEquip, definition.ActionPolicy.CanEquip); + Assert.AreEqual(canUse, definition.ActionPolicy.CanUse); + Assert.IsTrue(definition.ActionPolicy.PolicyInitialized); + } + + [Test] + public void TaskSubmission_IsIndependentFromCanDrop() + { + var definition = new ExtractionItemDefinition("quest", 1, 1, false, 1); + ExtractionItemActionPresetService.TryApply( + definition, + ExtractionItemActionPreset.QuestKeepOnDeath); + + Assert.IsFalse(definition.ActionPolicy.CanDrop); + Assert.IsTrue(ExtractionItemActionPolicyService.CanSubmitToTask(definition)); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionItemActionPolicyTests.cs.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionItemActionPolicyTests.cs.meta new file mode 100644 index 0000000..08dcd02 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionItemActionPolicyTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8047ce8f339b4c6785602a7411de3e9d +timeCreated: 1784385600 diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionItemLifecycleTests.cs b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionItemLifecycleTests.cs new file mode 100644 index 0000000..704e76b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionItemLifecycleTests.cs @@ -0,0 +1,443 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace POB.Extraction.Core.Package.Tests.Editor +{ + public class ExtractionItemLifecycleTests + { + private ExtractionPlayableConfig config; + private ExtractionProfileSaveData profile; + + [SetUp] + public void SetUp() + { + ExtractionFeatureSwitch.SetEnabledForTests(true); + config = new ExtractionPlayableConfig(4, 4, 2, 2); + profile = ExtractionProfileSaveData.CreateEmpty(); + } + + [TearDown] + public void TearDown() + { + ExtractionFeatureSwitch.SetEnabledForTests(false); + } + + [Test] + public void EquipAndUnequip_KeepSingleLocation_EquipmentCountsWeightWithoutGridSpace() + { + var weapon = AddDefinition("weapon", 2, 1, 7, ExtractionEquipmentSlotType.Weapon); + AddItemToGrid("weapon-1", weapon, profile.CarryGrid, ExtractionInventoryContainerType.Loadout, 0, 0); + + Assert.IsTrue(ExtractionEquipmentTransactionService.TryEquip( + profile, + null, + config, + "weapon-1", + ExtractionInventoryContainerType.Loadout, + ExtractionItemLocationService.BaseEquipmentLocationSubtype, + "weapon-primary", + ExtractionEquipmentSlotType.Weapon, + "equip-1", + out var displaced, + out var equipResult)); + + Assert.AreEqual(ExtractionEquipmentTransactionResult.Succeeded, equipResult); + Assert.IsNull(displaced); + Assert.IsFalse(profile.CarryGrid.TryGetPlacement("weapon-1", out _)); + Assert.AreEqual( + ExtractionInventoryContainerType.EquipmentSlot, + profile.Ownership.GetRequiredContainer("weapon-1")); + Assert.AreEqual( + 7, + ExtractionItemLocationService.CalculateWeight( + profile, + config, + ExtractionInventoryContainerType.EquipmentSlot)); + Assert.IsTrue(ExtractionItemLocationService.TryValidate(profile, out var issue), issue); + + Assert.IsTrue(ExtractionEquipmentTransactionService.TryUnequip( + profile, + null, + config, + ExtractionItemLocationService.BaseEquipmentLocationSubtype, + "weapon-primary", + ExtractionInventoryContainerType.Loadout, + "unequip-1", + out var itemId, + out var unequipResult)); + Assert.AreEqual("weapon-1", itemId); + Assert.AreEqual(ExtractionEquipmentTransactionResult.Succeeded, unequipResult); + Assert.IsTrue(profile.CarryGrid.TryGetPlacement("weapon-1", out _)); + Assert.IsTrue(ExtractionItemLocationService.TryValidate(profile, out issue), issue); + } + + [Test] + public void Equip_SwapHasNoRoom_FailsWithoutChangingEitherItem() + { + profile.CarryGrid = new ExtractionItemGrid(1, 1); + var small = AddDefinition("small", 1, 1, 1, ExtractionEquipmentSlotType.Weapon); + var large = AddDefinition("large", 2, 1, 1, ExtractionEquipmentSlotType.Weapon); + AddItemToGrid("small-1", small, profile.CarryGrid, ExtractionInventoryContainerType.Loadout, 0, 0); + var equipped = new ExtractionItemInstance("large-1", large.DefinitionId, 1); + Assert.IsTrue(profile.Items.Register(equipped)); + Assert.IsTrue(profile.Ownership.Register( + equipped.InstanceId, + ExtractionInventoryContainerType.EquipmentSlot, + ExtractionItemLocationService.BaseEquipmentLocationSubtype, + "weapon-primary")); + profile.Equipment.Slots.Add(new ExtractionEquipmentSlotState("weapon-primary", equipped.InstanceId)); + + Assert.IsFalse(ExtractionEquipmentTransactionService.TryEquip( + profile, + null, + config, + "small-1", + ExtractionInventoryContainerType.Loadout, + ExtractionItemLocationService.BaseEquipmentLocationSubtype, + "weapon-primary", + ExtractionEquipmentSlotType.Weapon, + "swap-no-space", + out _, + out var result)); + + Assert.AreEqual(ExtractionEquipmentTransactionResult.NoSpace, result); + Assert.IsTrue(profile.CarryGrid.TryGetPlacement("small-1", out _)); + Assert.IsTrue(profile.Equipment.TryGetItem("weapon-primary", out var equippedId)); + Assert.AreEqual("large-1", equippedId); + } + + [Test] + public void Equip_DuplicatePhysicalLocation_IsRejectedBeforeMutation() + { + var weapon = AddDefinition("weapon", 1, 1, 1, ExtractionEquipmentSlotType.Weapon); + AddItemToGrid( + "weapon-duplicate", + weapon, + profile.CarryGrid, + ExtractionInventoryContainerType.Loadout, + 0, + 0); + profile.Equipment.Slots.Add( + new ExtractionEquipmentSlotState("weapon-secondary", "weapon-duplicate")); + + Assert.IsFalse(ExtractionEquipmentTransactionService.TryEquip( + profile, + null, + config, + "weapon-duplicate", + ExtractionInventoryContainerType.Loadout, + ExtractionItemLocationService.BaseEquipmentLocationSubtype, + "weapon-primary", + ExtractionEquipmentSlotType.Weapon, + "equip-conflict", + out _, + out var result)); + + Assert.AreEqual(ExtractionEquipmentTransactionResult.LocationConflict, result); + Assert.IsTrue(profile.CarryGrid.TryGetPlacement("weapon-duplicate", out _)); + Assert.AreEqual( + ExtractionInventoryContainerType.Loadout, + profile.Ownership.GetRequiredContainer("weapon-duplicate")); + } + + [Test] + public void DropThenPickup_PreservesSameInstanceAndGrowthState() + { + var weapon = AddDefinition("weapon", 1, 1, 2, ExtractionEquipmentSlotType.Weapon); + profile.ActiveRaid = CreateRaid(); + profile.activeRaidId = profile.ActiveRaid.RaidId; + profile.ActiveRaidInventory = new ExtractionRaidInventoryState(4, 4, 2, 2); + var item = AddItemToGrid( + "weapon-persist", + weapon, + profile.ActiveRaidInventory.RaidBackpack, + ExtractionInventoryContainerType.RaidBackpack, + 0, + 0); + item.EnhancementLevel = 3; + item.ForgeTier = 2; + item.AffixIds.Add("affix-a"); + + Assert.IsTrue(ExtractionItemLifecycleService.TryDropInRaid( + profile, + profile.ActiveRaidInventory, + config, + item.InstanceId, + "world-1", + out var dropResult)); + Assert.AreEqual(ExtractionItemLifecycleResult.Succeeded, dropResult); + Assert.AreEqual( + ExtractionInventoryContainerType.WorldPickup, + profile.Ownership.GetRequiredContainer(item.InstanceId)); + + Assert.IsTrue(ExtractionItemLifecycleService.TryPickupWorldItem( + profile, + profile.ActiveRaidInventory, + config, + item.InstanceId, + ExtractionInventoryContainerType.RaidBackpack, + out var pickupResult)); + Assert.AreEqual(ExtractionItemLifecycleResult.Succeeded, pickupResult); + Assert.IsTrue(profile.Items.TryGet(item.InstanceId, out var restored)); + Assert.AreEqual(3, restored.EnhancementLevel); + Assert.AreEqual(2, restored.ForgeTier); + CollectionAssert.AreEqual(new[] { "affix-a" }, restored.AffixIds); + } + + [Test] + public void RaidBackpackAndSecure_MovePreservesSingleLocationAndPolicy() + { + profile.ActiveRaid = CreateRaid(); + profile.activeRaidId = profile.ActiveRaid.RaidId; + profile.ActiveRaidInventory = new ExtractionRaidInventoryState(4, 4, 2, 2); + var allowed = AddDefinition("secure-allowed", 1, 1, 1); + var denied = AddDefinition("secure-denied", 1, 1, 1); + denied.ActionPolicy.CanPlaceInSecure = false; + AddItemToGrid( + "allowed-1", + allowed, + profile.ActiveRaidInventory.RaidBackpack, + ExtractionInventoryContainerType.RaidBackpack, + 0, + 0); + AddItemToGrid( + "denied-1", + denied, + profile.ActiveRaidInventory.RaidBackpack, + ExtractionInventoryContainerType.RaidBackpack, + 1, + 0); + + Assert.IsTrue(ExtractionItemLifecycleService.TryMoveRaidCarriedItem( + profile, + profile.ActiveRaidInventory, + config, + "allowed-1", + ExtractionInventoryContainerType.InSecureContainer, + out var secureResult)); + Assert.AreEqual(ExtractionItemLifecycleResult.Succeeded, secureResult); + Assert.IsFalse(profile.ActiveRaidInventory.RaidBackpack.TryGetPlacement("allowed-1", out _)); + Assert.IsTrue(profile.ActiveRaidInventory.SecureContainer.TryGetPlacement("allowed-1", out _)); + Assert.AreEqual( + ExtractionInventoryContainerType.InSecureContainer, + profile.Ownership.GetRequiredContainer("allowed-1")); + Assert.IsTrue(ExtractionItemLocationService.TryValidate(profile, out var issue), issue); + + Assert.IsFalse(ExtractionItemLifecycleService.TryMoveRaidCarriedItem( + profile, + profile.ActiveRaidInventory, + config, + "denied-1", + ExtractionInventoryContainerType.InSecureContainer, + out var deniedResult)); + Assert.AreEqual(ExtractionItemLifecycleResult.PolicyDenied, deniedResult); + Assert.IsTrue(profile.ActiveRaidInventory.RaidBackpack.TryGetPlacement("denied-1", out _)); + + Assert.IsTrue(ExtractionItemLifecycleService.TryMoveRaidCarriedItem( + profile, + profile.ActiveRaidInventory, + config, + "allowed-1", + ExtractionInventoryContainerType.RaidBackpack, + out var backpackResult)); + Assert.AreEqual(ExtractionItemLifecycleResult.Succeeded, backpackResult); + Assert.IsTrue(profile.ActiveRaidInventory.RaidBackpack.TryGetPlacement("allowed-1", out _)); + Assert.IsFalse(profile.ActiveRaidInventory.SecureContainer.TryGetPlacement("allowed-1", out _)); + Assert.IsTrue(ExtractionItemLocationService.TryValidate(profile, out issue), issue); + } + + [Test] + public void BaseStashCarryAndSecure_MovePreservesSingleLocationAndPolicy() + { + var allowed = AddDefinition("base-allowed", 1, 1, 1); + var denied = AddDefinition("base-denied", 1, 1, 1); + denied.ActionPolicy.CanPlaceInSecure = false; + AddItemToGrid( + "base-allowed-1", + allowed, + profile.Stash, + ExtractionInventoryContainerType.Stash, + 0, + 0); + AddItemToGrid( + "base-denied-1", + denied, + profile.Stash, + ExtractionInventoryContainerType.Stash, + 1, + 0); + + Assert.IsTrue(ExtractionItemLifecycleService.TryMoveBaseStoredItem( + profile, + config, + "base-allowed-1", + ExtractionInventoryContainerType.Loadout, + out var carryResult)); + Assert.AreEqual(ExtractionItemLifecycleResult.Succeeded, carryResult); + Assert.IsTrue(profile.CarryGrid.TryGetPlacement("base-allowed-1", out _)); + + Assert.IsTrue(ExtractionItemLifecycleService.TryMoveBaseStoredItem( + profile, + config, + "base-allowed-1", + ExtractionInventoryContainerType.SecureContainer, + out var secureResult)); + Assert.AreEqual(ExtractionItemLifecycleResult.Succeeded, secureResult); + Assert.IsTrue(profile.SecureContainer.TryGetPlacement("base-allowed-1", out _)); + Assert.IsFalse(profile.CarryGrid.TryGetPlacement("base-allowed-1", out _)); + + Assert.IsFalse(ExtractionItemLifecycleService.TryMoveBaseStoredItem( + profile, + config, + "base-denied-1", + ExtractionInventoryContainerType.SecureContainer, + out var deniedResult)); + Assert.AreEqual(ExtractionItemLifecycleResult.PolicyDenied, deniedResult); + Assert.IsTrue(profile.Stash.TryGetPlacement("base-denied-1", out _)); + Assert.IsTrue(ExtractionItemLocationService.TryValidate(profile, out var issue), issue); + } + + [Test] + public void QuestItem_CannotDropOrDestroy_ButAuthorizedTaskSubmissionConsumesIt() + { + var quest = AddDefinition("quest", 1, 1, 1); + ExtractionItemActionPresetService.TryApply(quest, ExtractionItemActionPreset.QuestKeepOnDeath); + AddItemToGrid("quest-1", quest, profile.Stash, ExtractionInventoryContainerType.Stash, 0, 0); + + Assert.IsFalse(ExtractionItemLifecycleService.TryDestroyAtBase( + profile, config, "quest-1", true, out var destroyResult)); + Assert.AreEqual(ExtractionItemLifecycleResult.PolicyDenied, destroyResult); + Assert.IsTrue(ExtractionItemLifecycleService.TrySubmitToTask( + profile, null, config, "quest-1", "task-a", out var submitResult)); + Assert.AreEqual(ExtractionItemLifecycleResult.Succeeded, submitResult); + Assert.AreEqual( + ExtractionInventoryContainerType.Consumed, + profile.Ownership.GetRequiredContainer("quest-1")); + } + + [Test] + public void Use_QuantityReceiptAppliesOnce_AndLastUseMovesToConsumed() + { + var consumable = AddDefinition("med", 1, 1, 1); + consumable.ActionPolicy = ExtractionItemActionPolicy.CreateDefaultLoot(); + consumable.ActionPolicy.CanUse = true; + consumable.ActionPolicy.UseActionId = "heal"; + consumable.ActionPolicy.ConsumptionType = ExtractionItemConsumptionType.Quantity; + var item = AddItemToGrid("med-1", consumable, profile.Stash, ExtractionInventoryContainerType.Stash, 0, 0); + item.Quantity = 1; + + Assert.IsTrue(ExtractionItemUseService.TryConsumeForUse( + profile, null, config, item.InstanceId, "use-1", out var actionId, out var first)); + Assert.AreEqual("heal", actionId); + Assert.AreEqual(ExtractionItemUseResult.Succeeded, first); + Assert.AreEqual( + ExtractionInventoryContainerType.Consumed, + profile.Ownership.GetRequiredContainer(item.InstanceId)); + + Assert.IsTrue(ExtractionItemUseService.TryConsumeForUse( + profile, null, config, item.InstanceId, "use-1", out _, out var replay)); + Assert.AreEqual(ExtractionItemUseResult.AlreadyApplied, replay); + Assert.AreEqual(1, profile.ItemActionReceiptIds.Count); + } + + [Test] + public void Use_WorldLocation_IsRejectedWithoutConsumption() + { + var consumable = AddDefinition("consumable-location", 1, 1, 1); + consumable.ActionPolicy.CanUse = true; + consumable.ActionPolicy.UseActionId = "test.use"; + consumable.ActionPolicy.ConsumptionType = ExtractionItemConsumptionType.Quantity; + var item = new ExtractionItemInstance("consumable-world", consumable.DefinitionId, 2); + Assert.IsTrue(profile.Items.Register(item)); + Assert.IsTrue(profile.Ownership.Register( + item.InstanceId, + ExtractionInventoryContainerType.WorldPickup, + "world-pickup", + "pickup-1")); + + Assert.IsFalse(ExtractionItemUseService.TryConsumeForUse( + profile, + null, + config, + item.InstanceId, + "use-world", + out _, + out var result)); + + Assert.AreEqual(ExtractionItemUseResult.LocationConflict, result); + Assert.AreEqual(2, item.Quantity); + Assert.IsFalse(profile.ItemActionReceiptIds.Contains("use-world")); + } + + [Test] + public void DeathPolicy_SecureAndKeepItemsSurvive_DropItemMovesToCorpse_RaidBoundDestroys() + { + profile.ActiveRaid = CreateRaid(); + profile.activeRaidId = profile.ActiveRaid.RaidId; + profile.ActiveRaidInventory = new ExtractionRaidInventoryState(5, 5, 2, 2); + var drop = AddDefinition("drop", 1, 1, 1); + var keep = AddDefinition("keep", 1, 1, 1); + keep.ActionPolicy.DropOnDeath = false; + var secure = AddDefinition("secure", 1, 1, 1); + var bound = AddDefinition("bound", 1, 1, 1); + bound.ActionPolicy.RaidBound = true; + AddItemToGrid("drop-1", drop, profile.ActiveRaidInventory.RaidBackpack, ExtractionInventoryContainerType.RaidBackpack, 0, 0); + AddItemToGrid("keep-1", keep, profile.ActiveRaidInventory.RaidBackpack, ExtractionInventoryContainerType.RaidBackpack, 1, 0); + AddItemToGrid("secure-1", secure, profile.ActiveRaidInventory.SecureContainer, ExtractionInventoryContainerType.InSecureContainer, 0, 0); + AddItemToGrid("bound-1", bound, profile.ActiveRaidInventory.RaidBackpack, ExtractionInventoryContainerType.RaidBackpack, 2, 0); + + Assert.IsTrue(ExtractionItemLifecycleService.TryApplyDeathPolicy( + profile, profile.ActiveRaidInventory, config, "corpse-a", out var result)); + Assert.AreEqual(ExtractionItemLifecycleResult.Succeeded, result); + Assert.AreEqual(ExtractionInventoryContainerType.Corpse, profile.Ownership.GetRequiredContainer("drop-1")); + Assert.AreEqual(ExtractionInventoryContainerType.Holding, profile.Ownership.GetRequiredContainer("keep-1")); + Assert.AreEqual(ExtractionInventoryContainerType.Holding, profile.Ownership.GetRequiredContainer("secure-1")); + Assert.AreEqual(ExtractionInventoryContainerType.Destroyed, profile.Ownership.GetRequiredContainer("bound-1")); + Assert.IsTrue(profile.RecoveryHolding.TryGetPlacement("keep-1", out _)); + Assert.IsTrue(profile.RecoveryHolding.TryGetPlacement("secure-1", out _)); + } + + private ExtractionItemDefinition AddDefinition( + string id, + int width, + int height, + int weight, + ExtractionEquipmentSlotType slot = ExtractionEquipmentSlotType.None) + { + var definition = new ExtractionItemDefinition(id, width, height, true, 1) + { + Weight = weight + }; + if (slot != ExtractionEquipmentSlotType.None) + { + definition.ActionPolicy.CanEquip = true; + definition.ActionPolicy.EquipmentSlotType = slot; + definition.ActionPolicy.EffectAdapterId = "adapter." + id; + } + config.ItemDefinitions.Add(definition); + return definition; + } + + private ExtractionItemInstance AddItemToGrid( + string instanceId, + ExtractionItemDefinition definition, + ExtractionItemGrid grid, + ExtractionInventoryContainerType container, + int x, + int y) + { + var item = new ExtractionItemInstance(instanceId, definition.DefinitionId, 1); + Assert.IsTrue(profile.Items.Register(item)); + Assert.IsTrue(grid.TryPlace(item, definition, x, y, false)); + Assert.IsTrue(profile.Ownership.Register(instanceId, container)); + Assert.IsTrue(profile.Items.TryGet(instanceId, out var stored)); + return stored; + } + + private static ExtractionRaidSession CreateRaid() + { + var map = new ExtractionMapDefinition("map", "room", 300, 1, true); + return new ExtractionRaidSession(map, new ExtractionRaidStartRequest("raid", 1, 1)); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionItemLifecycleTests.cs.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionItemLifecycleTests.cs.meta new file mode 100644 index 0000000..7147cf3 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionItemLifecycleTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 718e6100f73945c5acebcb5658a548ba diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionLockAndLeverServiceTests.cs b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionLockAndLeverServiceTests.cs new file mode 100644 index 0000000..d932b27 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionLockAndLeverServiceTests.cs @@ -0,0 +1,241 @@ +using NUnit.Framework; + +namespace POB.Extraction.Core.Package.Tests.Editor +{ + public class ExtractionLockServiceTests + { + [SetUp] + public void SetUp() + { + ExtractionFeatureSwitch.SetEnabledForTests(true); + } + + [TearDown] + public void TearDown() + { + ExtractionFeatureSwitch.SetEnabledForTests(false); + } + + [Test] + public void Unlock_CompatibleRaidKey_ConsumesDurabilityOnceAndRestoresOpenState() + { + var config = CreateConfig(); + var profile = CreateProfileWithKey(config, durability: 2, ExtractionInventoryContainerType.RaidBackpack); + + Assert.IsTrue(ExtractionLockService.TryUnlock( + profile, config, "lock-a", "key-a", out var firstResult)); + Assert.AreEqual(ExtractionUnlockResult.Unlocked, firstResult); + Assert.IsTrue(profile.Items.TryGet("key-a", out var key)); + Assert.AreEqual(1, key.CurrentDurability); + + Assert.IsTrue(ExtractionLockService.TryUnlock( + profile, config, "lock-a", "key-a", out var secondResult)); + Assert.AreEqual(ExtractionUnlockResult.AlreadyUnlocked, secondResult); + Assert.AreEqual(1, key.CurrentDurability); + + string json = ExtractionProfileSerialization.ToJson(profile); + var restored = ExtractionProfileSerialization.FromJson(json); + Assert.Contains("lock-a", restored.ActiveRaid.Content.OpenedLockIds); + Assert.IsTrue(restored.Items.TryGet("key-a", out var restoredKey)); + Assert.AreEqual(1, restoredKey.CurrentDurability); + } + + [Test] + public void Unlock_LastDurability_MovesSameInstanceToDestroyedByUseAndRemovesGridPlacement() + { + var config = CreateConfig(); + var profile = CreateProfileWithKey(config, durability: 1, ExtractionInventoryContainerType.RaidBackpack); + + Assert.IsTrue(ExtractionLockService.TryUnlock( + profile, config, "lock-a", "key-a", out var result)); + + Assert.AreEqual(ExtractionUnlockResult.Unlocked, result); + Assert.AreEqual( + ExtractionInventoryContainerType.DestroyedByUse, + profile.Ownership.GetRequiredContainer("key-a")); + Assert.IsFalse(profile.ActiveRaidInventory.RaidBackpack.TryGetPlacement("key-a", out _)); + Assert.IsTrue(profile.Items.TryGet("key-a", out var key)); + Assert.AreEqual(0, key.CurrentDurability); + } + + [TestCase(ExtractionInventoryContainerType.EquipmentSlot)] + [TestCase(ExtractionInventoryContainerType.WorldPickup)] + [TestCase(ExtractionInventoryContainerType.Stash)] + public void Unlock_KeyOutsideRaidBackpackOrSecure_IsRejected( + ExtractionInventoryContainerType location) + { + var config = CreateConfig(); + var profile = CreateProfileWithKey(config, durability: 2, location); + + Assert.IsFalse(ExtractionLockService.TryUnlock( + profile, config, "lock-a", "key-a", out var result)); + + Assert.AreEqual(ExtractionUnlockResult.InvalidKeyLocation, result); + Assert.IsFalse(profile.ActiveRaid.Content.OpenedLockIds.Contains("lock-a")); + Assert.IsTrue(profile.Items.TryGet("key-a", out var key)); + Assert.AreEqual(2, key.CurrentDurability); + } + + [Test] + public void Unlock_ZeroDurability_IsRejectedWithoutOpeningLock() + { + var config = CreateConfig(); + var profile = CreateProfileWithKey(config, durability: 0, ExtractionInventoryContainerType.InSecureContainer); + + Assert.IsFalse(ExtractionLockService.TryUnlock( + profile, config, "lock-a", "key-a", out var result)); + Assert.AreEqual(ExtractionUnlockResult.NoDurability, result); + Assert.IsFalse(profile.ActiveRaid.Content.OpenedLockIds.Contains("lock-a")); + } + + private static ExtractionPlayableConfig CreateConfig() + { + var config = ExtractionLootRuntimeFixture.CreateConfigWithoutGuarantees(); + var key = new ExtractionItemDefinition("key-item", 1, 1, false, 1) + { + MaxDurability = 3, + Tags = new[] { "key" } + }; + key.CompatibleTargetIds.Add("lock-a"); + config.ItemDefinitions.Add(key); + config.LockDefinitions.Add(new ExtractionLockDefinition("lock-a", "map-a")); + return config; + } + + private static ExtractionProfileSaveData CreateProfileWithKey( + ExtractionPlayableConfig config, + int durability, + ExtractionInventoryContainerType location) + { + var profile = ExtractionLootRuntimeFixture.CreateActiveRaidProfile(config, 20); + var key = new ExtractionItemInstance("key-a", "key-item", 1) + { + CurrentDurability = durability + }; + Assert.IsTrue(profile.Items.Register(key)); + Assert.IsTrue(profile.Ownership.Register("key-a", location)); + if (location == ExtractionInventoryContainerType.RaidBackpack) + { + Assert.IsTrue(profile.ActiveRaidInventory == null); + profile.ActiveRaidInventory = new ExtractionRaidInventoryState(4, 4, 2, 2); + Assert.IsTrue(profile.ActiveRaidInventory.RaidBackpack.TryPlace( + key, config.ItemDefinitions[config.ItemDefinitions.Count - 1], 0, 0, false)); + } + else if (location == ExtractionInventoryContainerType.InSecureContainer) + { + profile.ActiveRaidInventory = new ExtractionRaidInventoryState(4, 4, 2, 2); + Assert.IsTrue(profile.ActiveRaidInventory.SecureContainer.TryPlace( + key, config.ItemDefinitions[config.ItemDefinitions.Count - 1], 0, 0, false)); + } + + return profile; + } + } + + public class ExtractionLeverServiceTests + { + [SetUp] + public void SetUp() + { + ExtractionFeatureSwitch.SetEnabledForTests(true); + } + + [TearDown] + public void TearDown() + { + ExtractionFeatureSwitch.SetEnabledForTests(false); + } + + [Test] + public void Activate_ApprovedEffectsCommitTogetherAndAreIdempotent() + { + var config = CreateConfig(); + var profile = ExtractionLootRuntimeFixture.CreateActiveRaidProfile(config, 31); + var manifest = profile.ActiveRaid.Content.LootManifest; + manifest.Containers[0].BonusGroupId = "bonus-a"; + manifest.Containers[0].Active = false; + + Assert.IsTrue(ExtractionLeverService.TryActivate( + profile, config, "lever-a", out var firstResult)); + + Assert.AreEqual(ExtractionLeverActivationResult.Activated, firstResult); + Assert.AreEqual(120, profile.ActiveRaid.Content.DeadlineExtensionSeconds); + Assert.AreEqual(1, profile.ActiveRaid.Content.ThreatLevelDelta); + Assert.AreEqual( + profile.ActiveRaid.ThreatLevel + 1, + ExtractionRaidPressureService.GetEffectiveThreatLevel(profile.ActiveRaid)); + Assert.IsTrue(manifest.Containers[0].Active); + Assert.Contains("bonus-a", profile.ActiveRaid.Content.UnlockedBonusContainerGroupIds); + Assert.IsTrue(profile.ActiveRaid.HasRaidFlag("power.on")); + Assert.AreEqual( + 120, + ExtractionRaidPressureService.GetEffectiveOpenAtElapsedSeconds( + profile.ActiveRaid, + config.ExtractionPoints[0])); + Assert.AreEqual(720, ExtractionRaidPressureService.GetEffectiveDurationSeconds(profile.ActiveRaid)); + + Assert.IsTrue(ExtractionLeverService.TryActivate( + profile, config, "lever-a", out var secondResult)); + Assert.AreEqual(ExtractionLeverActivationResult.AlreadyActivated, secondResult); + Assert.AreEqual(120, profile.ActiveRaid.Content.DeadlineExtensionSeconds); + Assert.AreEqual(1, profile.ActiveRaid.Content.ThreatLevelDelta); + } + + [Test] + public void Activate_InvalidLaterEffect_LeavesEarlierEffectsUnchanged() + { + var config = CreateConfig(); + config.LeverDefinitions[0].Effects.Add(new ExtractionLeverEffectDefinition( + ExtractionLeverEffectType.AdvanceExtractionPointSeconds, + "missing-point", + 30)); + var profile = ExtractionLootRuntimeFixture.CreateActiveRaidProfile(config, 32); + profile.ActiveRaid.Content.LootManifest.Containers[0].BonusGroupId = "bonus-a"; + profile.ActiveRaid.Content.LootManifest.Containers[0].Active = false; + + Assert.IsFalse(ExtractionLeverService.TryActivate( + profile, config, "lever-a", out var result)); + + Assert.AreEqual(ExtractionLeverActivationResult.MissingTarget, result); + Assert.AreEqual(0, profile.ActiveRaid.Content.DeadlineExtensionSeconds); + Assert.AreEqual(0, profile.ActiveRaid.Content.ThreatLevelDelta); + Assert.IsFalse(profile.ActiveRaid.HasRaidFlag("power.on")); + Assert.IsFalse(profile.ActiveRaid.Content.ActivatedLeverIds.Contains("lever-a")); + } + + [Test] + public void ExtractionPoint_SingleUseAndEffectiveDeadline_ReadCommittedRaidState() + { + var config = CreateConfig(); + var profile = ExtractionLootRuntimeFixture.CreateActiveRaidProfile(config, 33); + var raid = profile.ActiveRaid; + var point = config.ExtractionPoints[0]; + raid.Content.ExtractionPointStates.Add(new ExtractionPointRuntimeState(point.PointId, 10)); + + Assert.IsFalse(ExtractionRaidPressureService.CanStartExtraction(raid, point, 1005, false)); + Assert.IsTrue(ExtractionRaidPressureService.CanStartExtraction(raid, point, 1010, false)); + raid.Content.UsedExtractionPointIds.Add(point.PointId); + Assert.IsFalse(ExtractionRaidPressureService.CanStartExtraction(raid, point, 1010, false)); + } + + private static ExtractionPlayableConfig CreateConfig() + { + var config = ExtractionLootRuntimeFixture.CreateConfigWithoutGuarantees(); + config.ExtractionPoints.Add(new ExtractionPointDefinition( + "extract-timed", "map-a", 3, false, 300, true, false)); + var lever = new ExtractionLeverDefinition("lever-a", "map-a"); + lever.Effects.Add(new ExtractionLeverEffectDefinition( + ExtractionLeverEffectType.ExtendRaidDeadlineSeconds, null, 120)); + lever.Effects.Add(new ExtractionLeverEffectDefinition( + ExtractionLeverEffectType.AddThreatLevel, null, 1)); + lever.Effects.Add(new ExtractionLeverEffectDefinition( + ExtractionLeverEffectType.UnlockBonusContainerGroup, "bonus-a", 1)); + lever.Effects.Add(new ExtractionLeverEffectDefinition( + ExtractionLeverEffectType.AdvanceExtractionPointSeconds, "extract-timed", 180, 60)); + lever.Effects.Add(new ExtractionLeverEffectDefinition( + ExtractionLeverEffectType.SetRaidFlag, "power.on", 1)); + config.LeverDefinitions.Add(lever); + return config; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionLockAndLeverServiceTests.cs.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionLockAndLeverServiceTests.cs.meta new file mode 100644 index 0000000..05d4329 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionLockAndLeverServiceTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e32f08150b2247a4a73c60e4fb0fcd5e +timeCreated: 1784385600 diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionLootContentConfigValidatorTests.cs b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionLootContentConfigValidatorTests.cs new file mode 100644 index 0000000..4d148a5 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionLootContentConfigValidatorTests.cs @@ -0,0 +1,275 @@ +using NUnit.Framework; + +namespace POB.Extraction.Core.Package.Tests.Editor +{ + public class ExtractionLootContentConfigValidatorTests + { + [Test] + public void Validate_CompleteContentConfig_PassesWithoutWarnings() + { + var report = ExtractionLootContentConfigValidator.Validate(CreateValidConfig()); + + Assert.IsTrue(report.IsValid, report.FirstError); + Assert.IsEmpty(report.Warnings); + } + + [Test] + public void Validate_DuplicateRegionId_ReturnsChineseError() + { + var config = CreateValidConfig(); + config.LootRegions.Add(CreateRegion("region-a")); + + var report = ExtractionLootContentConfigValidator.Validate(config); + + Assert.IsFalse(report.IsValid); + StringAssert.Contains("区域 ID", report.FirstError); + StringAssert.Contains("重复", report.FirstError); + } + + [Test] + public void Validate_ContainerMaximumExceedsCapacity_ReturnsError() + { + var config = CreateValidConfig(); + config.ContainerDefinitions[0].MaximumContentCount = 3; + + var report = ExtractionLootContentConfigValidator.Validate(config); + + Assert.IsFalse(report.IsValid); + StringAssert.Contains("容量", report.FirstError); + } + + [Test] + public void Validate_SpawnCandidateReferencesMissingContainer_ReturnsError() + { + var config = CreateValidConfig(); + config.ContainerSpawns[0].Candidates[0].ContainerTypeId = "missing-container"; + + var report = ExtractionLootContentConfigValidator.Validate(config); + + Assert.IsFalse(report.IsValid); + StringAssert.Contains("不存在的容器类型", report.FirstError); + } + + [Test] + public void Validate_SpawnEnablesAlwaysAndChancePerRaid_ReturnsError() + { + var config = CreateValidConfig(); + config.ContainerSpawns[0].ChancePerRaid = true; + + var report = ExtractionLootContentConfigValidator.Validate(config); + + Assert.IsFalse(report.IsValid); + StringAssert.Contains("Always 与 ChancePerRaid", report.FirstError); + } + + [Test] + public void Validate_ChancePerRaidOutsideUnitInterval_ReturnsError() + { + var config = CreateValidConfig(); + var spawn = config.ContainerSpawns[0]; + spawn.Always = false; + spawn.ChancePerRaid = true; + spawn.Chance = 0f; + + var report = ExtractionLootContentConfigValidator.Validate(config); + + Assert.IsFalse(report.IsValid); + StringAssert.Contains("出现率", report.FirstError); + } + + [Test] + public void Validate_RareLootDisabledLeavesNoCandidate_ReturnsError() + { + var config = CreateValidConfig(); + config.ContainerDefinitions[0].LootTableIds.Remove("common-table"); + + var report = ExtractionLootContentConfigValidator.Validate(config); + + Assert.IsFalse(report.IsValid); + StringAssert.Contains("关闭珍品", report.FirstError); + } + + [Test] + public void Validate_RevealTimesNotStrictlyIncreasing_ReturnsError() + { + var config = CreateValidConfig(); + config.LootProfiles[0].RevealTimes.BaseRevealSeconds.Epic = 1.2f; + + var report = ExtractionLootContentConfigValidator.Validate(config); + + Assert.IsFalse(report.IsValid); + StringAssert.Contains("严格递增", report.FirstError); + } + + [Test] + public void Validate_MinimumGeneratedDropsExceedGuaranteedCapacity_ReturnsError() + { + var config = CreateValidConfig(); + config.LootProfiles[0].MinimumGeneratedDropsByRarity.Common = 2; + config.LootProfiles[0].MinimumGeneratedDropsByRarity.Rare = 1; + + var report = ExtractionLootContentConfigValidator.Validate(config); + + Assert.IsFalse(report.IsValid); + StringAssert.Contains("最低生成数", report.FirstError); + StringAssert.Contains("固定容器容量", report.FirstError); + } + + [Test] + public void Validate_MinimumRarityHasNoReachableItem_ReturnsError() + { + var config = CreateValidConfig(); + config.LootProfiles[0].MinimumGeneratedDropsByRarity.Epic = 1; + + var report = ExtractionLootContentConfigValidator.Validate(config); + + Assert.IsFalse(report.IsValid); + StringAssert.Contains("Epic", report.FirstError); + StringAssert.Contains("合法物品", report.FirstError); + } + + [Test] + public void Validate_MinimumRarityOnlyExistsInOneWeightedCandidate_ReturnsError() + { + var config = CreateValidConfig(); + var commonOnly = new ExtractionContainerDefinition("common-only", 2, 1, 2, 1f); + commonOnly.LootTableIds.Add("common-table"); + config.ContainerDefinitions.Add(commonOnly); + config.LootRegions[0].AllowedContainerTypeIds.Add("common-only"); + config.ContainerSpawns[0].Candidates.Add( + new ExtractionWeightedContainerCandidate("common-only", 1)); + + var report = ExtractionLootContentConfigValidator.Validate(config); + + Assert.IsFalse(report.IsValid); + StringAssert.Contains("Rare", report.FirstError); + StringAssert.Contains("所有候选结果", report.FirstError); + StringAssert.Contains("只能承载 0 个", report.FirstError); + } + + [Test] + public void Validate_UniformRarityCurve_ProducesIneffectiveWarning() + { + var config = CreateValidConfig(); + config.ContentTiers[0].RarityWeightMultipliers = + new ExtractionRarityFloatValues(2f, 2f, 2f, 2f, 2f, 2f); + + var report = ExtractionLootContentConfigValidator.Validate(config); + + Assert.IsTrue(report.IsValid, report.FirstError); + Assert.That(report.Warnings, Has.Count.EqualTo(1)); + StringAssert.Contains("统一倍率", report.Warnings[0]); + StringAssert.Contains("不会改变", report.Warnings[0]); + StringAssert.Contains("掉落概率", report.Warnings[0]); + } + + [Test] + public void ContentPolicy_OnlyRareLootDisabledChangesRewards() + { + var profile = CreateValidConfig().LootProfiles[0]; + var lenient = new ExtractionDifficultySettings + { + CorpseLossTier = ExtractionCorpseLossTier.Lenient, + EnemyDamageTier = ExtractionEnemyDamageTier.Half + }; + var hard = new ExtractionDifficultySettings + { + CorpseLossTier = ExtractionCorpseLossTier.Default, + EnemyDamageTier = ExtractionEnemyDamageTier.Hard + }; + + Assert.AreEqual( + ExtractionLootContentPolicy.GetMinimumGeneratedDrops( + profile, + ExtractionItemRarity.Rare, + lenient.RareLootDisabled), + ExtractionLootContentPolicy.GetMinimumGeneratedDrops( + profile, + ExtractionItemRarity.Rare, + hard.RareLootDisabled)); + + hard.RareLootDisabled = true; + Assert.IsFalse(ExtractionLootContentPolicy.IsLootTableEnabled( + CreateTable("rare-only", "rare-item", true), + hard.RareLootDisabled)); + Assert.AreEqual( + 0, + ExtractionLootContentPolicy.GetMinimumGeneratedDrops( + profile, + ExtractionItemRarity.Rare, + hard.RareLootDisabled)); + Assert.AreEqual( + 1, + ExtractionLootContentPolicy.GetMinimumGeneratedDrops( + profile, + ExtractionItemRarity.Common, + hard.RareLootDisabled)); + Assert.IsFalse(ExtractionLootContentPolicy.IsPityEnabled(profile, hard.RareLootDisabled)); + } + + private static ExtractionPlayableConfig CreateValidConfig() + { + var config = new ExtractionPlayableConfig(6, 4, 2, 2); + var map = new ExtractionMapDefinition("map-a", "Room", 900, 1, true) + { + ContentTierId = "tier-a", + LootProfileId = "profile-a" + }; + config.Maps.Add(map); + + config.ItemDefinitions.Add(CreateItem("common-item", ExtractionItemRarity.Common)); + config.ItemDefinitions.Add(CreateItem("rare-item", ExtractionItemRarity.Rare)); + config.LootTables.Add(CreateTable("common-table", "common-item", false)); + config.LootTables.Add(CreateTable("rare-table", "rare-item", true)); + + config.ContentTiers.Add( + new ExtractionContentTierDefinition( + "tier-a", + new ExtractionRarityFloatValues(1f, 1.05f, 1.15f, 1.3f, 1.5f, 1.8f))); + + var profile = new ExtractionLootProfileDefinition("profile-a", "tier-a") + { + MinimumGeneratedDropsByRarity = + new ExtractionRarityIntValues(1, 0, 1, 0, 0, 0), + Pity = new ExtractionLootPityDefinition(ExtractionItemRarity.Rare, 0.25f, 3f), + RevealTimes = new ExtractionRevealTimeDefinition( + new ExtractionRarityFloatValues(0.5f, 0.8f, 1.2f, 1.8f, 2.6f, 3.6f)) + }; + profile.RegionIds.Add("region-a"); + config.LootProfiles.Add(profile); + config.LootRegions.Add(CreateRegion("region-a")); + + var container = new ExtractionContainerDefinition("supply", 2, 1, 2, 1f); + container.LootTableIds.Add("common-table"); + container.LootTableIds.Add("rare-table"); + config.ContainerDefinitions.Add(container); + + var spawn = new ExtractionContainerSpawnDefinition("spawn-a", "region-a", true, false, 1f); + spawn.Candidates.Add(new ExtractionWeightedContainerCandidate("supply", 1)); + config.ContainerSpawns.Add(spawn); + return config; + } + + private static ExtractionLootRegionDefinition CreateRegion(string regionId) + { + var region = new ExtractionLootRegionDefinition( + regionId, + new ExtractionRarityFloatValues(1f, 1.02f, 1.08f, 1.16f, 1.28f, 1.45f)); + region.AllowedContainerTypeIds.Add("supply"); + region.ContainerSpawnIds.Add("spawn-a"); + return region; + } + + private static ExtractionItemDefinition CreateItem(string id, ExtractionItemRarity rarity) + { + return new ExtractionItemDefinition(id, 1, 1, false, 1) { Rarity = rarity }; + } + + private static ExtractionLootTableDefinition CreateTable(string id, string itemId, bool isRare) + { + var table = new ExtractionLootTableDefinition(id) { IsRare = isRare }; + table.Entries.Add(new ExtractionLootTableEntry(itemId, 1, 1, false)); + return table; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionLootContentConfigValidatorTests.cs.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionLootContentConfigValidatorTests.cs.meta new file mode 100644 index 0000000..58066ae --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionLootContentConfigValidatorTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 179d06e19bcd40edb5c6ec4358ce1d5a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionMerchantRotationAndWalletTests.cs b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionMerchantRotationAndWalletTests.cs new file mode 100644 index 0000000..9afbd30 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionMerchantRotationAndWalletTests.cs @@ -0,0 +1,287 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; + +namespace POB.Extraction.Core.Package.Tests.Editor +{ + public class ExtractionMerchantRotationAndWalletTests + { + private ExtractionPlayableConfig config; + + [SetUp] + public void SetUp() + { + ExtractionFeatureSwitch.SetEnabledForTests(true); + config = CreateConfig(); + } + + [TearDown] + public void TearDown() + { + ExtractionFeatureSwitch.SetEnabledForTests(false); + } + + [Test] + public void Rotation_SameSeedSameOffers_DifferentSeedsCanChangeOffers() + { + var first = new List(); + var replay = new List(); + Assert.IsTrue(ExtractionMerchantRotationService.TryBuildOffers(config, "rotation-a", 7, first)); + Assert.IsTrue(ExtractionMerchantRotationService.TryBuildOffers(config, "rotation-a", 7, replay)); + CollectionAssert.AreEqual(OfferIds(first), OfferIds(replay)); + + bool foundDifferent = false; + for (int seed = 8; seed < 40; seed++) + { + var candidate = new List(); + Assert.IsTrue(ExtractionMerchantRotationService.TryBuildOffers(config, "rotation-a", seed, candidate)); + if (string.Join("|", OfferIds(candidate)) == string.Join("|", OfferIds(first))) continue; + foundDifferent = true; + break; + } + Assert.IsTrue(foundDifferent, "The configured pool must expose seed-driven variation."); + } + + [Test] + public void SettledRaid_RotatesAtMostOnce_AndEarlyAbandonDoesNotRotate() + { + var profile = ExtractionProfileSaveData.CreateEmpty(); + Assert.IsTrue(ExtractionMerchantRotationService.TryInitialize(profile, config, "initial", 1)); + string initial = profile.Merchant.RotationId; + + Assert.IsTrue(ExtractionMerchantRotationService.TryRotateAfterSettledRaid( + profile, + config, + new ExtractionMerchantRaidOutcome( + "raid-early", + ExtractionMerchantRaidOutcomeType.Abandon, + 5, + false), + 2)); + Assert.AreEqual(initial, profile.Merchant.RotationId); + Assert.AreEqual("raid-early", profile.Merchant.LastProcessedSettledRaidId); + Assert.AreEqual(0, profile.Merchant.FreeRotationCount); + + var valid = new ExtractionMerchantRaidOutcome( + "raid-valid", + ExtractionMerchantRaidOutcomeType.Death, + 31, + false); + Assert.IsTrue(ExtractionMerchantRotationService.TryRotateAfterSettledRaid( + profile, config, valid, 3)); + string rotated = profile.Merchant.RotationId; + Assert.AreEqual(1, profile.Merchant.FreeRotationCount); + + Assert.IsTrue(ExtractionMerchantRotationService.TryRotateAfterSettledRaid( + profile, config, valid, 99)); + Assert.AreEqual(rotated, profile.Merchant.RotationId); + Assert.AreEqual(1, profile.Merchant.FreeRotationCount); + } + + [Test] + public void PurchaseSellAndPaidRefresh_UseOnlyBloodSampleAndAreIdempotent() + { + var initial = ExtractionProfileSaveData.CreateEmpty(); + Assert.IsTrue(ExtractionMerchantRotationService.TryInitialize(initial, config, "initial", 1)); + string offerId = initial.Merchant.Offers[0].OfferId; + var offer = config.MerchantOfferDefinitions.Find(candidate => candidate.OfferId == offerId); + Assert.IsNotNull(offer); + var store = new ExtractionInMemoryProfileStore(initial); + var wallet = new TestWallet(100); + var service = new ExtractionMerchantWalletTransactionService(store, wallet, config); + + var purchase = service.TryPurchase("tx-buy", offerId, "bought-1"); + Assert.IsTrue(purchase.IsSuccess); + int afterPurchase = 100 - offer.PriceQuantity; + Assert.AreEqual(afterPurchase, wallet.Balance); + Assert.IsTrue(store.Profile.Items.TryGet("bought-1", out _)); + + var replay = service.TryPurchase("tx-buy", offerId, "bought-1"); + Assert.IsTrue(replay.IsSuccess); + Assert.AreEqual(afterPurchase, wallet.Balance, "replay must not debit twice"); + Assert.IsTrue(service.Complete(replay).IsSuccess); + + Assert.IsTrue(service.TryGetSellQuote("bought-1", out int sellQuote)); + var sale = service.TrySell("tx-sell", "bought-1", sellQuote); + Assert.IsTrue(sale.IsSuccess); + Assert.AreEqual(afterPurchase + sellQuote, wallet.Balance); + Assert.AreEqual( + ExtractionInventoryContainerType.Sold, + store.Profile.Ownership.GetRequiredContainer("bought-1")); + + int refreshCost = ExtractionMerchantRotationService.GetPaidRefreshCost( + store.Profile.Merchant, + config.MerchantRotation); + string beforeRotation = store.Profile.Merchant.RotationId; + var refresh = service.TryPaidRefresh("tx-refresh", 42); + Assert.IsTrue(refresh.IsSuccess); + Assert.AreEqual(afterPurchase + sellQuote - refreshCost, wallet.Balance); + Assert.AreNotEqual(beforeRotation, store.Profile.Merchant.RotationId); + Assert.AreEqual(1, store.Profile.Merchant.PaidRefreshCount); + Assert.AreEqual(refreshCost + config.MerchantRotation.PaidRefreshCostIncrement, + store.Profile.Merchant.NextPaidRefreshCost); + } + + [Test] + public void Purchase_ProfilePrepareFailure_DoesNotDebitOrGrantItem() + { + var initial = ExtractionProfileSaveData.CreateEmpty(); + Assert.IsTrue(ExtractionMerchantRotationService.TryInitialize(initial, config, "initial", 1)); + var store = new ExtractionInMemoryProfileStore(initial) + { + NextCommitFault = ExtractionProfileInMemoryCommitFault.Prepare + }; + var wallet = new TestWallet(100); + var service = new ExtractionMerchantWalletTransactionService(store, wallet, config); + + var result = service.TryPurchase("tx-fail", initial.Merchant.Offers[0].OfferId, "never-granted"); + + Assert.IsFalse(result.IsSuccess); + Assert.AreEqual(100, wallet.Balance); + Assert.IsFalse(store.Profile.Items.TryGet("never-granted", out _)); + } + + [Test] + public void Sell_PolicyDenied_DoesNotCreditOrRemoveItem() + { + var initial = ExtractionProfileSaveData.CreateEmpty(); + var definition = config.ItemDefinitions[0]; + definition.ActionPolicy.CanSell = false; + var item = new ExtractionItemInstance("no-sell", definition.DefinitionId, 1); + Assert.IsTrue(initial.Items.Register(item)); + Assert.IsTrue(initial.Stash.TryPlace(item, definition, 0, 0, false)); + Assert.IsTrue(initial.Ownership.Register(item.InstanceId, ExtractionInventoryContainerType.Stash)); + var store = new ExtractionInMemoryProfileStore(initial); + var wallet = new TestWallet(20); + var service = new ExtractionMerchantWalletTransactionService(store, wallet, config); + + Assert.IsFalse(service.TryGetSellQuote("no-sell", out _)); + Assert.AreEqual(20, wallet.Balance); + Assert.IsTrue(store.Profile.Stash.TryGetPlacement("no-sell", out _)); + } + + private static ExtractionPlayableConfig CreateConfig() + { + var result = new ExtractionPlayableConfig(4, 4, 2, 2); + for (int i = 0; i < 3; i++) + { + var item = new ExtractionItemDefinition("item-" + i, 1, 1, false, 1) + { + Value = 20 + i * 4 + }; + result.ItemDefinitions.Add(item); + var offer = new ExtractionMerchantOfferDefinition( + "offer-" + i, + item.DefinitionId, + 1, + "legacy-unused", + 12 + i, + 1) + { + PriceItemDefinitionId = null, + PriceResourceId = ExtractionMerchantOfferDefinition.BloodSampleResourceId, + InitialStock = 1, + SelectionWeight = i + 1 + }; + result.MerchantOfferDefinitions.Add(offer); + } + + var pool = new ExtractionMerchantOfferPoolDefinition("starter"); + pool.OfferIds.Add("offer-0"); + pool.OfferIds.Add("offer-1"); + pool.OfferIds.Add("offer-2"); + result.MerchantOfferPools.Add(pool); + result.MerchantSlots.Add(new ExtractionMerchantSlotDefinition("slot-0", pool.PoolId)); + result.MerchantSlots.Add(new ExtractionMerchantSlotDefinition("slot-1", pool.PoolId)); + result.MerchantRotation.MinimumValidRaidGameplaySeconds = 30; + result.MerchantRotation.BasePaidRefreshCost = 10; + result.MerchantRotation.PaidRefreshCostIncrement = 5; + return result; + } + + private static string[] OfferIds(List offers) + { + var ids = new string[offers.Count]; + for (int i = 0; i < offers.Count; i++) ids[i] = offers[i].OfferId; + return ids; + } + + private sealed class TestWallet : ISharedMetaWallet + { + private readonly Dictionary receipts = new(); + + internal TestWallet(int balance) + { + Balance = balance; + } + + internal int Balance { get; private set; } + + public bool TryQuery(string resourceId, out SharedMetaWalletBalance balance) + { + balance = resourceId == ExtractionMerchantOfferDefinition.BloodSampleResourceId + ? new SharedMetaWalletBalance(resourceId, Balance) + : null; + return balance != null; + } + + public SharedMetaWalletTransactionResult TryDebit(SharedMetaWalletTransactionRequest request) + { + return Apply(request, -request.Quantity); + } + + public SharedMetaWalletTransactionResult TryCredit(SharedMetaWalletTransactionRequest request) + { + return Apply(request, request.Quantity); + } + + private SharedMetaWalletTransactionResult Apply( + SharedMetaWalletTransactionRequest request, + int delta) + { + if (request == null + || !request.IsValid + || request.ResourceId != ExtractionMerchantOfferDefinition.BloodSampleResourceId) + { + return new SharedMetaWalletTransactionResult( + SharedMetaWalletTransactionStatus.InvalidRequest, + Balance); + } + if (receipts.TryGetValue(request.TransactionId, out var existing)) + { + return existing.PayloadHash == request.PayloadHash && existing.Delta == delta + ? new SharedMetaWalletTransactionResult( + SharedMetaWalletTransactionStatus.AlreadyApplied, + Balance) + : new SharedMetaWalletTransactionResult( + SharedMetaWalletTransactionStatus.Conflict, + Balance); + } + if (delta < 0 && Balance < -delta) + { + return new SharedMetaWalletTransactionResult( + SharedMetaWalletTransactionStatus.InsufficientFunds, + Balance); + } + + Balance += delta; + receipts.Add(request.TransactionId, new WalletReceipt(request.PayloadHash, delta)); + return new SharedMetaWalletTransactionResult( + SharedMetaWalletTransactionStatus.Succeeded, + Balance); + } + } + + private sealed class WalletReceipt + { + internal WalletReceipt(string payloadHash, int delta) + { + PayloadHash = payloadHash; + Delta = delta; + } + + internal string PayloadHash { get; } + internal int Delta { get; } + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionMerchantRotationAndWalletTests.cs.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionMerchantRotationAndWalletTests.cs.meta new file mode 100644 index 0000000..5b14065 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionMerchantRotationAndWalletTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fae8f995092a43e4a572ac565befceda diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionOperationJournalContractTests.cs b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionOperationJournalContractTests.cs new file mode 100644 index 0000000..a0e056d --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionOperationJournalContractTests.cs @@ -0,0 +1,194 @@ +using NUnit.Framework; + +namespace POB.Extraction.Core.Package.Tests.Editor +{ + public class ExtractionOperationJournalContractTests + { + private const string OperationId = + "operation:v1:e64d8651524edb2522f319234d171c7ef6afc721a8381ac47f7026bb2eb3bc98"; + + [Test] + public void StableHash_FixedVector_MatchesCrossProcessContract() + { + string hash = ExtractionStableHash.ComputeSha256( + "loot.spawn-point", + "42", + "loot-a"); + int value = ExtractionStableHash.ComputeInt32( + "loot.spawn-point", + "42", + "loot-a"); + + Assert.AreEqual( + "sha256:c1df1bcb0c34434adde20a64014958bbe6883d068d927cc32f40cab96bd8fb06", + hash); + Assert.AreEqual(-1042342965, value); + } + + [Test] + public void StableIds_FixedVector_MatchesCrossProcessContract() + { + string operationId = ExtractionOperationId.Create( + "raid.failure", + "raid-001", + "PlayerDeath"); + string receiptId = ExtractionReceiptId.Create(operationId, "snapshot"); + + Assert.AreEqual(OperationId, operationId); + Assert.AreEqual( + "receipt:v1:5231eaaa8cc82434014c196cb047f1a5799bc6e3c68cdc5385646757bd1399e1", + receiptId); + } + + [Test] + public void StableHash_NullEmptyAndFieldBoundaries_DoNotCollide() + { + string nullHash = ExtractionStableHash.ComputeSha256("test", null, "a"); + string emptyHash = ExtractionStableHash.ComputeSha256("test", string.Empty, "a"); + string splitHash = ExtractionStableHash.ComputeSha256("test", "ab", "c"); + string joinedHash = ExtractionStableHash.ComputeSha256("test", "a", "bc"); + + Assert.AreNotEqual(nullHash, emptyHash); + Assert.AreNotEqual(splitHash, joinedHash); + } + + [Test] + public void Journal_CommittedRoundTrip_ReplaysPresentationWithoutAddingDuplicateEntry() + { + var profile = ExtractionProfileSaveData.CreateEmpty(); + string payloadHash = ExtractionStableHash.ComputeSha256("operation.payload", "payload-a"); + + Assert.IsTrue(ExtractionOperationJournalService.TryPrepare( + profile.OperationJournal, + OperationId, + "raid.failure", + payloadHash, + out var entry, + out var firstAction)); + Assert.AreEqual(ExtractionOperationReplayAction.ApplyDomain, firstAction); + Assert.IsTrue(ExtractionOperationJournalService.TryMarkCommitted(entry)); + + string json = ExtractionProfileSerialization.ToJson(profile); + ExtractionProfileSaveData reloaded = ExtractionProfileSerialization.FromJson(json); + + Assert.IsTrue(ExtractionOperationJournalService.TryPrepare( + reloaded.OperationJournal, + OperationId, + "raid.failure", + payloadHash, + out var replayedEntry, + out var replayAction)); + Assert.AreEqual(ExtractionOperationReplayAction.ReplayPresentation, replayAction); + Assert.AreEqual(1, reloaded.OperationJournal.Entries.Count); + Assert.IsTrue(replayedEntry.TryGetState(out var state)); + Assert.AreEqual(ExtractionOperationState.Committed, state); + } + + [Test] + public void Journal_CompletedReceipt_ReplayIsNoOp() + { + var journal = new ExtractionOperationJournal(); + const string payloadHash = "sha256:payload"; + Assert.IsTrue(ExtractionOperationJournalService.TryPrepare( + journal, + OperationId, + "raid.failure", + payloadHash, + out var entry, + out _)); + Assert.IsTrue(ExtractionOperationJournalService.TryMarkCommitted(entry)); + Assert.IsTrue(ExtractionOperationJournalService.TryMarkCompleted(entry)); + + Assert.IsTrue(ExtractionOperationJournalService.TryPrepare( + journal, + OperationId, + "raid.failure", + payloadHash, + out var replayedEntry, + out var replayAction)); + Assert.AreEqual(ExtractionOperationReplayAction.NoOp, replayAction); + Assert.IsTrue(replayedEntry.TryGetState(out var state)); + Assert.AreEqual(ExtractionOperationState.Completed, state); + Assert.AreEqual(1, journal.Entries.Count); + } + + [Test] + public void Journal_SameOperationWithDifferentPayload_FailsClosed() + { + var journal = new ExtractionOperationJournal(); + Assert.IsTrue(ExtractionOperationJournalService.TryPrepare( + journal, + OperationId, + "raid.failure", + "sha256:first", + out var entry, + out _)); + Assert.IsTrue(ExtractionOperationJournalService.TryMarkCommitted(entry)); + + bool prepared = ExtractionOperationJournalService.TryPrepare( + journal, + OperationId, + "raid.failure", + "sha256:different", + out _, + out var replayAction); + + Assert.IsFalse(prepared); + Assert.AreEqual(ExtractionOperationReplayAction.Conflict, replayAction); + Assert.AreEqual(1, journal.Entries.Count); + Assert.IsTrue(entry.TryGetState(out var state)); + Assert.AreEqual(ExtractionOperationState.Committed, state); + } + + [Test] + public void Journal_StateMachine_IsForwardOnlyAndIdempotent() + { + var entry = new ExtractionOperationJournalEntry( + OperationId, + "raid.failure", + ExtractionOperationState.Prepared, + "sha256:payload"); + + Assert.IsFalse(ExtractionOperationJournalService.TryMarkCompleted(entry)); + Assert.IsTrue(ExtractionOperationJournalService.TryMarkCommitted(entry)); + Assert.IsTrue(ExtractionOperationJournalService.TryMarkCommitted(entry)); + Assert.IsTrue(ExtractionOperationJournalService.TryMarkCompleted(entry)); + Assert.IsTrue(ExtractionOperationJournalService.TryMarkCompleted(entry)); + Assert.IsFalse(ExtractionOperationJournalService.TryMarkCompensated(entry)); + Assert.IsTrue(entry.TryGetState(out var state)); + Assert.AreEqual(ExtractionOperationState.Completed, state); + } + + [Test] + public void Journal_CompensatedReceipt_IsTerminalAndSerializedValuesStayStable() + { + Assert.AreEqual(0, (int)ExtractionOperationState.Prepared); + Assert.AreEqual(1, (int)ExtractionOperationState.Committed); + Assert.AreEqual(2, (int)ExtractionOperationState.Completed); + Assert.AreEqual(3, (int)ExtractionOperationState.Compensated); + Assert.AreEqual(4, (int)ExtractionOperationState.CompensationPending); + + var journal = new ExtractionOperationJournal(); + Assert.IsTrue(ExtractionOperationJournalService.TryPrepare( + journal, + OperationId, + "shared-wallet.debit", + "sha256:wallet-payload", + out var entry, + out _)); + Assert.IsTrue(ExtractionOperationJournalService.TryMarkCommitted(entry)); + Assert.IsTrue(ExtractionOperationJournalService.TryMarkCompensated(entry)); + + Assert.IsTrue(ExtractionOperationJournalService.TryPrepare( + journal, + OperationId, + "shared-wallet.debit", + "sha256:wallet-payload", + out var replayedEntry, + out var replayAction)); + Assert.AreEqual(ExtractionOperationReplayAction.NoOp, replayAction); + Assert.IsTrue(replayedEntry.TryGetState(out var state)); + Assert.AreEqual(ExtractionOperationState.Compensated, state); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionOperationJournalContractTests.cs.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionOperationJournalContractTests.cs.meta new file mode 100644 index 0000000..82f7742 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionOperationJournalContractTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0fb118729db64045b89ba2113d972c24 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionProfileMigrationTests.cs b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionProfileMigrationTests.cs new file mode 100644 index 0000000..b691fa9 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionProfileMigrationTests.cs @@ -0,0 +1,129 @@ +using NUnit.Framework; +using UnityEngine; + +namespace POB.Extraction.Core.Package.Tests.Editor +{ + public class ExtractionProfileMigrationTests + { + [TestCase(0)] + [TestCase(1)] + public void EnsureInitialized_LegacyProfile_MigratesDeterministicallyToSchemaV2(int schemaVersion) + { + string json = + $"{{\"SchemaVersion\":{schemaVersion}," + + "\"Items\":{\"Entries\":[{\"InstanceId\":\"legacy-item\",\"DefinitionId\":\"legacy-cargo\",\"Quantity\":1}]} ," + + "\"Loadout\":{\"Width\":6,\"Height\":4,\"Placements\":[{\"ItemInstanceId\":\"legacy-item\",\"X\":1,\"Y\":2,\"Width\":1,\"Height\":1,\"Rotated\":false}]}," + + "\"Ownership\":{\"Entries\":[{\"ItemInstanceId\":\"legacy-item\",\"Container\":1}]}}"; + var profile = ExtractionProfileSerialization.FromJson(json); + + Assert.AreEqual(2, profile.SchemaVersion); + Assert.IsTrue(profile.CarryGrid.TryGetPlacement("legacy-item", out _)); + Assert.AreEqual(0, profile.Equipment.Slots.Count, "旧 Loadout 不能在迁移时被猜测为已装备。"); + Assert.IsNotNull(profile.Character); + Assert.IsNotNull(profile.Merchant); + Assert.IsNotNull(profile.OperationJournal); + + Assert.IsTrue(profile.Items.TryGet("legacy-item", out var item)); + Assert.IsTrue(item.HasFlag(ExtractionItemInstanceFlags.CanDrop)); + Assert.IsTrue(item.HasFlag(ExtractionItemInstanceFlags.CanSell)); + Assert.IsTrue(item.HasFlag(ExtractionItemInstanceFlags.DropOnDeath)); + Assert.IsNotNull(item.AffixIds); + Assert.AreEqual(0, item.CurrentDurability); + + var ownershipEntry = profile.Ownership.Entries[0]; + Assert.AreEqual(ExtractionProfileMigration.CarryGridLocationSubtype, ownershipEntry.LocationSubtype); + + string once = JsonUtility.ToJson(profile); + profile.EnsureInitialized(); + Assert.AreEqual(once, JsonUtility.ToJson(profile), "迁移重复执行必须保持字节级稳定。"); + } + + [Test] + public void EnsureInitialized_SchemaV2ExplicitPolicyAndInstanceState_PreservesValues() + { + var profile = ExtractionProfileSaveData.CreateEmpty(); + var item = new ExtractionItemInstance("task-item", "task-definition", 1) + { + CurrentDurability = 4, + EnhancementLevel = 2, + ForgeTier = 1, + Flags = ExtractionItemInstanceFlags.PolicyInitialized | ExtractionItemInstanceFlags.RaidBound + }; + item.AffixIds.Add("affix.stable"); + + Assert.IsTrue(profile.Items.Register(item)); + profile.EnsureInitialized(); + Assert.IsTrue(profile.Items.TryGet(item.InstanceId, out var stored)); + + Assert.IsFalse(stored.HasFlag(ExtractionItemInstanceFlags.CanDrop)); + Assert.IsFalse(stored.HasFlag(ExtractionItemInstanceFlags.CanSell)); + Assert.IsFalse(stored.HasFlag(ExtractionItemInstanceFlags.DropOnDeath)); + Assert.IsTrue(stored.HasFlag(ExtractionItemInstanceFlags.RaidBound)); + Assert.AreEqual(4, stored.CurrentDurability); + Assert.AreEqual(2, stored.EnhancementLevel); + Assert.AreEqual(1, stored.ForgeTier); + CollectionAssert.AreEqual(new[] { "affix.stable" }, stored.AffixIds); + } + + [Test] + public void CreateEmpty_SchemaV2_InitializesNewAuthorityRoots() + { + var profile = ExtractionProfileSaveData.CreateEmpty(); + + Assert.AreEqual(2, profile.SchemaVersion); + Assert.IsNotNull(profile.CarryGrid); + Assert.IsNotNull(profile.Character); + Assert.AreEqual(1f, profile.Character.SearchSpeedMultiplier); + Assert.IsNotNull(profile.Equipment); + Assert.IsNotNull(profile.Merchant); + Assert.IsNotNull(profile.OperationJournal); + } + + [Test] + public void FromJson_LegacyActiveRaid_InitializesContentAndEquipmentRoots() + { + const string json = + "{\"SchemaVersion\":1,\"activeRaidId\":\"raid-1\"," + + "\"ActiveRaid\":{\"RaidId\":\"raid-1\",\"MapId\":\"map-1\"}," + + "\"ActiveRaidInventory\":{" + + "\"RaidBackpack\":{\"Width\":6,\"Height\":4,\"Placements\":[]}," + + "\"SecureContainer\":{\"Width\":2,\"Height\":2,\"Placements\":[]}}}"; + + var profile = ExtractionProfileSerialization.FromJson(json); + + Assert.IsNotNull(profile.ActiveRaid.Content); + Assert.IsNotNull(profile.ActiveRaidInventory.Equipment); + Assert.IsEmpty(profile.ActiveRaid.Content.AppliedReceiptIds); + Assert.IsEmpty(profile.ActiveRaidInventory.Equipment.Slots); + } + + [Test] + public void FromJson_FutureSchema_DoesNotApplyLegacyLoadoutMigration() + { + const string json = + "{\"SchemaVersion\":99,\"activeRaidId\":\"future\"," + + "\"Loadout\":{\"Width\":6,\"Height\":4,\"Placements\":[{" + + "\"ItemInstanceId\":\"future-item\",\"X\":0,\"Y\":0,\"Width\":1,\"Height\":1}]}}"; + + var profile = ExtractionProfileSerialization.FromJson(json); + + Assert.AreEqual(99, profile.SchemaVersion); + Assert.AreEqual("future", profile.activeRaidId); + Assert.IsFalse(profile.CarryGrid?.TryGetPlacement("future-item", out _) ?? false); + } + + [Test] + public void InventoryContainerType_SerializedValues_KeepLegacyValuesAndAppendV2States() + { + Assert.AreEqual(0, (int)ExtractionInventoryContainerType.Stash); + Assert.AreEqual(1, (int)ExtractionInventoryContainerType.Loadout); + Assert.AreEqual(9, (int)ExtractionInventoryContainerType.Buyback); + Assert.AreEqual(10, (int)ExtractionInventoryContainerType.EquipmentSlot); + Assert.AreEqual(11, (int)ExtractionInventoryContainerType.WorldPickup); + Assert.AreEqual(12, (int)ExtractionInventoryContainerType.Destroyed); + Assert.AreEqual(13, (int)ExtractionInventoryContainerType.Consumed); + Assert.AreEqual(14, (int)ExtractionInventoryContainerType.Sold); + Assert.AreEqual(15, (int)ExtractionInventoryContainerType.DestroyedByUse); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionProfileMigrationTests.cs.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionProfileMigrationTests.cs.meta new file mode 100644 index 0000000..6f2763d --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionProfileMigrationTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1d7f4dfeb1bd43869cd5e6c04554a098 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionProfileStoreContractTests.cs b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionProfileStoreContractTests.cs new file mode 100644 index 0000000..98ef5ea --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionProfileStoreContractTests.cs @@ -0,0 +1,138 @@ +using NUnit.Framework; + +namespace POB.Extraction.Core.Package.Tests.Editor +{ + public class ExtractionProfileStoreContractTests + { + [Test] + public void Load_DraftMutationWithoutCommit_DoesNotChangeAuthority() + { + var store = CreateStore("old"); + var loaded = store.Load(); + + Assert.IsTrue(loaded.TryCreateDraft(out var draft)); + draft.Profile.activeRaidId = "draft"; + + Assert.AreEqual("old", store.Load().Profile.activeRaidId); + Assert.AreEqual(loaded.Revision, store.Load().Revision); + } + + [Test] + public void Profile_LegacyMutableView_CanSeedInMemoryFixture() + { + var store = CreateStore("old"); + + store.Profile.activeRaidId = "seeded"; + + Assert.AreEqual("seeded", store.Load().Profile.activeRaidId); + } + + [Test] + public void Commit_ValidDraft_ReplacesAuthorityAndAdvancesRevision() + { + var store = CreateStore("old"); + var loaded = store.Load(); + Assert.IsTrue(loaded.TryCreateDraft(out var draft)); + draft.Profile.activeRaidId = "committed"; + + ExtractionProfileCommitResult result = store.Commit(draft); + + Assert.IsTrue(result.Success); + Assert.AreNotEqual(loaded.Revision, result.Revision); + Assert.AreEqual("committed", store.Load().Profile.activeRaidId); + } + + [TestCase(ExtractionProfileInMemoryCommitFault.Prepare, ExtractionProfileCommitFailure.PrepareFailed)] + [TestCase(ExtractionProfileInMemoryCommitFault.Commit, ExtractionProfileCommitFailure.WriteFailed)] + [TestCase(ExtractionProfileInMemoryCommitFault.Readback, ExtractionProfileCommitFailure.ReadbackFailed)] + public void Commit_InjectedFault_PreservesAuthorityAndRevision( + ExtractionProfileInMemoryCommitFault fault, + ExtractionProfileCommitFailure expectedFailure) + { + var store = CreateStore("old"); + var loaded = store.Load(); + Assert.IsTrue(loaded.TryCreateDraft(out var draft)); + draft.Profile.activeRaidId = "new"; + store.NextCommitFault = fault; + + ExtractionProfileCommitResult result = store.Commit(draft); + + Assert.IsFalse(result.Success); + Assert.AreEqual(expectedFailure, result.Failure); + Assert.AreEqual("old", store.Load().Profile.activeRaidId); + Assert.AreEqual(loaded.Revision, store.Load().Revision); + } + + [Test] + public void Commit_StaleDraft_ReturnsConflictAndKeepsFirstCommit() + { + var store = CreateStore("old"); + var baseline = store.Load(); + Assert.IsTrue(baseline.TryCreateDraft(out var first)); + Assert.IsTrue(baseline.TryCreateDraft(out var stale)); + first.Profile.activeRaidId = "first"; + stale.Profile.activeRaidId = "stale"; + Assert.IsTrue(store.Commit(first).Success); + + ExtractionProfileCommitResult result = store.Commit(stale); + + Assert.IsFalse(result.Success); + Assert.AreEqual(ExtractionProfileCommitFailure.RevisionConflict, result.Failure); + Assert.AreEqual("first", store.Load().Profile.activeRaidId); + } + + [Test] + public void SaveProfile_ReturnsObservableCommitResult() + { + var store = CreateStore("old"); + ExtractionProfileSaveData profile = store.LoadProfile(); + profile.activeRaidId = "saved"; + + ExtractionProfileCommitResult result = store.SaveProfile(profile); + + Assert.IsTrue(result.Success); + Assert.AreEqual("saved", store.LoadProfile().activeRaidId); + } + + [Test] + public void Load_NewerSchema_IsReadOnlyAndCannotCreateDraft() + { + var store = new ExtractionInMemoryProfileStore(new ExtractionProfileSaveData + { + SchemaVersion = ExtractionProfileSaveData.CurrentSchemaVersion + 1, + activeRaidId = "future" + }); + + ExtractionProfileLoadResult loaded = store.Load(); + + Assert.IsTrue(loaded.IsReadOnly); + Assert.IsFalse(loaded.CanCommit); + Assert.AreEqual(ExtractionProfileLoadFailure.UnsupportedSchema, loaded.Failure); + Assert.IsFalse(loaded.TryCreateDraft(out _)); + Assert.AreEqual("future", loaded.Profile.activeRaidId); + } + + [Test] + public void InMemoryBlob_TryCommitWithStaleRevision_ReturnsConflict() + { + var blob = new ExtractionInMemoryProfileBlobStore(); + ExtractionProfileBlobLoadResult missing = blob.Load(); + Assert.IsTrue(blob.TryCommit(missing.Revision, "{\"v\":1}").Success); + + ExtractionProfileBlobCommitResult result = blob.TryCommit(missing.Revision, "{\"v\":2}"); + + Assert.IsFalse(result.Success); + Assert.AreEqual(ExtractionProfileCommitFailure.RevisionConflict, result.Failure); + Assert.IsTrue(blob.TryLoad(out string json)); + Assert.AreEqual("{\"v\":1}", json); + } + + private static ExtractionInMemoryProfileStore CreateStore(string activeRaidId) + { + return new ExtractionInMemoryProfileStore(new ExtractionProfileSaveData + { + activeRaidId = activeRaidId + }); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionProfileStoreContractTests.cs.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionProfileStoreContractTests.cs.meta new file mode 100644 index 0000000..9582cb4 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionProfileStoreContractTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1359e0c8e4894ca2973db8bc5966519e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionRaidLootManifestTests.cs b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionRaidLootManifestTests.cs new file mode 100644 index 0000000..4555ddc --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionRaidLootManifestTests.cs @@ -0,0 +1,490 @@ +using NUnit.Framework; +using UnityEngine; + +namespace POB.Extraction.Core.Package.Tests.Editor +{ + public class ExtractionRaidLootManifestTests + { + [SetUp] + public void SetUp() + { + ExtractionFeatureSwitch.SetEnabledForTests(true); + } + + [TearDown] + public void TearDown() + { + ExtractionFeatureSwitch.SetEnabledForTests(false); + } + + [Test] + public void Generate_SameSeedAndConfig_ProducesIdenticalManifest() + { + var config = ExtractionLootRuntimeFixture.CreateConfig(); + var map = config.Maps[0]; + + Assert.IsTrue(ExtractionRaidLootManifestGenerator.TryGenerate( + config, map, 12345, false, out var first, out var firstFailure), firstFailure.ToString()); + Assert.IsTrue(ExtractionRaidLootManifestGenerator.TryGenerate( + config, map, 12345, false, out var second, out var secondFailure), secondFailure.ToString()); + + Assert.AreEqual(JsonUtility.ToJson(first), JsonUtility.ToJson(second)); + Assert.AreEqual(first.ManifestId, second.ManifestId); + } + + [Test] + public void Generate_DifferentSeed_ProducesDifferentManifestIdentity() + { + var config = ExtractionLootRuntimeFixture.CreateConfig(); + + Assert.IsTrue(ExtractionRaidLootManifestGenerator.TryGenerate( + config, config.Maps[0], 100, false, out var first, out _)); + Assert.IsTrue(ExtractionRaidLootManifestGenerator.TryGenerate( + config, config.Maps[0], 101, false, out var second, out _)); + + Assert.AreNotEqual(first.ManifestId, second.ManifestId); + } + + [Test] + public void Generate_MinimumRarityQuota_IsPrecommittedWithoutOrdinarySlots() + { + var config = ExtractionLootRuntimeFixture.CreateConfig(); + + Assert.IsTrue(ExtractionRaidLootManifestGenerator.TryGenerate( + config, config.Maps[0], 77, false, out var manifest, out var failure), failure.ToString()); + + Assert.AreEqual(2, ExtractionLootRuntimeFixture.CountGuaranteed(manifest, ExtractionItemRarity.Common)); + Assert.AreEqual(1, ExtractionLootRuntimeFixture.CountGuaranteed(manifest, ExtractionItemRarity.Uncommon)); + Assert.AreEqual(1, ExtractionLootRuntimeFixture.CountGuaranteed(manifest, ExtractionItemRarity.Rare)); + foreach (var container in manifest.Containers) + { + Assert.IsFalse(container.Opened); + Assert.LessOrEqual(container.Entries.Count, container.TargetContentCount); + Assert.LessOrEqual(container.TargetContentCount, container.MaximumContentCount); + } + } + + [Test] + public void Generate_RareLootDisabled_FiltersRareQuotaAndRareEntries() + { + var config = ExtractionLootRuntimeFixture.CreateConfig(); + + Assert.IsTrue(ExtractionRaidLootManifestGenerator.TryGenerate( + config, config.Maps[0], 77, true, out var manifest, out var failure), failure.ToString()); + + Assert.AreEqual(0, ExtractionLootRuntimeFixture.CountGuaranteed(manifest, ExtractionItemRarity.Rare)); + foreach (var container in manifest.Containers) + foreach (var entry in container.Entries) + Assert.Less(entry.Rarity, ExtractionItemRarity.Rare); + } + + [Test] + public void Generate_InsufficientCapacity_FailsDeterministically() + { + var config = ExtractionLootRuntimeFixture.CreateConfig(); + config.LootProfiles[0].MinimumGeneratedDropsByRarity = + new ExtractionRarityIntValues(0, 0, 9, 0, 0, 0); + + Assert.IsFalse(ExtractionRaidLootManifestGenerator.TryGenerate( + config, config.Maps[0], 77, false, out var manifest, out var failure)); + Assert.IsNull(manifest); + Assert.AreEqual(ExtractionRaidLootManifestFailure.InsufficientGuaranteedCapacity, failure); + } + + [Test] + public void SessionFactory_ManifestFailure_DoesNotMoveOwnershipOrCreateRaid() + { + var config = ExtractionLootRuntimeFixture.CreateConfig(); + config.LootProfiles[0].MinimumGeneratedDropsByRarity = + new ExtractionRarityIntValues(0, 0, 9, 0, 0, 0); + var profile = ExtractionProfileSaveData.CreateEmpty(); + profile.Items.Register(new ExtractionItemInstance("loadout-item", "common-item", 1)); + profile.Ownership.Register("loadout-item", ExtractionInventoryContainerType.Loadout); + var request = new ExtractionRaidStartRequest("raid-a", 77, 1000); + request.LoadoutItemInstanceIds.Add("loadout-item"); + + Assert.IsFalse(ExtractionRaidSessionFactory.TryCreate( + profile, + config, + config.Maps[0], + request, + false, + out var session, + out var failure)); + + Assert.IsNull(session); + Assert.AreEqual(ExtractionRaidLootManifestFailure.InsufficientGuaranteedCapacity, failure); + Assert.IsNull(profile.ActiveRaid); + Assert.IsNull(profile.activeRaidId); + Assert.AreEqual( + ExtractionInventoryContainerType.Loadout, + profile.Ownership.GetRequiredContainer("loadout-item")); + } + } + + public class ExtractionContainerLootServiceTests + { + [SetUp] + public void SetUp() + { + ExtractionFeatureSwitch.SetEnabledForTests(true); + } + + [TearDown] + public void TearDown() + { + ExtractionFeatureSwitch.SetEnabledForTests(false); + } + + [Test] + public void Open_FirstOpenCommitsResultsOrderPityAndReceipt_ReopenDoesNotReroll() + { + var config = ExtractionLootRuntimeFixture.CreateConfigWithoutGuarantees(); + var profile = ExtractionLootRuntimeFixture.CreateActiveRaidProfile(config, 42); + string containerId = profile.ActiveRaid.Content.LootManifest.Containers[0].ContainerId; + + Assert.IsTrue(ExtractionContainerLootService.TryOpen( + profile, config, containerId, out var opened, out var openResult)); + Assert.AreEqual(ExtractionContainerOpenResult.Opened, openResult); + string firstJson = JsonUtility.ToJson(opened); + int firstPity = profile.ActiveRaid.Content.LootManifest.PityState.ConsecutiveMisses; + + Assert.IsTrue(ExtractionContainerLootService.TryOpen( + profile, config, containerId, out var reopened, out var reopenResult)); + Assert.AreEqual(ExtractionContainerOpenResult.AlreadyOpened, reopenResult); + Assert.AreEqual(firstJson, JsonUtility.ToJson(reopened)); + Assert.AreEqual(firstPity, profile.ActiveRaid.Content.LootManifest.PityState.ConsecutiveMisses); + Assert.IsNotEmpty(opened.OpenReceiptId); + Assert.AreEqual(opened.Entries.Count, ExtractionLootRuntimeFixture.CountDistinctRevealOrders(opened)); + foreach (var entry in opened.Entries) + Assert.AreEqual(ExtractionContainerLootEntryState.CommittedHidden, entry.State); + } + + [Test] + public void Open_RoundTrip_PreservesCommittedResultByteForByte() + { + var config = ExtractionLootRuntimeFixture.CreateConfigWithoutGuarantees(); + var profile = ExtractionLootRuntimeFixture.CreateActiveRaidProfile(config, 19); + string containerId = profile.ActiveRaid.Content.LootManifest.Containers[0].ContainerId; + Assert.IsTrue(ExtractionContainerLootService.TryOpen(profile, config, containerId, out _, out _)); + string before = ExtractionProfileSerialization.ToJson(profile); + + var reloaded = ExtractionProfileSerialization.FromJson(before); + + Assert.AreEqual(before, ExtractionProfileSerialization.ToJson(reloaded)); + Assert.IsTrue(ExtractionContainerLootService.TryOpen( + reloaded, config, containerId, out _, out var result)); + Assert.AreEqual(ExtractionContainerOpenResult.AlreadyOpened, result); + Assert.AreEqual(before, ExtractionProfileSerialization.ToJson(reloaded)); + } + + [Test] + public void Open_RareLootDisabled_NeverUsesRareCandidateAndPityStaysReset() + { + var config = ExtractionLootRuntimeFixture.CreateConfigWithoutGuarantees(); + var profile = ExtractionLootRuntimeFixture.CreateActiveRaidProfile(config, 91, true); + string containerId = profile.ActiveRaid.Content.LootManifest.Containers[0].ContainerId; + + Assert.IsTrue(ExtractionContainerLootService.TryOpen( + profile, config, containerId, out var container, out _)); + + foreach (var entry in container.Entries) + Assert.Less(entry.Rarity, ExtractionItemRarity.Rare); + Assert.AreEqual(0, profile.ActiveRaid.Content.LootManifest.PityState.ConsecutiveMisses); + } + } + + public class ExtractionContainerSearchServiceTests + { + [SetUp] + public void SetUp() + { + ExtractionFeatureSwitch.SetEnabledForTests(true); + } + + [TearDown] + public void TearDown() + { + ExtractionFeatureSwitch.SetEnabledForTests(false); + } + + [Test] + public void Search_PauseFreezeResumeAndReveal_UsesCommittedProgress() + { + var config = ExtractionLootRuntimeFixture.CreateConfigWithoutGuarantees(); + var profile = ExtractionLootRuntimeFixture.CreateActiveRaidProfile(config, 12); + string containerId = profile.ActiveRaid.Content.LootManifest.Containers[0].ContainerId; + Assert.IsTrue(ExtractionContainerLootService.TryOpen( + profile, config, containerId, out var container, out _)); + Assert.IsTrue(ExtractionContainerSearchService.TryStart( + profile.ActiveRaid, containerId, out _)); + + Assert.IsTrue(ExtractionContainerSearchService.TryAdvance( + profile.ActiveRaid, config, containerId, 0f, 1f, out var frozenReveal, out var frozenResult)); + Assert.IsNull(frozenReveal); + Assert.AreEqual(ExtractionContainerSearchResult.Progressed, frozenResult); + Assert.AreEqual(0f, container.SearchState.CurrentEntryElapsedSeconds); + + Assert.IsTrue(ExtractionContainerSearchService.TryAdvance( + profile.ActiveRaid, config, containerId, 0.2f, 1f, out _, out _)); + float savedProgress = container.SearchState.CurrentEntryElapsedSeconds; + Assert.Greater(savedProgress, 0f); + Assert.IsTrue(ExtractionContainerSearchService.TryPause(profile.ActiveRaid, containerId, out _)); + Assert.IsFalse(ExtractionContainerSearchService.TryAdvance( + profile.ActiveRaid, config, containerId, 1f, 1f, out _, out var pausedResult)); + Assert.AreEqual(ExtractionContainerSearchResult.NotActive, pausedResult); + Assert.AreEqual(savedProgress, container.SearchState.CurrentEntryElapsedSeconds); + + Assert.IsTrue(ExtractionContainerSearchService.TryStart(profile.ActiveRaid, containerId, out _)); + Assert.IsTrue(ExtractionContainerSearchService.TryAdvance( + profile.ActiveRaid, config, containerId, 10f, 1f, out var revealed, out var revealResult)); + Assert.IsNotNull(revealed); + Assert.AreEqual(ExtractionContainerLootEntryState.Revealed, revealed.State); + Assert.IsTrue( + revealResult == ExtractionContainerSearchResult.Revealed + || revealResult == ExtractionContainerSearchResult.Completed); + Assert.IsNotEmpty(revealed.RevealReceiptId); + } + + [Test] + public void Search_HiddenEntryCannotTransfer_RevealedEntryCanTransferOnce() + { + var config = ExtractionLootRuntimeFixture.CreateConfigWithoutGuarantees(); + var profile = ExtractionLootRuntimeFixture.CreateActiveRaidProfile(config, 13); + string containerId = profile.ActiveRaid.Content.LootManifest.Containers[0].ContainerId; + Assert.IsTrue(ExtractionContainerLootService.TryOpen( + profile, config, containerId, out var container, out _)); + var first = ExtractionLootRuntimeFixture.GetEntryAtRevealOrder(container, 0); + + Assert.IsFalse(ExtractionContainerSearchService.CanTransfer( + profile.ActiveRaid, containerId, first.EntryId)); + Assert.IsFalse(ExtractionContainerSearchService.TryMarkTransferred( + profile.ActiveRaid, containerId, first.EntryId, "transfer-a")); + + Assert.IsTrue(ExtractionContainerSearchService.TryStart(profile.ActiveRaid, containerId, out _)); + Assert.IsTrue(ExtractionContainerSearchService.TryAdvance( + profile.ActiveRaid, config, containerId, 10f, 1f, out var revealed, out _)); + Assert.IsTrue(ExtractionContainerSearchService.CanTransfer( + profile.ActiveRaid, containerId, revealed.EntryId)); + Assert.IsTrue(ExtractionContainerSearchService.TryMarkTransferred( + profile.ActiveRaid, containerId, revealed.EntryId, "transfer-a")); + Assert.IsFalse(ExtractionContainerSearchService.TryMarkTransferred( + profile.ActiveRaid, containerId, revealed.EntryId, "transfer-a")); + } + + [Test] + public void Search_RoundTrip_PreservesCurrentEntryProgress() + { + var config = ExtractionLootRuntimeFixture.CreateConfigWithoutGuarantees(); + var profile = ExtractionLootRuntimeFixture.CreateActiveRaidProfile(config, 14); + string containerId = profile.ActiveRaid.Content.LootManifest.Containers[0].ContainerId; + Assert.IsTrue(ExtractionContainerLootService.TryOpen(profile, config, containerId, out _, out _)); + Assert.IsTrue(ExtractionContainerSearchService.TryStart(profile.ActiveRaid, containerId, out _)); + Assert.IsTrue(ExtractionContainerSearchService.TryAdvance( + profile.ActiveRaid, config, containerId, 0.2f, 1f, out _, out _)); + string json = ExtractionProfileSerialization.ToJson(profile); + + var restored = ExtractionProfileSerialization.FromJson(json); + var restoredManifest = restored.ActiveRaid.Content.LootManifest; + Assert.IsTrue(restoredManifest.TryGetContainer(containerId, out var restoredContainer)); + + Assert.AreEqual(0.2f, restoredContainer.SearchState.CurrentEntryElapsedSeconds, 0.0001f); + Assert.AreEqual(containerId, restoredManifest.ActiveSearchContainerId); + Assert.IsFalse(restoredContainer.SearchState.Paused); + } + + [Test] + public void Transfer_RevealedEntryRegistersSameInstanceOnceInRaidBackpack() + { + var config = ExtractionLootRuntimeFixture.CreateConfigWithoutGuarantees(); + var profile = ExtractionLootRuntimeFixture.CreateActiveRaidProfile(config, 77); + var inventory = new ExtractionRaidInventoryState(6, 4, 2, 2); + profile.ActiveRaidInventory = inventory; + string containerId = profile.ActiveRaid.Content.LootManifest.Containers[0].ContainerId; + Assert.IsTrue(ExtractionContainerLootService.TryOpen( + profile, + config, + containerId, + out _, + out _)); + Assert.IsTrue(ExtractionContainerSearchService.TryStart(profile.ActiveRaid, containerId, out _)); + Assert.IsTrue(ExtractionContainerSearchService.TryAdvance( + profile.ActiveRaid, + config, + containerId, + 10f, + 1f, + out var revealed, + out _)); + + Assert.IsTrue(ExtractionContainerTransferService.TryTransfer( + profile, + inventory, + config, + containerId, + revealed.EntryId, + ExtractionInventoryContainerType.RaidBackpack, + "transfer-77", + out string itemInstanceId, + out var result)); + + Assert.AreEqual(ExtractionContainerTransferResult.Succeeded, result); + Assert.AreEqual(revealed.ItemInstanceId, itemInstanceId); + Assert.AreEqual(ExtractionContainerLootEntryState.Transferred, revealed.State); + Assert.AreEqual("transfer-77", revealed.TransferReceiptId); + Assert.IsTrue(profile.Items.TryGet(itemInstanceId, out var item)); + Assert.IsTrue(item.HasFlag(ExtractionItemInstanceFlags.PolicyInitialized)); + Assert.AreEqual( + ExtractionInventoryContainerType.RaidBackpack, + profile.Ownership.GetRequiredContainer(itemInstanceId)); + Assert.IsTrue(inventory.RaidBackpack.TryGetPlacement(itemInstanceId, out _)); + + Assert.IsTrue(ExtractionContainerTransferService.TryTransfer( + profile, + inventory, + config, + containerId, + revealed.EntryId, + ExtractionInventoryContainerType.RaidBackpack, + "transfer-77", + out var replayedItemId, + out var replayResult)); + Assert.AreEqual(itemInstanceId, replayedItemId); + Assert.AreEqual(ExtractionContainerTransferResult.AlreadyTransferred, replayResult); + } + + [Test] + public void FeatureOff_OpenAndSearchDoNotMutateCommittedContainerState() + { + var config = ExtractionLootRuntimeFixture.CreateConfig(); + var profile = ExtractionLootRuntimeFixture.CreateActiveRaidProfile(config, 891); + var container = profile.ActiveRaid.Content.LootManifest.Containers[0]; + int initialSequence = profile.ActiveRaid.Content.LootManifest.NextOpenSequence; + + ExtractionFeatureSwitch.SetEnabledForTests(false); + + Assert.IsFalse(ExtractionContainerLootService.TryOpen( + profile, + config, + container.ContainerId, + out _, + out _)); + Assert.IsFalse(ExtractionContainerSearchService.TryStart( + profile.ActiveRaid, + container.ContainerId, + out _)); + Assert.IsFalse(container.Opened); + Assert.AreEqual(initialSequence, profile.ActiveRaid.Content.LootManifest.NextOpenSequence); + Assert.IsNull(profile.ActiveRaid.Content.LootManifest.ActiveSearchContainerId); + } + } + + internal static class ExtractionLootRuntimeFixture + { + public static ExtractionPlayableConfig CreateConfigWithoutGuarantees() + { + var config = CreateConfig(); + config.LootProfiles[0].MinimumGeneratedDropsByRarity = new ExtractionRarityIntValues(); + return config; + } + + public static ExtractionPlayableConfig CreateConfig() + { + var config = new ExtractionPlayableConfig(6, 4, 2, 2); + var map = new ExtractionMapDefinition("map-a", "RoomA", 600, 1, true) + { + ContentTierId = "tier-a", + LootProfileId = "profile-a" + }; + config.Maps.Add(map); + config.ContentTiers.Add(new ExtractionContentTierDefinition( + "tier-a", + new ExtractionRarityFloatValues(1f, 1f, 1f, 1f, 1f, 1f))); + config.LootProfiles.Add(new ExtractionLootProfileDefinition("profile-a", "tier-a") + { + MinimumGeneratedDropsByRarity = new ExtractionRarityIntValues(2, 1, 1, 0, 0, 0), + Pity = new ExtractionLootPityDefinition(ExtractionItemRarity.Rare, 0.5f, 3f), + RevealTimes = new ExtractionRevealTimeDefinition( + new ExtractionRarityFloatValues(0.5f, 0.8f, 1.2f, 1.8f, 2.6f, 3.6f)), + RegionIds = { "region-a" } + }); + config.LootRegions.Add(new ExtractionLootRegionDefinition( + "region-a", + new ExtractionRarityFloatValues(1f, 1f, 1f, 1f, 1f, 1f)) + { + AllowedContainerTypeIds = { "container-a" }, + ContainerSpawnIds = { "spawn-a", "spawn-b" } + }); + var container = new ExtractionContainerDefinition("container-a", 4, 2, 4, 1f); + container.LootTableIds.Add("table-a"); + config.ContainerDefinitions.Add(container); + config.ContainerSpawns.Add(CreateSpawn("spawn-a", true, 1f)); + config.ContainerSpawns.Add(CreateSpawn("spawn-b", true, 1f)); + + config.ItemDefinitions.Add(CreateItem("common-item", ExtractionItemRarity.Common)); + config.ItemDefinitions.Add(CreateItem("uncommon-item", ExtractionItemRarity.Uncommon)); + config.ItemDefinitions.Add(CreateItem("rare-item", ExtractionItemRarity.Rare)); + var table = new ExtractionLootTableDefinition("table-a"); + table.Entries.Add(new ExtractionLootTableEntry("common-item", 100, 1, true)); + table.Entries.Add(new ExtractionLootTableEntry("uncommon-item", 30, 1, true)); + table.Entries.Add(new ExtractionLootTableEntry("rare-item", 1, 1, true)); + config.LootTables.Add(table); + return config; + } + + public static ExtractionProfileSaveData CreateActiveRaidProfile( + ExtractionPlayableConfig config, + int seed, + bool rareLootDisabled = false) + { + var profile = ExtractionProfileSaveData.CreateEmpty(); + var request = new ExtractionRaidStartRequest("raid-" + seed, seed, 1000); + Assert.IsTrue(ExtractionRaidSessionFactory.TryCreate( + profile, + config, + config.Maps[0], + request, + rareLootDisabled, + out _, + out var failure), failure.ToString()); + return profile; + } + + public static int CountGuaranteed(ExtractionRaidLootManifest manifest, ExtractionItemRarity rarity) + { + int count = 0; + foreach (var container in manifest.Containers) + foreach (var entry in container.Entries) + if (entry.Guaranteed && entry.Rarity == rarity) count++; + return count; + } + + public static int CountDistinctRevealOrders(ExtractionRaidContainerManifest container) + { + var orders = new System.Collections.Generic.HashSet(); + foreach (var entry in container.Entries) orders.Add(entry.RevealOrder); + return orders.Count; + } + + public static ExtractionContainerLootEntry GetEntryAtRevealOrder( + ExtractionRaidContainerManifest container, + int revealOrder) + { + foreach (var entry in container.Entries) + if (entry.RevealOrder == revealOrder) return entry; + return null; + } + + private static ExtractionContainerSpawnDefinition CreateSpawn(string id, bool always, float chance) + { + var spawn = new ExtractionContainerSpawnDefinition(id, "region-a", always, !always, chance); + spawn.Candidates.Add(new ExtractionWeightedContainerCandidate("container-a", 1)); + return spawn; + } + + private static ExtractionItemDefinition CreateItem(string id, ExtractionItemRarity rarity) + { + return new ExtractionItemDefinition(id, 1, 1, false, 1) { Rarity = rarity }; + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionRaidLootManifestTests.cs.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionRaidLootManifestTests.cs.meta new file mode 100644 index 0000000..1f84ac1 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionRaidLootManifestTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 8a84bc1b90ca47d596b3e1ed5f882a1c +timeCreated: 1784385600 diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionWeaponGrowthServiceTests.cs b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionWeaponGrowthServiceTests.cs new file mode 100644 index 0000000..d5dff0c --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionWeaponGrowthServiceTests.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using NUnit.Framework; + +namespace POB.Extraction.Core.Package.Tests.Editor +{ + public class ExtractionWeaponGrowthServiceTests + { + private ExtractionPlayableConfig config; + private ExtractionProfileSaveData profile; + private ExtractionWeaponGrowthDefinition growth; + + [SetUp] + public void SetUp() + { + ExtractionFeatureSwitch.SetEnabledForTests(true); + config = new ExtractionPlayableConfig(4, 4, 2, 2); + profile = ExtractionProfileSaveData.CreateEmpty(); + + var weaponDefinition = new ExtractionItemDefinition("weapon", 1, 1, false, 1); + weaponDefinition.ActionPolicy.CanEquip = true; + weaponDefinition.ActionPolicy.EquipmentSlotType = ExtractionEquipmentSlotType.Weapon; + weaponDefinition.ActionPolicy.EffectAdapterId = "weapon-adapter"; + var materialDefinition = new ExtractionItemDefinition("material", 1, 1, false, 99); + config.ItemDefinitions.Add(weaponDefinition); + config.ItemDefinitions.Add(materialDefinition); + + AddItem("weapon-1", weaponDefinition, 1, 0); + AddItem("material-1", materialDefinition, 4, 1); + + growth = new ExtractionWeaponGrowthDefinition { ItemDefinitionId = "weapon" }; + var enhance = new ExtractionWeaponGrowthStepDefinition(1); + enhance.Costs.Add(new ExtractionItemCost("material", 2)); + growth.EnhancementSteps.Add(enhance); + var forge = new ExtractionWeaponGrowthStepDefinition(1); + forge.Costs.Add(new ExtractionItemCost("material", 1)); + growth.ForgeSteps.Add(forge); + var affix = new ExtractionWeaponAffixDefinition("affix-a"); + affix.Costs.Add(new ExtractionItemCost("material", 1)); + growth.Affixes.Add(affix); + } + + [TearDown] + public void TearDown() + { + ExtractionFeatureSwitch.SetEnabledForTests(false); + } + + [Test] + public void EnhanceForgeAndAffix_KeepInstanceIdAndApplyReceiptsOnce() + { + var sources = new List { ExtractionInventoryContainerType.Stash }; + Assert.IsTrue(ExtractionWeaponGrowthService.TryEnhance( + profile, config, growth, "weapon-1", sources, "enhance-1", out var enhanceResult)); + Assert.AreEqual(ExtractionWeaponGrowthResult.Succeeded, enhanceResult); + + Assert.IsTrue(ExtractionWeaponGrowthService.TryEnhance( + profile, config, growth, "weapon-1", sources, "enhance-1", out var replay)); + Assert.AreEqual(ExtractionWeaponGrowthResult.AlreadyApplied, replay); + Assert.IsTrue(ExtractionWeaponGrowthService.TryForge( + profile, config, growth, "weapon-1", sources, "forge-1", out _)); + Assert.IsTrue(ExtractionWeaponGrowthService.TryReplaceAffix( + profile, config, growth, "weapon-1", 0, "affix-a", true, sources, "affix-1", out _)); + + Assert.IsTrue(profile.Items.TryGet("weapon-1", out var weapon)); + Assert.AreEqual("weapon-1", weapon.InstanceId); + Assert.AreEqual(1, weapon.EnhancementLevel); + Assert.AreEqual(1, weapon.ForgeTier); + CollectionAssert.AreEqual(new[] { "affix-a" }, weapon.AffixIds); + Assert.IsFalse( + profile.Items.TryGet("material-1", out _), + "fully consumed material is removed from the registry"); + } + + [Test] + public void Enhance_InsufficientMaterials_DoesNotChangeWeapon() + { + Assert.IsTrue(profile.Items.TryGet("material-1", out var material)); + material.Quantity = 1; + var sources = new List { ExtractionInventoryContainerType.Stash }; + + Assert.IsFalse(ExtractionWeaponGrowthService.TryEnhance( + profile, config, growth, "weapon-1", sources, "enhance-fail", out var result)); + + Assert.AreEqual(ExtractionWeaponGrowthResult.InsufficientMaterials, result); + Assert.IsTrue(profile.Items.TryGet("weapon-1", out var weapon)); + Assert.AreEqual(0, weapon.EnhancementLevel); + Assert.AreEqual(1, material.Quantity); + Assert.IsFalse(profile.ItemActionReceiptIds.Contains("enhance-fail")); + } + + [Test] + public void ReplaceAffix_NotConfirmed_ConsumesNothing() + { + Assert.IsTrue(profile.Items.TryGet("material-1", out var material)); + int before = material.Quantity; + + Assert.IsFalse(ExtractionWeaponGrowthService.TryReplaceAffix( + profile, + config, + growth, + "weapon-1", + 0, + "affix-a", + false, + new List { ExtractionInventoryContainerType.Stash }, + "affix-cancel", + out var result)); + + Assert.AreEqual(ExtractionWeaponGrowthResult.Cancelled, result); + Assert.AreEqual(before, material.Quantity); + } + + private void AddItem( + string instanceId, + ExtractionItemDefinition definition, + int quantity, + int x) + { + var item = new ExtractionItemInstance(instanceId, definition.DefinitionId, quantity); + Assert.IsTrue(profile.Items.Register(item)); + Assert.IsTrue(profile.Stash.TryPlace(item, definition, x, 0, false)); + Assert.IsTrue(profile.Ownership.Register(instanceId, ExtractionInventoryContainerType.Stash)); + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionWeaponGrowthServiceTests.cs.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionWeaponGrowthServiceTests.cs.meta new file mode 100644 index 0000000..90f382d --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/ExtractionWeaponGrowthServiceTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 23a76d3a8ba14f628d074bb91e33c6c3 diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/POB.Extraction.Core.Package.Tests.Editor.asmdef b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/POB.Extraction.Core.Package.Tests.Editor.asmdef new file mode 100644 index 0000000..e6287a8 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/POB.Extraction.Core.Package.Tests.Editor.asmdef @@ -0,0 +1,21 @@ +{ + "name": "POB.Extraction.Core.Package.Tests.Editor", + "rootNamespace": "POB.Extraction.Core.Package.Tests.Editor", + "references": [ + "POB.Extraction.Core" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false, + "optionalUnityReferences": [ + "TestAssemblies" + ] +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/POB.Extraction.Core.Package.Tests.Editor.asmdef.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/POB.Extraction.Core.Package.Tests.Editor.asmdef.meta new file mode 100644 index 0000000..0530550 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/POB.Extraction.Core.Package.Tests.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9f41c05a491800344be22c241a20702f +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/SharedMetaWalletContractTests.cs b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/SharedMetaWalletContractTests.cs new file mode 100644 index 0000000..da36787 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/SharedMetaWalletContractTests.cs @@ -0,0 +1,459 @@ +using System; +using System.Collections.Generic; +using NUnit.Framework; + +namespace POB.Extraction.Core.Package.Tests.Editor +{ + public class SharedMetaWalletContractTests + { + [Test] + public void TransactionStatus_NumericValuesStayStable() + { + Assert.AreEqual(0, (int)SharedMetaWalletTransactionStatus.Succeeded); + Assert.AreEqual(1, (int)SharedMetaWalletTransactionStatus.AlreadyApplied); + Assert.AreEqual(2, (int)SharedMetaWalletTransactionStatus.InsufficientFunds); + Assert.AreEqual(3, (int)SharedMetaWalletTransactionStatus.InvalidRequest); + Assert.AreEqual(4, (int)SharedMetaWalletTransactionStatus.StoreUnavailable); + Assert.AreEqual(5, (int)SharedMetaWalletTransactionStatus.Conflict); + Assert.AreEqual(0, (int)SharedMetaWalletProfileFlow.DebitThenProfile); + Assert.AreEqual(1, (int)SharedMetaWalletProfileFlow.ProfileThenCredit); + Assert.AreEqual(0, (int)SharedMetaWalletProfileSagaStatus.Succeeded); + Assert.AreEqual(1, (int)SharedMetaWalletProfileSagaStatus.PresentationPending); + Assert.AreEqual(2, (int)SharedMetaWalletProfileSagaStatus.AlreadyCompleted); + Assert.AreEqual(3, (int)SharedMetaWalletProfileSagaStatus.Compensated); + Assert.AreEqual(4, (int)SharedMetaWalletProfileSagaStatus.InsufficientFunds); + Assert.AreEqual(5, (int)SharedMetaWalletProfileSagaStatus.InvalidRequest); + Assert.AreEqual(6, (int)SharedMetaWalletProfileSagaStatus.StoreUnavailable); + Assert.AreEqual(7, (int)SharedMetaWalletProfileSagaStatus.Conflict); + Assert.AreEqual(8, (int)SharedMetaWalletProfileSagaStatus.DomainRejected); + Assert.AreEqual(9, (int)SharedMetaWalletProfileSagaStatus.RecoveryPending); + } + + [Test] + public void TransactionRequest_RequiresStableIdResourceQuantityAndPayloadHash() + { + var valid = new SharedMetaWalletTransactionRequest( + "tx-001", + "BloodSample", + 3, + "sha256:payload"); + + Assert.IsTrue(valid.IsValid); + Assert.IsFalse(new SharedMetaWalletTransactionRequest("", "BloodSample", 3, "sha256:payload").IsValid); + Assert.IsFalse(new SharedMetaWalletTransactionRequest("tx-001", "", 3, "sha256:payload").IsValid); + Assert.IsFalse(new SharedMetaWalletTransactionRequest("tx-001", "BloodSample", 0, "sha256:payload").IsValid); + Assert.IsFalse(new SharedMetaWalletTransactionRequest("tx-001", "BloodSample", 3, "").IsValid); + } + + [Test] + public void TransactionResult_TreatsReplayAsSuccessful() + { + var applied = new SharedMetaWalletTransactionResult( + SharedMetaWalletTransactionStatus.Succeeded, + balance: 7); + var replay = new SharedMetaWalletTransactionResult( + SharedMetaWalletTransactionStatus.AlreadyApplied, + balance: 7); + var failed = new SharedMetaWalletTransactionResult( + SharedMetaWalletTransactionStatus.InsufficientFunds, + balance: 2); + + Assert.IsTrue(applied.IsSuccess); + Assert.IsTrue(replay.IsSuccess); + Assert.IsFalse(failed.IsSuccess); + } + } + + public class SharedMetaWalletProfileSagaTests + { + private const string TransactionId = + "operation:v1:44b33455f263739b2a922b67d777162dd6fd3029ae0e1b9f207be42cb6935ad0"; + private const string OperationType = "merchant.purchase"; + private const string ResourceId = "BloodSample"; + private const string PayloadHash = "sha256:wallet-profile-payload"; + + [Test] + public void Debit_PrepareCommitFailure_DoesNotTouchWalletOrDomain() + { + var store = new FaultInjectingProfileStore { FailCommitNumber = 1 }; + var wallet = new FaultInjectingWallet(balance: 10); + var saga = new SharedMetaWalletProfileSaga(store, wallet); + + var result = saga.Execute( + Request(SharedMetaWalletProfileFlow.DebitThenProfile), + _ => true, + profile => profile.Merchant.PaidRefreshCount++); + + Assert.AreEqual(SharedMetaWalletProfileSagaStatus.StoreUnavailable, result.Status); + Assert.AreEqual(10, wallet.Balance); + Assert.AreEqual(0, store.CurrentProfile.Merchant.PaidRefreshCount); + Assert.IsEmpty(store.CurrentProfile.OperationJournal.Entries); + } + + [Test] + public void Debit_WalletUnavailableAfterPrepared_RetryCompletesExactlyOnce() + { + var store = new FaultInjectingProfileStore(); + var wallet = new FaultInjectingWallet(balance: 10) { FailNextDebit = true }; + var saga = new SharedMetaWalletProfileSaga(store, wallet); + int applyCalls = 0; + + var first = saga.Execute( + Request(SharedMetaWalletProfileFlow.DebitThenProfile), + _ => true, + profile => + { + applyCalls++; + profile.Merchant.PaidRefreshCount++; + }); + + Assert.AreEqual(SharedMetaWalletProfileSagaStatus.StoreUnavailable, first.Status); + Assert.AreEqual(10, wallet.Balance); + AssertJournalState(store, ExtractionOperationState.Prepared); + + var retry = saga.Execute( + Request(SharedMetaWalletProfileFlow.DebitThenProfile), + _ => true, + profile => + { + applyCalls++; + profile.Merchant.PaidRefreshCount++; + }); + + Assert.AreEqual(SharedMetaWalletProfileSagaStatus.Succeeded, retry.Status); + Assert.AreEqual(6, wallet.Balance); + Assert.AreEqual(1, applyCalls); + Assert.AreEqual(1, store.CurrentProfile.Merchant.PaidRefreshCount); + AssertJournalState(store, ExtractionOperationState.Committed); + } + + [Test] + public void Debit_ProfileCommitFailureAfterWallet_RetryDoesNotDoubleDebit() + { + var store = new FaultInjectingProfileStore { FailCommitNumber = 2 }; + var wallet = new FaultInjectingWallet(balance: 10); + var saga = new SharedMetaWalletProfileSaga(store, wallet); + int applyCalls = 0; + + var first = saga.Execute( + Request(SharedMetaWalletProfileFlow.DebitThenProfile), + _ => true, + profile => + { + applyCalls++; + profile.Merchant.PaidRefreshCount++; + }); + + Assert.AreEqual(SharedMetaWalletProfileSagaStatus.RecoveryPending, first.Status); + Assert.AreEqual(6, wallet.Balance); + Assert.AreEqual(0, store.CurrentProfile.Merchant.PaidRefreshCount); + AssertJournalState(store, ExtractionOperationState.Prepared); + + var retry = saga.Execute( + Request(SharedMetaWalletProfileFlow.DebitThenProfile), + _ => true, + profile => + { + applyCalls++; + profile.Merchant.PaidRefreshCount++; + }); + + Assert.AreEqual(SharedMetaWalletProfileSagaStatus.Succeeded, retry.Status); + Assert.AreEqual(6, wallet.Balance); + Assert.AreEqual(2, applyCalls, "The failed draft may run once, but only one mutation may persist."); + Assert.AreEqual(1, store.CurrentProfile.Merchant.PaidRefreshCount); + AssertJournalState(store, ExtractionOperationState.Committed); + } + + [Test] + public void Debit_CompensationInterrupted_RetryRestoresWalletAndTerminates() + { + var store = new FaultInjectingProfileStore(); + var wallet = new FaultInjectingWallet(balance: 10) { FailNextCredit = true }; + var saga = new SharedMetaWalletProfileSaga(store, wallet); + int validationCalls = 0; + + bool CanApply(ExtractionProfileSaveData _) + { + validationCalls++; + return validationCalls == 1; + } + + var first = saga.Execute( + Request(SharedMetaWalletProfileFlow.DebitThenProfile), + CanApply, + profile => profile.Merchant.PaidRefreshCount++); + + Assert.AreEqual(SharedMetaWalletProfileSagaStatus.RecoveryPending, first.Status); + Assert.AreEqual(6, wallet.Balance); + Assert.AreEqual(0, store.CurrentProfile.Merchant.PaidRefreshCount); + AssertJournalState(store, ExtractionOperationState.CompensationPending); + + var retry = saga.Execute( + Request(SharedMetaWalletProfileFlow.DebitThenProfile), + _ => + { + Assert.Fail("Compensation replay must not re-enter domain validation."); + return false; + }, + _ => Assert.Fail("Compensation replay must not apply the domain mutation.")); + + Assert.AreEqual(SharedMetaWalletProfileSagaStatus.Compensated, retry.Status); + Assert.AreEqual(10, wallet.Balance); + Assert.AreEqual(0, store.CurrentProfile.Merchant.PaidRefreshCount); + AssertJournalState(store, ExtractionOperationState.Compensated); + } + + [Test] + public void Debit_DomainMutationThrowsAfterPartialChange_CompensatesWithoutPersistingDraft() + { + var store = new FaultInjectingProfileStore(); + var wallet = new FaultInjectingWallet(balance: 10); + var saga = new SharedMetaWalletProfileSaga(store, wallet); + + var result = saga.Execute( + Request(SharedMetaWalletProfileFlow.DebitThenProfile), + _ => true, + profile => + { + profile.Merchant.PaidRefreshCount++; + throw new InvalidOperationException("Injected partial domain mutation."); + }); + + Assert.AreEqual(SharedMetaWalletProfileSagaStatus.Compensated, result.Status); + Assert.AreEqual(10, wallet.Balance); + Assert.AreEqual(0, store.CurrentProfile.Merchant.PaidRefreshCount); + AssertJournalState(store, ExtractionOperationState.Compensated); + } + + [Test] + public void Credit_WalletFailureAfterProfileCommit_RetryCreditsWithoutReapplyingDomain() + { + var store = new FaultInjectingProfileStore(); + var wallet = new FaultInjectingWallet(balance: 10) { FailNextCredit = true }; + var saga = new SharedMetaWalletProfileSaga(store, wallet); + int applyCalls = 0; + + var first = saga.Execute( + Request(SharedMetaWalletProfileFlow.ProfileThenCredit), + _ => true, + profile => + { + applyCalls++; + profile.Merchant.PaidRefreshCount++; + }); + + Assert.AreEqual(SharedMetaWalletProfileSagaStatus.RecoveryPending, first.Status); + Assert.AreEqual(10, wallet.Balance); + Assert.AreEqual(1, applyCalls); + Assert.AreEqual(1, store.CurrentProfile.Merchant.PaidRefreshCount); + AssertJournalState(store, ExtractionOperationState.Committed); + + var retry = saga.Execute( + Request(SharedMetaWalletProfileFlow.ProfileThenCredit), + _ => true, + _ => applyCalls++); + + Assert.AreEqual(SharedMetaWalletProfileSagaStatus.PresentationPending, retry.Status); + Assert.AreEqual(14, wallet.Balance); + Assert.AreEqual(1, applyCalls); + Assert.AreEqual(1, store.CurrentProfile.Merchant.PaidRefreshCount); + } + + [Test] + public void Complete_CommitFailure_ReplaysPresentationAndCompletesWithoutDuplicateMutation() + { + var store = new FaultInjectingProfileStore(); + var wallet = new FaultInjectingWallet(balance: 10); + var saga = new SharedMetaWalletProfileSaga(store, wallet); + int applyCalls = 0; + var request = Request(SharedMetaWalletProfileFlow.DebitThenProfile); + + Assert.AreEqual( + SharedMetaWalletProfileSagaStatus.Succeeded, + saga.Execute( + request, + _ => true, + profile => + { + applyCalls++; + profile.Merchant.PaidRefreshCount++; + }).Status); + + store.FailCommitNumber = store.CommitCount + 1; + Assert.AreEqual( + SharedMetaWalletProfileSagaStatus.RecoveryPending, + saga.Complete(request).Status); + + Assert.AreEqual( + SharedMetaWalletProfileSagaStatus.PresentationPending, + saga.Execute(request, _ => true, _ => applyCalls++).Status); + Assert.AreEqual(1, applyCalls); + Assert.AreEqual(6, wallet.Balance); + + Assert.AreEqual(SharedMetaWalletProfileSagaStatus.Succeeded, saga.Complete(request).Status); + Assert.AreEqual( + SharedMetaWalletProfileSagaStatus.AlreadyCompleted, + saga.Execute(request, _ => true, _ => applyCalls++).Status); + Assert.AreEqual(1, applyCalls); + Assert.AreEqual(6, wallet.Balance); + AssertJournalState(store, ExtractionOperationState.Completed); + } + + private static SharedMetaWalletProfileSagaRequest Request(SharedMetaWalletProfileFlow flow) + { + return new SharedMetaWalletProfileSagaRequest( + TransactionId, + OperationType, + ResourceId, + quantity: 4, + PayloadHash, + flow); + } + + private static void AssertJournalState( + FaultInjectingProfileStore store, + ExtractionOperationState expected) + { + var entries = store.CurrentProfile.OperationJournal.Entries; + Assert.AreEqual(1, entries.Count); + Assert.IsTrue(entries[0].TryGetState(out var state)); + Assert.AreEqual(expected, state); + } + + private sealed class FaultInjectingProfileStore : IExtractionProfileStore + { + private readonly ExtractionInMemoryProfileStore inner = new(); + + public int FailCommitNumber { get; set; } + public int CommitCount { get; private set; } + public ExtractionProfileSaveData CurrentProfile => inner.Load().Profile; + + public ExtractionProfileLoadResult Load() => inner.Load(); + + public ExtractionProfileCommitResult Commit(ExtractionProfileDraft draft) + { + CommitCount++; + if (CommitCount == FailCommitNumber) + { + return ExtractionProfileCommitResult.Failed( + ExtractionProfileCommitFailure.WriteFailed, + "Injected profile commit failure.", + inner.Load()); + } + + return inner.Commit(draft); + } + + public ExtractionProfileSaveData LoadProfile() => inner.LoadProfile(); + + public ExtractionProfileCommitResult SaveProfile(ExtractionProfileSaveData profile) + { + if (!inner.Load().TryCreateDraft(profile, out var draft)) + { + return ExtractionProfileCommitResult.Failed( + ExtractionProfileCommitFailure.InvalidDraft, + "Unable to create test draft.", + inner.Load()); + } + + return Commit(draft); + } + } + + private sealed class FaultInjectingWallet : ISharedMetaWallet + { + private readonly Dictionary receipts = new(); + + public FaultInjectingWallet(int balance) + { + Balance = balance; + } + + public int Balance { get; private set; } + public bool FailNextDebit { get; set; } + public bool FailNextCredit { get; set; } + + public bool TryQuery(string resourceId, out SharedMetaWalletBalance balance) + { + balance = new SharedMetaWalletBalance(resourceId, Balance); + return string.Equals(resourceId, ResourceId, StringComparison.Ordinal); + } + + public SharedMetaWalletTransactionResult TryDebit(SharedMetaWalletTransactionRequest request) + { + if (FailNextDebit) + { + FailNextDebit = false; + return Result(SharedMetaWalletTransactionStatus.StoreUnavailable); + } + + if (!TryReplay(request, debit: true, out var replay)) return replay; + if (Balance < request.Quantity) + return Result(SharedMetaWalletTransactionStatus.InsufficientFunds); + + Balance -= request.Quantity; + receipts.Add(request.TransactionId, new WalletReceipt(request, debit: true)); + return Result(SharedMetaWalletTransactionStatus.Succeeded); + } + + public SharedMetaWalletTransactionResult TryCredit(SharedMetaWalletTransactionRequest request) + { + if (FailNextCredit) + { + FailNextCredit = false; + return Result(SharedMetaWalletTransactionStatus.StoreUnavailable); + } + + if (!TryReplay(request, debit: false, out var replay)) return replay; + Balance += request.Quantity; + receipts.Add(request.TransactionId, new WalletReceipt(request, debit: false)); + return Result(SharedMetaWalletTransactionStatus.Succeeded); + } + + private bool TryReplay( + SharedMetaWalletTransactionRequest request, + bool debit, + out SharedMetaWalletTransactionResult result) + { + result = null; + if (!receipts.TryGetValue(request.TransactionId, out var receipt)) return true; + + result = receipt.Matches(request, debit) + ? Result(SharedMetaWalletTransactionStatus.AlreadyApplied) + : Result(SharedMetaWalletTransactionStatus.Conflict); + return false; + } + + private SharedMetaWalletTransactionResult Result(SharedMetaWalletTransactionStatus status) + { + return new SharedMetaWalletTransactionResult(status, Balance); + } + } + + private sealed class WalletReceipt + { + private readonly string transactionId; + private readonly int quantity; + private readonly string payloadHash; + private readonly bool debit; + + public WalletReceipt(SharedMetaWalletTransactionRequest request, bool debit) + { + transactionId = request.TransactionId; + quantity = request.Quantity; + payloadHash = request.PayloadHash; + this.debit = debit; + } + + public bool Matches(SharedMetaWalletTransactionRequest request, bool expectedDebit) + { + return request != null + && debit == expectedDebit + && string.Equals(transactionId, request.TransactionId, StringComparison.Ordinal) + && quantity == request.Quantity + && string.Equals(payloadHash, request.PayloadHash, StringComparison.Ordinal); + } + } + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/Tests/Editor/SharedMetaWalletContractTests.cs.meta b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/SharedMetaWalletContractTests.cs.meta new file mode 100644 index 0000000..08fbe86 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/Tests/Editor/SharedMetaWalletContractTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3119a43a1fe44df7bdc37c87b7828a4c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/com.zerogamestudio.zeroengine.extraction/package.json b/com.zerogamestudio.zeroengine.extraction/package.json new file mode 100644 index 0000000..5d6eb3b --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/package.json @@ -0,0 +1,23 @@ +{ + "name": "com.zerogamestudio.zeroengine.extraction", + "version": "0.1.0", + "displayName": "ZeroEngine Extraction Core", + "description": "Pure extraction-looter rules for inventory, loot, raid sessions, recovery, economy, and settlement.", + "unity": "2022.3", + "dependencies": {}, + "keywords": [ + "zeroengine", + "extraction", + "looter" + ], + "author": { + "name": "ZeroGameStudio", + "url": "https://github.com/zerogamestudio" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/liuzqk/zeroengine.git", + "directory": "com.zerogamestudio.zeroengine.extraction" + } +} diff --git a/com.zerogamestudio.zeroengine.extraction/package.json.meta b/com.zerogamestudio.zeroengine.extraction/package.json.meta new file mode 100644 index 0000000..eae36f7 --- /dev/null +++ b/com.zerogamestudio.zeroengine.extraction/package.json.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7b4074c6f5154e44ab0405dd6fb16925 +PackageManifestImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: