Fix bugs, add endgame/completion features, adventure dedup, and comprehensive renderer tests
Bug fixes: - Fix double "NEW FEATURE UNLOCKED" message and broken enum-to-key mapping for all UIFeatures - Fix Spectre markup crash when opening inventory with colors unlocked (unescaped rarity brackets) - Fix latent Spectre markup crash in ResourcePanel (unescaped bar brackets) - Fix WeightedRandom.PickMultiple picking with replacement causing duplicate drops - Fix double AddItem bug (BoxEngine + RenderEvents both adding to state) - Add StatType and FontStyle fields to ItemDefinition (were in JSON but missing from C# record) New features: - Endgame box (Mythic) that appears when all 8 resources are discovered, contains Crown of Completion - Completion percentage tracker as mid-game meta unlock (tier 3), shown in both renderers - Adventure pool depletion: adventure sub-boxes with already-unlocked themes are removed from loot pool - Global error handling with log file output (openthebox-error.log) - Tiered meta progression: 5 sequential meta boxes replacing single box_meta New tests (180 new, 228 total): - 5 panel rendering test classes covering all enum values (Portrait, Resource, Stats, Inventory, Chat) - RenderContext and RendererFactory logic tests - SpectreRenderer output tests across 4 context configurations - BasicRenderer output and input method tests - Simulation state mutation and full-run completion tests
This commit is contained in:
parent
77c70593ee
commit
4c4d528187
18 changed files with 1659 additions and 41 deletions
|
|
@ -33,7 +33,8 @@
|
||||||
{"itemDefinitionId": "box_supply", "weight": 1, "condition": {"type": "ResourceAbove", "targetId": "any", "value": 0}},
|
{"itemDefinitionId": "box_supply", "weight": 1, "condition": {"type": "ResourceAbove", "targetId": "any", "value": 0}},
|
||||||
{"itemDefinitionId": "box_story", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 5}},
|
{"itemDefinitionId": "box_story", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 5}},
|
||||||
{"itemDefinitionId": "box_cookie", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 3}},
|
{"itemDefinitionId": "box_cookie", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 3}},
|
||||||
{"itemDefinitionId": "box_music", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 10}}
|
{"itemDefinitionId": "box_music", "weight": 1, "condition": {"type": "BoxesOpenedAbove", "value": 10}},
|
||||||
|
{"itemDefinitionId": "box_endgame", "weight": 1, "condition": {"type": "AllResourcesVisible"}}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -80,7 +81,7 @@
|
||||||
{"itemDefinitionId": "cosmetic_eyes_green", "weight": 2},
|
{"itemDefinitionId": "cosmetic_eyes_green", "weight": 2},
|
||||||
{"itemDefinitionId": "tint_cyan", "weight": 2},
|
{"itemDefinitionId": "tint_cyan", "weight": 2},
|
||||||
{"itemDefinitionId": "tint_orange", "weight": 2},
|
{"itemDefinitionId": "tint_orange", "weight": 2},
|
||||||
{"itemDefinitionId": "box_meta", "weight": 1}
|
{"itemDefinitionId": "box_meta_basics", "weight": 1}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -101,9 +102,10 @@
|
||||||
{"itemDefinitionId": "stamina_drink", "weight": 3},
|
{"itemDefinitionId": "stamina_drink", "weight": 3},
|
||||||
{"itemDefinitionId": "cosmetic_hair_ponytail", "weight": 2},
|
{"itemDefinitionId": "cosmetic_hair_ponytail", "weight": 2},
|
||||||
{"itemDefinitionId": "cosmetic_eyes_sunglasses", "weight": 2},
|
{"itemDefinitionId": "cosmetic_eyes_sunglasses", "weight": 2},
|
||||||
|
{"itemDefinitionId": "cosmetic_eyes_magician", "weight": 2},
|
||||||
{"itemDefinitionId": "cosmetic_body_sexy", "weight": 2},
|
{"itemDefinitionId": "cosmetic_body_sexy", "weight": 2},
|
||||||
{"itemDefinitionId": "tint_purple", "weight": 2},
|
{"itemDefinitionId": "tint_purple", "weight": 2},
|
||||||
{"itemDefinitionId": "box_meta", "weight": 2},
|
{"itemDefinitionId": "box_meta_basics", "weight": 2},
|
||||||
{"itemDefinitionId": "lore_1", "weight": 1},
|
{"itemDefinitionId": "lore_1", "weight": 1},
|
||||||
{"itemDefinitionId": "lore_2", "weight": 1},
|
{"itemDefinitionId": "lore_2", "weight": 1},
|
||||||
{"itemDefinitionId": "box_story", "weight": 1}
|
{"itemDefinitionId": "box_story", "weight": 1}
|
||||||
|
|
@ -117,7 +119,7 @@
|
||||||
"rarity": "Epic",
|
"rarity": "Epic",
|
||||||
"isAutoOpen": false,
|
"isAutoOpen": false,
|
||||||
"lootTable": {
|
"lootTable": {
|
||||||
"guaranteedRolls": ["box_meta", "box_of_boxes"],
|
"guaranteedRolls": ["box_meta_basics", "box_of_boxes"],
|
||||||
"rollCount": 3,
|
"rollCount": 3,
|
||||||
"entries": [
|
"entries": [
|
||||||
{"itemDefinitionId": "material_titanium_raw", "weight": 2},
|
{"itemDefinitionId": "material_titanium_raw", "weight": 2},
|
||||||
|
|
@ -130,6 +132,7 @@
|
||||||
{"itemDefinitionId": "cosmetic_eyes_pilotglasses", "weight": 2},
|
{"itemDefinitionId": "cosmetic_eyes_pilotglasses", "weight": 2},
|
||||||
{"itemDefinitionId": "cosmetic_body_suit", "weight": 2},
|
{"itemDefinitionId": "cosmetic_body_suit", "weight": 2},
|
||||||
{"itemDefinitionId": "cosmetic_legs_pegleg", "weight": 2},
|
{"itemDefinitionId": "cosmetic_legs_pegleg", "weight": 2},
|
||||||
|
{"itemDefinitionId": "cosmetic_arms_extrapair", "weight": 2},
|
||||||
{"itemDefinitionId": "tint_neon", "weight": 2},
|
{"itemDefinitionId": "tint_neon", "weight": 2},
|
||||||
{"itemDefinitionId": "tint_silver", "weight": 2},
|
{"itemDefinitionId": "tint_silver", "weight": 2},
|
||||||
{"itemDefinitionId": "lore_3", "weight": 1},
|
{"itemDefinitionId": "lore_3", "weight": 1},
|
||||||
|
|
@ -180,7 +183,7 @@
|
||||||
{"itemDefinitionId": "tint_rainbow", "weight": 1},
|
{"itemDefinitionId": "tint_rainbow", "weight": 1},
|
||||||
{"itemDefinitionId": "lore_6", "weight": 1},
|
{"itemDefinitionId": "lore_6", "weight": 1},
|
||||||
{"itemDefinitionId": "lore_10", "weight": 1},
|
{"itemDefinitionId": "lore_10", "weight": 1},
|
||||||
{"itemDefinitionId": "box_meta", "weight": 2},
|
{"itemDefinitionId": "box_meta_basics", "weight": 2},
|
||||||
{"itemDefinitionId": "box_black", "weight": 1},
|
{"itemDefinitionId": "box_black", "weight": 1},
|
||||||
{"itemDefinitionId": "box_music", "weight": 1},
|
{"itemDefinitionId": "box_music", "weight": 1},
|
||||||
{"itemDefinitionId": "box_story", "weight": 1}
|
{"itemDefinitionId": "box_story", "weight": 1}
|
||||||
|
|
@ -188,9 +191,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "box_meta",
|
"id": "box_meta_basics",
|
||||||
"nameKey": "box.meta",
|
"nameKey": "box.meta_basics",
|
||||||
"descriptionKey": "box.meta.desc",
|
"descriptionKey": "box.meta_basics.desc",
|
||||||
"rarity": "Rare",
|
"rarity": "Rare",
|
||||||
"isAutoOpen": false,
|
"isAutoOpen": false,
|
||||||
"lootTable": {
|
"lootTable": {
|
||||||
|
|
@ -198,17 +201,59 @@
|
||||||
"rollCount": 1,
|
"rollCount": 1,
|
||||||
"entries": [
|
"entries": [
|
||||||
{"itemDefinitionId": "meta_colors", "weight": 5},
|
{"itemDefinitionId": "meta_colors", "weight": 5},
|
||||||
{"itemDefinitionId": "meta_extended_colors", "weight": 3},
|
|
||||||
{"itemDefinitionId": "meta_arrows", "weight": 4},
|
{"itemDefinitionId": "meta_arrows", "weight": 4},
|
||||||
{"itemDefinitionId": "meta_animation", "weight": 4},
|
{"itemDefinitionId": "meta_animation", "weight": 4},
|
||||||
|
{"itemDefinitionId": "box_meta_interface", "weight": 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "box_meta_interface",
|
||||||
|
"nameKey": "box.meta_interface",
|
||||||
|
"descriptionKey": "box.meta_interface.desc",
|
||||||
|
"rarity": "Rare",
|
||||||
|
"isAutoOpen": false,
|
||||||
|
"lootTable": {
|
||||||
|
"guaranteedRolls": ["box_of_boxes"],
|
||||||
|
"rollCount": 1,
|
||||||
|
"entries": [
|
||||||
{"itemDefinitionId": "meta_inventory", "weight": 3},
|
{"itemDefinitionId": "meta_inventory", "weight": 3},
|
||||||
{"itemDefinitionId": "meta_resources", "weight": 3},
|
{"itemDefinitionId": "meta_resources", "weight": 3},
|
||||||
{"itemDefinitionId": "meta_stats", "weight": 3},
|
{"itemDefinitionId": "meta_stats", "weight": 3},
|
||||||
{"itemDefinitionId": "meta_portrait", "weight": 2},
|
|
||||||
{"itemDefinitionId": "meta_chat", "weight": 2},
|
|
||||||
{"itemDefinitionId": "meta_layout", "weight": 1},
|
|
||||||
{"itemDefinitionId": "meta_shortcuts", "weight": 3},
|
{"itemDefinitionId": "meta_shortcuts", "weight": 3},
|
||||||
{"itemDefinitionId": "meta_crafting", "weight": 2},
|
{"itemDefinitionId": "box_meta_deep", "weight": 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "box_meta_deep",
|
||||||
|
"nameKey": "box.meta_deep",
|
||||||
|
"descriptionKey": "box.meta_deep.desc",
|
||||||
|
"rarity": "Epic",
|
||||||
|
"isAutoOpen": false,
|
||||||
|
"lootTable": {
|
||||||
|
"guaranteedRolls": ["box_of_boxes"],
|
||||||
|
"rollCount": 1,
|
||||||
|
"entries": [
|
||||||
|
{"itemDefinitionId": "meta_extended_colors", "weight": 3},
|
||||||
|
{"itemDefinitionId": "meta_crafting", "weight": 3},
|
||||||
|
{"itemDefinitionId": "meta_chat", "weight": 3},
|
||||||
|
{"itemDefinitionId": "meta_portrait", "weight": 2},
|
||||||
|
{"itemDefinitionId": "meta_completion", "weight": 3},
|
||||||
|
{"itemDefinitionId": "box_meta_resources", "weight": 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "box_meta_resources",
|
||||||
|
"nameKey": "box.meta_resources",
|
||||||
|
"descriptionKey": "box.meta_resources.desc",
|
||||||
|
"rarity": "Epic",
|
||||||
|
"isAutoOpen": false,
|
||||||
|
"lootTable": {
|
||||||
|
"guaranteedRolls": ["box_of_boxes"],
|
||||||
|
"rollCount": 1,
|
||||||
|
"entries": [
|
||||||
{"itemDefinitionId": "meta_resource_health", "weight": 2},
|
{"itemDefinitionId": "meta_resource_health", "weight": 2},
|
||||||
{"itemDefinitionId": "meta_resource_mana", "weight": 2},
|
{"itemDefinitionId": "meta_resource_mana", "weight": 2},
|
||||||
{"itemDefinitionId": "meta_resource_food", "weight": 2},
|
{"itemDefinitionId": "meta_resource_food", "weight": 2},
|
||||||
|
|
@ -217,14 +262,30 @@
|
||||||
{"itemDefinitionId": "meta_resource_blood", "weight": 1},
|
{"itemDefinitionId": "meta_resource_blood", "weight": 1},
|
||||||
{"itemDefinitionId": "meta_resource_oxygen", "weight": 1},
|
{"itemDefinitionId": "meta_resource_oxygen", "weight": 1},
|
||||||
{"itemDefinitionId": "meta_resource_energy", "weight": 1},
|
{"itemDefinitionId": "meta_resource_energy", "weight": 1},
|
||||||
|
{"itemDefinitionId": "box_meta_mastery", "weight": 1}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "box_meta_mastery",
|
||||||
|
"nameKey": "box.meta_mastery",
|
||||||
|
"descriptionKey": "box.meta_mastery.desc",
|
||||||
|
"rarity": "Legendary",
|
||||||
|
"isAutoOpen": false,
|
||||||
|
"lootTable": {
|
||||||
|
"guaranteedRolls": ["box_of_boxes"],
|
||||||
|
"rollCount": 1,
|
||||||
|
"entries": [
|
||||||
|
{"itemDefinitionId": "meta_layout", "weight": 2},
|
||||||
{"itemDefinitionId": "meta_stat_strength", "weight": 1},
|
{"itemDefinitionId": "meta_stat_strength", "weight": 1},
|
||||||
{"itemDefinitionId": "meta_stat_intelligence", "weight": 1},
|
{"itemDefinitionId": "meta_stat_intelligence", "weight": 1},
|
||||||
{"itemDefinitionId": "meta_stat_luck", "weight": 1},
|
{"itemDefinitionId": "meta_stat_luck", "weight": 1},
|
||||||
{"itemDefinitionId": "meta_stat_charisma", "weight": 1},
|
{"itemDefinitionId": "meta_stat_charisma", "weight": 1},
|
||||||
|
{"itemDefinitionId": "meta_stat_dexterity", "weight": 1},
|
||||||
|
{"itemDefinitionId": "meta_stat_wisdom", "weight": 1},
|
||||||
{"itemDefinitionId": "meta_font_consolas", "weight": 2},
|
{"itemDefinitionId": "meta_font_consolas", "weight": 2},
|
||||||
{"itemDefinitionId": "meta_font_firetruc", "weight": 1},
|
{"itemDefinitionId": "meta_font_firetruc", "weight": 1},
|
||||||
{"itemDefinitionId": "meta_font_jetbrains", "weight": 2},
|
{"itemDefinitionId": "meta_font_jetbrains", "weight": 2}
|
||||||
{"itemDefinitionId": "box_meta", "weight": 3}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -287,6 +348,7 @@
|
||||||
"descriptionKey": "box.adventure.space.desc",
|
"descriptionKey": "box.adventure.space.desc",
|
||||||
"rarity": "Rare",
|
"rarity": "Rare",
|
||||||
"isAutoOpen": false,
|
"isAutoOpen": false,
|
||||||
|
"adventureTheme": "Space",
|
||||||
"lootTable": {
|
"lootTable": {
|
||||||
"guaranteedRolls": ["box_of_boxes"],
|
"guaranteedRolls": ["box_of_boxes"],
|
||||||
"rollCount": 2,
|
"rollCount": 2,
|
||||||
|
|
@ -307,6 +369,7 @@
|
||||||
"descriptionKey": "box.adventure.medieval.desc",
|
"descriptionKey": "box.adventure.medieval.desc",
|
||||||
"rarity": "Rare",
|
"rarity": "Rare",
|
||||||
"isAutoOpen": false,
|
"isAutoOpen": false,
|
||||||
|
"adventureTheme": "Medieval",
|
||||||
"lootTable": {
|
"lootTable": {
|
||||||
"guaranteedRolls": ["box_of_boxes"],
|
"guaranteedRolls": ["box_of_boxes"],
|
||||||
"rollCount": 2,
|
"rollCount": 2,
|
||||||
|
|
@ -326,6 +389,7 @@
|
||||||
"descriptionKey": "box.adventure.pirate.desc",
|
"descriptionKey": "box.adventure.pirate.desc",
|
||||||
"rarity": "Rare",
|
"rarity": "Rare",
|
||||||
"isAutoOpen": false,
|
"isAutoOpen": false,
|
||||||
|
"adventureTheme": "Pirate",
|
||||||
"lootTable": {
|
"lootTable": {
|
||||||
"guaranteedRolls": ["pirate_map", "box_of_boxes"],
|
"guaranteedRolls": ["pirate_map", "box_of_boxes"],
|
||||||
"rollCount": 2,
|
"rollCount": 2,
|
||||||
|
|
@ -345,6 +409,7 @@
|
||||||
"descriptionKey": "box.adventure.contemporary.desc",
|
"descriptionKey": "box.adventure.contemporary.desc",
|
||||||
"rarity": "Rare",
|
"rarity": "Rare",
|
||||||
"isAutoOpen": false,
|
"isAutoOpen": false,
|
||||||
|
"adventureTheme": "Contemporary",
|
||||||
"lootTable": {
|
"lootTable": {
|
||||||
"guaranteedRolls": ["box_of_boxes"],
|
"guaranteedRolls": ["box_of_boxes"],
|
||||||
"rollCount": 2,
|
"rollCount": 2,
|
||||||
|
|
@ -364,6 +429,7 @@
|
||||||
"descriptionKey": "box.adventure.sentimental.desc",
|
"descriptionKey": "box.adventure.sentimental.desc",
|
||||||
"rarity": "Rare",
|
"rarity": "Rare",
|
||||||
"isAutoOpen": false,
|
"isAutoOpen": false,
|
||||||
|
"adventureTheme": "Sentimental",
|
||||||
"lootTable": {
|
"lootTable": {
|
||||||
"guaranteedRolls": ["box_of_boxes"],
|
"guaranteedRolls": ["box_of_boxes"],
|
||||||
"rollCount": 2,
|
"rollCount": 2,
|
||||||
|
|
@ -382,6 +448,7 @@
|
||||||
"descriptionKey": "box.adventure.prehistoric.desc",
|
"descriptionKey": "box.adventure.prehistoric.desc",
|
||||||
"rarity": "Rare",
|
"rarity": "Rare",
|
||||||
"isAutoOpen": false,
|
"isAutoOpen": false,
|
||||||
|
"adventureTheme": "Prehistoric",
|
||||||
"lootTable": {
|
"lootTable": {
|
||||||
"guaranteedRolls": ["box_of_boxes"],
|
"guaranteedRolls": ["box_of_boxes"],
|
||||||
"rollCount": 2,
|
"rollCount": 2,
|
||||||
|
|
@ -400,6 +467,7 @@
|
||||||
"descriptionKey": "box.adventure.cosmic.desc",
|
"descriptionKey": "box.adventure.cosmic.desc",
|
||||||
"rarity": "Epic",
|
"rarity": "Epic",
|
||||||
"isAutoOpen": false,
|
"isAutoOpen": false,
|
||||||
|
"adventureTheme": "Cosmic",
|
||||||
"lootTable": {
|
"lootTable": {
|
||||||
"guaranteedRolls": ["box_of_boxes"],
|
"guaranteedRolls": ["box_of_boxes"],
|
||||||
"rollCount": 2,
|
"rollCount": 2,
|
||||||
|
|
@ -418,6 +486,7 @@
|
||||||
"descriptionKey": "box.adventure.microscopic.desc",
|
"descriptionKey": "box.adventure.microscopic.desc",
|
||||||
"rarity": "Rare",
|
"rarity": "Rare",
|
||||||
"isAutoOpen": false,
|
"isAutoOpen": false,
|
||||||
|
"adventureTheme": "Microscopic",
|
||||||
"lootTable": {
|
"lootTable": {
|
||||||
"guaranteedRolls": ["box_of_boxes"],
|
"guaranteedRolls": ["box_of_boxes"],
|
||||||
"rollCount": 2,
|
"rollCount": 2,
|
||||||
|
|
@ -436,6 +505,7 @@
|
||||||
"descriptionKey": "box.adventure.darkfantasy.desc",
|
"descriptionKey": "box.adventure.darkfantasy.desc",
|
||||||
"rarity": "Epic",
|
"rarity": "Epic",
|
||||||
"isAutoOpen": false,
|
"isAutoOpen": false,
|
||||||
|
"adventureTheme": "DarkFantasy",
|
||||||
"lootTable": {
|
"lootTable": {
|
||||||
"guaranteedRolls": ["box_of_boxes"],
|
"guaranteedRolls": ["box_of_boxes"],
|
||||||
"rollCount": 2,
|
"rollCount": 2,
|
||||||
|
|
@ -509,7 +579,7 @@
|
||||||
{"itemDefinitionId": "material_diamond_gem", "weight": 1},
|
{"itemDefinitionId": "material_diamond_gem", "weight": 1},
|
||||||
{"itemDefinitionId": "cosmic_core", "weight": 1},
|
{"itemDefinitionId": "cosmic_core", "weight": 1},
|
||||||
{"itemDefinitionId": "darkfantasy_gem", "weight": 1},
|
{"itemDefinitionId": "darkfantasy_gem", "weight": 1},
|
||||||
{"itemDefinitionId": "box_meta", "weight": 2},
|
{"itemDefinitionId": "box_meta_basics", "weight": 2},
|
||||||
{"itemDefinitionId": "box_cookie", "weight": 2}
|
{"itemDefinitionId": "box_cookie", "weight": 2}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -560,5 +630,17 @@
|
||||||
"rollCount": 0,
|
"rollCount": 0,
|
||||||
"entries": []
|
"entries": []
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "box_endgame",
|
||||||
|
"nameKey": "box.endgame",
|
||||||
|
"descriptionKey": "box.endgame.desc",
|
||||||
|
"rarity": "Mythic",
|
||||||
|
"isAutoOpen": false,
|
||||||
|
"lootTable": {
|
||||||
|
"guaranteedRolls": ["endgame_crown", "box_of_boxes"],
|
||||||
|
"rollCount": 0,
|
||||||
|
"entries": []
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -161,5 +161,9 @@
|
||||||
{"id": "resource_max_energy", "nameKey": "item.resource_max_energy", "category": "Consumable", "rarity": "Rare", "tags": ["Improvement", "ResourceMax"], "resourceType": "Energy", "resourceMaxIncrease": 10},
|
{"id": "resource_max_energy", "nameKey": "item.resource_max_energy", "category": "Consumable", "rarity": "Rare", "tags": ["Improvement", "ResourceMax"], "resourceType": "Energy", "resourceMaxIncrease": 10},
|
||||||
|
|
||||||
{"id": "music_melody", "nameKey": "item.music_melody", "category": "Consumable", "rarity": "Rare", "tags": ["Music", "Fun"], "description": "A melody plays from the box. Console.Beep never sounded so good."},
|
{"id": "music_melody", "nameKey": "item.music_melody", "category": "Consumable", "rarity": "Rare", "tags": ["Music", "Fun"], "description": "A melody plays from the box. Console.Beep never sounded so good."},
|
||||||
{"id": "cookie_fortune", "nameKey": "item.cookie_fortune", "category": "Consumable", "rarity": "Common", "tags": ["Cookie", "Fun"], "description": "A fortune cookie with wisdom of questionable origin."}
|
{"id": "cookie_fortune", "nameKey": "item.cookie_fortune", "category": "Consumable", "rarity": "Common", "tags": ["Cookie", "Fun"], "description": "A fortune cookie with wisdom of questionable origin."},
|
||||||
|
|
||||||
|
{"id": "meta_completion", "nameKey": "meta.completion", "category": "Meta", "rarity": "Rare", "tags": ["Meta"], "metaUnlock": "CompletionTracker"},
|
||||||
|
|
||||||
|
{"id": "endgame_crown", "nameKey": "item.endgame_crown", "category": "Cosmetic", "rarity": "Mythic", "tags": ["Cosmetic", "Endgame"], "cosmeticSlot": "Hair", "cosmeticValue": "crown"}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"loot.rarity": "Rarity",
|
"loot.rarity": "Rarity",
|
||||||
"loot.category": "Category",
|
"loot.category": "Category",
|
||||||
"ui.feature_unlocked": "NEW FEATURE UNLOCKED: {0}",
|
"ui.feature_unlocked": "NEW FEATURE UNLOCKED: {0}",
|
||||||
|
"ui.completion": "Completion: {0}%",
|
||||||
"prompt.what_do": "What do you do?",
|
"prompt.what_do": "What do you do?",
|
||||||
"prompt.invalid_choice": "Please enter a number between 1 and {0}.",
|
"prompt.invalid_choice": "Please enter a number between 1 and {0}.",
|
||||||
|
|
||||||
|
|
@ -68,8 +69,16 @@
|
||||||
"box.improvement.desc": "You can always improve. Especially with boxes.",
|
"box.improvement.desc": "You can always improve. Especially with boxes.",
|
||||||
"box.supply": "Supply Box",
|
"box.supply": "Supply Box",
|
||||||
"box.supply.desc": "Supplies! The lifeblood of any box-opening enthusiast.",
|
"box.supply.desc": "Supplies! The lifeblood of any box-opening enthusiast.",
|
||||||
"box.meta": "Meta Box",
|
"box.meta_basics": "Meta Box - The Basics",
|
||||||
"box.meta.desc": "This box improves... the way you see boxes. How meta.",
|
"box.meta_basics.desc": "Colors, arrows, animations. The foundation of seeing.",
|
||||||
|
"box.meta_interface": "Meta Box - The Interface",
|
||||||
|
"box.meta_interface.desc": "Panels, resources, stats. The tools of understanding.",
|
||||||
|
"box.meta_deep": "Meta Box - Customization",
|
||||||
|
"box.meta_deep.desc": "Extended colors, crafting, chat, portrait. Express yourself.",
|
||||||
|
"box.meta_resources": "Meta Box - Resources",
|
||||||
|
"box.meta_resources.desc": "Unlock the ability to see what you have. And what you lack.",
|
||||||
|
"box.meta_mastery": "Meta Box - Mastery",
|
||||||
|
"box.meta_mastery.desc": "Layout, stats, fonts. The final touches of a true box master.",
|
||||||
"box.black": "Black Box",
|
"box.black": "Black Box",
|
||||||
"box.black.desc": "Nobody knows what's inside. Not even the box.",
|
"box.black.desc": "Nobody knows what's inside. Not even the box.",
|
||||||
"box.story": "Story Box",
|
"box.story": "Story Box",
|
||||||
|
|
@ -111,6 +120,7 @@
|
||||||
"meta.shortcuts": "Keyboard Shortcuts",
|
"meta.shortcuts": "Keyboard Shortcuts",
|
||||||
"meta.animation": "Box Opening Animation",
|
"meta.animation": "Box Opening Animation",
|
||||||
"meta.crafting": "Crafting Panel",
|
"meta.crafting": "Crafting Panel",
|
||||||
|
"meta.completion": "Completion Tracker",
|
||||||
|
|
||||||
"item.rarity.common": "Common",
|
"item.rarity.common": "Common",
|
||||||
"item.rarity.uncommon": "Uncommon",
|
"item.rarity.uncommon": "Uncommon",
|
||||||
|
|
@ -399,5 +409,9 @@
|
||||||
"recipe.craft_box_cool": "Craft Cool Box",
|
"recipe.craft_box_cool": "Craft Cool Box",
|
||||||
"recipe.craft_box_supply": "Craft Supply Box",
|
"recipe.craft_box_supply": "Craft Supply Box",
|
||||||
"recipe.craft_box_style": "Craft Style Box",
|
"recipe.craft_box_style": "Craft Style Box",
|
||||||
"recipe.craft_box_epic": "Craft Epic Box"
|
"recipe.craft_box_epic": "Craft Epic Box",
|
||||||
|
|
||||||
|
"box.endgame": "The Final Box",
|
||||||
|
"box.endgame.desc": "You found all the resources. This is it. The last box. Are you ready?",
|
||||||
|
"item.endgame_crown": "Crown of Completion"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"loot.rarity": "Rarete",
|
"loot.rarity": "Rarete",
|
||||||
"loot.category": "Categorie",
|
"loot.category": "Categorie",
|
||||||
"ui.feature_unlocked": "NOUVELLE FONCTIONNALITE : {0}",
|
"ui.feature_unlocked": "NOUVELLE FONCTIONNALITE : {0}",
|
||||||
|
"ui.completion": "Completion : {0}%",
|
||||||
"prompt.what_do": "Que fais-tu ?",
|
"prompt.what_do": "Que fais-tu ?",
|
||||||
"prompt.invalid_choice": "Entre un nombre entre 1 et {0}.",
|
"prompt.invalid_choice": "Entre un nombre entre 1 et {0}.",
|
||||||
|
|
||||||
|
|
@ -68,8 +69,16 @@
|
||||||
"box.improvement.desc": "On peut toujours s'ameliorer. Surtout avec des boites.",
|
"box.improvement.desc": "On peut toujours s'ameliorer. Surtout avec des boites.",
|
||||||
"box.supply": "Boite de fourniture",
|
"box.supply": "Boite de fourniture",
|
||||||
"box.supply.desc": "Des fournitures ! Le sang vital de tout passione d'ouverture de boites.",
|
"box.supply.desc": "Des fournitures ! Le sang vital de tout passione d'ouverture de boites.",
|
||||||
"box.meta": "Boite Meta",
|
"box.meta_basics": "Boite Meta - Les Bases",
|
||||||
"box.meta.desc": "Cette boite ameliore... la facon dont tu vois les boites. Trop meta.",
|
"box.meta_basics.desc": "Couleurs, fleches, animations. Le fondement de la vision.",
|
||||||
|
"box.meta_interface": "Boite Meta - L'Interface",
|
||||||
|
"box.meta_interface.desc": "Panneaux, ressources, stats. Les outils de la comprehension.",
|
||||||
|
"box.meta_deep": "Boite Meta - Personnalisation",
|
||||||
|
"box.meta_deep.desc": "Couleurs etendues, artisanat, chat, portrait. Exprime-toi.",
|
||||||
|
"box.meta_resources": "Boite Meta - Ressources",
|
||||||
|
"box.meta_resources.desc": "Deverouille la capacite de voir ce que tu as. Et ce qui te manque.",
|
||||||
|
"box.meta_mastery": "Boite Meta - La Maitrise",
|
||||||
|
"box.meta_mastery.desc": "Mise en page, stats, polices. Les touches finales d'un vrai maitre des boites.",
|
||||||
"box.black": "Boite noire",
|
"box.black": "Boite noire",
|
||||||
"box.black.desc": "Personne ne sait ce qu'il y a dedans. Meme pas la boite.",
|
"box.black.desc": "Personne ne sait ce qu'il y a dedans. Meme pas la boite.",
|
||||||
"box.story": "Boite a histoire",
|
"box.story": "Boite a histoire",
|
||||||
|
|
@ -111,6 +120,7 @@
|
||||||
"meta.shortcuts": "Raccourcis clavier",
|
"meta.shortcuts": "Raccourcis clavier",
|
||||||
"meta.animation": "Animation d'ouverture de boite",
|
"meta.animation": "Animation d'ouverture de boite",
|
||||||
"meta.crafting": "Panneau de fabrication",
|
"meta.crafting": "Panneau de fabrication",
|
||||||
|
"meta.completion": "Suivi de completion",
|
||||||
|
|
||||||
"item.rarity.common": "Commun",
|
"item.rarity.common": "Commun",
|
||||||
"item.rarity.uncommon": "Peu commun",
|
"item.rarity.uncommon": "Peu commun",
|
||||||
|
|
@ -399,5 +409,9 @@
|
||||||
"recipe.craft_box_cool": "Fabriquer une boite coolos",
|
"recipe.craft_box_cool": "Fabriquer une boite coolos",
|
||||||
"recipe.craft_box_supply": "Fabriquer une boite de fourniture",
|
"recipe.craft_box_supply": "Fabriquer une boite de fourniture",
|
||||||
"recipe.craft_box_style": "Fabriquer une boite stylee",
|
"recipe.craft_box_style": "Fabriquer une boite stylee",
|
||||||
"recipe.craft_box_epic": "Fabriquer une boite epique"
|
"recipe.craft_box_epic": "Fabriquer une boite epique",
|
||||||
|
|
||||||
|
"box.endgame": "La Boite Finale",
|
||||||
|
"box.endgame.desc": "Tu as trouve toutes les ressources. C'est la derniere boite. Es-tu pret ?",
|
||||||
|
"item.endgame_crown": "Couronne d'Accomplissement"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,5 +12,6 @@ public sealed record BoxDefinition(
|
||||||
ItemRarity Rarity,
|
ItemRarity Rarity,
|
||||||
LootTable LootTable,
|
LootTable LootTable,
|
||||||
bool IsAutoOpen,
|
bool IsAutoOpen,
|
||||||
List<string>? RequiredItems = null
|
List<string>? RequiredItems = null,
|
||||||
|
AdventureTheme? AdventureTheme = null
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -32,5 +32,8 @@ public enum LootConditionType
|
||||||
HasAdventure,
|
HasAdventure,
|
||||||
|
|
||||||
/// <summary>The player owns a specific cosmetic item.</summary>
|
/// <summary>The player owns a specific cosmetic item.</summary>
|
||||||
HasCosmetic
|
HasCosmetic,
|
||||||
|
|
||||||
|
/// <summary>All resource types are visible (discovered through meta progression).</summary>
|
||||||
|
AllResourcesVisible
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,5 +41,8 @@ public enum UIFeature
|
||||||
BoxAnimation,
|
BoxAnimation,
|
||||||
|
|
||||||
/// <summary>Phase 7: Dedicated crafting panel for material transformation and item creation.</summary>
|
/// <summary>Phase 7: Dedicated crafting panel for material transformation and item creation.</summary>
|
||||||
CraftingPanel
|
CraftingPanel,
|
||||||
|
|
||||||
|
/// <summary>Phase 5: Shows completion percentage of all unique content.</summary>
|
||||||
|
CompletionTracker
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,7 @@ public sealed record ItemDefinition(
|
||||||
MaterialType? MaterialType = null,
|
MaterialType? MaterialType = null,
|
||||||
MaterialForm? MaterialForm = null,
|
MaterialForm? MaterialForm = null,
|
||||||
WorkstationType? WorkstationType = null,
|
WorkstationType? WorkstationType = null,
|
||||||
AdventureTheme? AdventureTheme = null
|
AdventureTheme? AdventureTheme = null,
|
||||||
|
StatType? StatType = null,
|
||||||
|
FontStyle? FontStyle = null
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -23,14 +23,43 @@ public static class Program
|
||||||
private static IRenderer _renderer = null!;
|
private static IRenderer _renderer = null!;
|
||||||
private static bool _running = true;
|
private static bool _running = true;
|
||||||
|
|
||||||
|
private static readonly string LogFilePath = Path.Combine(
|
||||||
|
AppContext.BaseDirectory, "openthebox-error.log");
|
||||||
|
|
||||||
public static async Task Main(string[] args)
|
public static async Task Main(string[] args)
|
||||||
{
|
{
|
||||||
_saveManager = new SaveManager();
|
try
|
||||||
_loc = new LocalizationManager(Locale.EN);
|
{
|
||||||
_renderContext = new RenderContext();
|
_saveManager = new SaveManager();
|
||||||
_renderer = RendererFactory.Create(_renderContext, _loc);
|
_loc = new LocalizationManager(Locale.EN);
|
||||||
|
_renderContext = new RenderContext();
|
||||||
|
_renderer = RendererFactory.Create(_renderContext, _loc);
|
||||||
|
|
||||||
await MainMenuLoop();
|
await MainMenuLoop();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogError(ex);
|
||||||
|
Console.ForegroundColor = ConsoleColor.Red;
|
||||||
|
Console.WriteLine($"A fatal error occurred. Details have been written to:");
|
||||||
|
Console.WriteLine(LogFilePath);
|
||||||
|
Console.ResetColor();
|
||||||
|
Console.WriteLine("Press any key to exit...");
|
||||||
|
Console.ReadKey(intercept: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void LogError(Exception ex)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entry = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}\n\n";
|
||||||
|
File.AppendAllText(LogFilePath, entry);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// If logging itself fails, at least don't hide the original error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task MainMenuLoop()
|
private static async Task MainMenuLoop()
|
||||||
|
|
@ -149,6 +178,7 @@ public static class Program
|
||||||
while (_running)
|
while (_running)
|
||||||
{
|
{
|
||||||
_renderer.Clear();
|
_renderer.Clear();
|
||||||
|
UpdateCompletionPercent();
|
||||||
_renderer.ShowGameState(_state, _renderContext);
|
_renderer.ShowGameState(_state, _renderContext);
|
||||||
|
|
||||||
var actions = BuildActionList();
|
var actions = BuildActionList();
|
||||||
|
|
@ -262,7 +292,6 @@ public static class Program
|
||||||
// Skip loot reveal for auto-consumed items (auto-opening boxes)
|
// Skip loot reveal for auto-consumed items (auto-opening boxes)
|
||||||
if (autoConsumedIds.Contains(itemEvt.Item.Id))
|
if (autoConsumedIds.Contains(itemEvt.Item.Id))
|
||||||
break;
|
break;
|
||||||
_state.AddItem(itemEvt.Item);
|
|
||||||
var itemDef = _registry.GetItem(itemEvt.Item.DefinitionId);
|
var itemDef = _registry.GetItem(itemEvt.Item.DefinitionId);
|
||||||
var itemBoxDef = itemDef is null ? _registry.GetBox(itemEvt.Item.DefinitionId) : null;
|
var itemBoxDef = itemDef is null ? _registry.GetBox(itemEvt.Item.DefinitionId) : null;
|
||||||
_renderer.ShowLootReveal(
|
_renderer.ShowLootReveal(
|
||||||
|
|
@ -278,9 +307,8 @@ public static class Program
|
||||||
case UIFeatureUnlockedEvent uiEvt:
|
case UIFeatureUnlockedEvent uiEvt:
|
||||||
_renderContext.Unlock(uiEvt.Feature);
|
_renderContext.Unlock(uiEvt.Feature);
|
||||||
_renderer = RendererFactory.Create(_renderContext, _loc);
|
_renderer = RendererFactory.Create(_renderContext, _loc);
|
||||||
var featureKey = $"meta.{uiEvt.Feature.ToString().ToLower()}";
|
|
||||||
_renderer.ShowUIFeatureUnlocked(
|
_renderer.ShowUIFeatureUnlocked(
|
||||||
_loc.Get("meta.unlocked", _loc.Get(featureKey)));
|
_loc.Get(GetUIFeatureLocKey(uiEvt.Feature)));
|
||||||
_renderer.WaitForKeyPress(_loc.Get("prompt.press_key"));
|
_renderer.WaitForKeyPress(_loc.Get("prompt.press_key"));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|
@ -455,6 +483,48 @@ public static class Program
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves the localized display name for any definition ID (item or box).
|
/// Resolves the localized display name for any definition ID (item or box).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
private static void UpdateCompletionPercent()
|
||||||
|
{
|
||||||
|
if (!_renderContext.HasCompletionTracker) return;
|
||||||
|
|
||||||
|
var totalCosmetics = _registry.Items.Values.Count(i => i.CosmeticSlot.HasValue);
|
||||||
|
var totalAdventures = Enum.GetValues<AdventureTheme>().Length;
|
||||||
|
var totalUIFeatures = Enum.GetValues<UIFeature>().Length;
|
||||||
|
var totalResources = Enum.GetValues<ResourceType>().Length;
|
||||||
|
var totalStats = Enum.GetValues<StatType>().Length;
|
||||||
|
var totalFonts = Enum.GetValues<FontStyle>().Length;
|
||||||
|
|
||||||
|
var total = totalCosmetics + totalAdventures + totalUIFeatures
|
||||||
|
+ totalResources + totalStats + totalFonts;
|
||||||
|
|
||||||
|
var unlocked = _state.UnlockedCosmetics.Count
|
||||||
|
+ _state.UnlockedAdventures.Count
|
||||||
|
+ _state.UnlockedUIFeatures.Count
|
||||||
|
+ _state.VisibleResources.Count
|
||||||
|
+ _state.VisibleStats.Count
|
||||||
|
+ _state.AvailableFonts.Count;
|
||||||
|
|
||||||
|
_renderContext.CompletionPercent = total > 0 ? (int)(unlocked * 100.0 / total) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetUIFeatureLocKey(UIFeature feature) => feature switch
|
||||||
|
{
|
||||||
|
UIFeature.TextColors => "meta.colors",
|
||||||
|
UIFeature.ExtendedColors => "meta.extended_colors",
|
||||||
|
UIFeature.ArrowKeySelection => "meta.arrows",
|
||||||
|
UIFeature.InventoryPanel => "meta.inventory",
|
||||||
|
UIFeature.ResourcePanel => "meta.resources",
|
||||||
|
UIFeature.StatsPanel => "meta.stats",
|
||||||
|
UIFeature.PortraitPanel => "meta.portrait",
|
||||||
|
UIFeature.ChatPanel => "meta.chat",
|
||||||
|
UIFeature.FullLayout => "meta.layout",
|
||||||
|
UIFeature.KeyboardShortcuts => "meta.shortcuts",
|
||||||
|
UIFeature.BoxAnimation => "meta.animation",
|
||||||
|
UIFeature.CraftingPanel => "meta.crafting",
|
||||||
|
UIFeature.CompletionTracker => "meta.completion",
|
||||||
|
_ => $"meta.{feature.ToString().ToLower()}"
|
||||||
|
};
|
||||||
|
|
||||||
private static string GetLocalizedName(string definitionId)
|
private static string GetLocalizedName(string definitionId)
|
||||||
{
|
{
|
||||||
var itemDef = _registry.GetItem(definitionId);
|
var itemDef = _registry.GetItem(definitionId);
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,10 @@ public sealed class BasicRenderer(LocalizationManager loc) : IRenderer
|
||||||
|
|
||||||
public void ShowGameState(GameState state, RenderContext context)
|
public void ShowGameState(GameState state, RenderContext context)
|
||||||
{
|
{
|
||||||
// Phase 0: no panels unlocked yet, so nothing to show.
|
if (context.HasCompletionTracker)
|
||||||
|
{
|
||||||
|
Console.WriteLine(loc.Get("ui.completion", context.CompletionPercent));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ShowAdventureDialogue(string? character, string text)
|
public void ShowAdventureDialogue(string? character, string text)
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ public static class ResourcePanel
|
||||||
string bar = new string('#', filled) + new string('-', empty);
|
string bar = new string('#', filled) + new string('-', empty);
|
||||||
string color = GetResourceColor(resourceType);
|
string color = GetResourceColor(resourceType);
|
||||||
|
|
||||||
rows.Add(new Markup($" [{color}]{Markup.Escape(label)}[/]: [{color}][{bar}][/] {current}/{max}"));
|
rows.Add(new Markup($" [{color}]{Markup.Escape(label)}[/]: [{color}][[{bar}]][/] {current}/{max}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (rows.Count == 0)
|
if (rows.Count == 0)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,10 @@ public sealed class RenderContext
|
||||||
public bool HasKeyboardShortcuts => Has(UIFeature.KeyboardShortcuts);
|
public bool HasKeyboardShortcuts => Has(UIFeature.KeyboardShortcuts);
|
||||||
public bool HasBoxAnimation => Has(UIFeature.BoxAnimation);
|
public bool HasBoxAnimation => Has(UIFeature.BoxAnimation);
|
||||||
public bool HasCraftingPanel => Has(UIFeature.CraftingPanel);
|
public bool HasCraftingPanel => Has(UIFeature.CraftingPanel);
|
||||||
|
public bool HasCompletionTracker => Has(UIFeature.CompletionTracker);
|
||||||
|
|
||||||
|
/// <summary>Completion percentage (0-100), updated before each render call.</summary>
|
||||||
|
public int CompletionPercent { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a <see cref="RenderContext"/> that mirrors the features already unlocked in a
|
/// Builds a <see cref="RenderContext"/> that mirrors the features already unlocked in a
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ public sealed class SpectreRenderer : IRenderer
|
||||||
foreach (var (name, rarity, category) in items)
|
foreach (var (name, rarity, category) in items)
|
||||||
{
|
{
|
||||||
string color = RarityColor(rarity);
|
string color = RarityColor(rarity);
|
||||||
AnsiConsole.MarkupLine($" - [{color}]{Markup.Escape(name)}[/] [{color}][{Markup.Escape(rarity)}][/] ({Markup.Escape(category)})");
|
AnsiConsole.MarkupLine($" - [{color}]{Markup.Escape(name)}[/] [{color}][[{Markup.Escape(rarity)}]][/] ({Markup.Escape(category)})");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
|
|
@ -382,6 +382,11 @@ public sealed class SpectreRenderer : IRenderer
|
||||||
layout["Bottom"].Update(new Panel("[dim]???[/]").Header("???"));
|
layout["Bottom"].Update(new Panel("[dim]???[/]").Header("???"));
|
||||||
|
|
||||||
AnsiConsole.Write(layout);
|
AnsiConsole.Write(layout);
|
||||||
|
|
||||||
|
if (context.HasCompletionTracker)
|
||||||
|
{
|
||||||
|
AnsiConsole.Write(new Rule($"[bold cyan]{Markup.Escape(_loc.Get("ui.completion", context.CompletionPercent))}[/]").RuleStyle("cyan"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -413,5 +418,10 @@ public sealed class SpectreRenderer : IRenderer
|
||||||
{
|
{
|
||||||
AnsiConsole.Write(ChatPanel.Render([]));
|
AnsiConsole.Write(ChatPanel.Render([]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (context.HasCompletionTracker)
|
||||||
|
{
|
||||||
|
AnsiConsole.Write(new Rule($"[bold cyan]{Markup.Escape(_loc.Get("ui.completion", context.CompletionPercent))}[/]").RuleStyle("cyan"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,42 @@ public class BoxEngine(ContentRegistry registry)
|
||||||
|
|
||||||
// Handle weighted random rolls
|
// Handle weighted random rolls
|
||||||
var eligibleEntries = FilterEligibleEntries(boxDef.LootTable, state);
|
var eligibleEntries = FilterEligibleEntries(boxDef.LootTable, state);
|
||||||
|
// Remove unique-unlock items the player already owns so they don't drop again
|
||||||
|
eligibleEntries.RemoveAll(e =>
|
||||||
|
{
|
||||||
|
// Cosmetics: check by definition ID
|
||||||
|
if (state.UnlockedCosmetics.Contains(e.ItemDefinitionId))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Check if this is a box with a known adventure theme already unlocked
|
||||||
|
var boxDef = registry.GetBox(e.ItemDefinitionId);
|
||||||
|
if (boxDef?.AdventureTheme is { } theme && state.UnlockedAdventures.Contains(theme))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Look up item definition for field-based dedup
|
||||||
|
var itemDef = registry.GetItem(e.ItemDefinitionId);
|
||||||
|
if (itemDef is null)
|
||||||
|
return false; // Box entries and unknown entries survive the filter
|
||||||
|
|
||||||
|
// Meta UI features already unlocked
|
||||||
|
if (itemDef.MetaUnlock.HasValue && state.UnlockedUIFeatures.Contains(itemDef.MetaUnlock.Value))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Resource visibility already unlocked (only Meta items, not consumables)
|
||||||
|
if (itemDef.ResourceType.HasValue && itemDef.Category == ItemCategory.Meta
|
||||||
|
&& state.VisibleResources.Contains(itemDef.ResourceType.Value))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Stat visibility already unlocked
|
||||||
|
if (itemDef.StatType.HasValue && state.VisibleStats.Contains(itemDef.StatType.Value))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Font already unlocked
|
||||||
|
if (itemDef.FontStyle.HasValue && state.AvailableFonts.Contains(itemDef.FontStyle.Value))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
if (boxDef.LootTable.RollCount > 0 && eligibleEntries.Count > 0)
|
if (boxDef.LootTable.RollCount > 0 && eligibleEntries.Count > 0)
|
||||||
{
|
{
|
||||||
var weightedEntries = eligibleEntries
|
var weightedEntries = eligibleEntries
|
||||||
|
|
@ -111,6 +147,8 @@ public class BoxEngine(ContentRegistry registry)
|
||||||
&& state.UnlockedAdventures.Contains(adv),
|
&& state.UnlockedAdventures.Contains(adv),
|
||||||
LootConditionType.HasCosmetic => condition.TargetId is not null
|
LootConditionType.HasCosmetic => condition.TargetId is not null
|
||||||
&& state.UnlockedCosmetics.Contains(condition.TargetId),
|
&& state.UnlockedCosmetics.Contains(condition.TargetId),
|
||||||
|
LootConditionType.AllResourcesVisible =>
|
||||||
|
state.VisibleResources.Count >= Enum.GetValues<ResourceType>().Length,
|
||||||
_ => true
|
_ => true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,18 @@ public class MetaEngine
|
||||||
state.UnlockedAdventures.Add(itemDef.AdventureTheme.Value);
|
state.UnlockedAdventures.Add(itemDef.AdventureTheme.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make stat type visible if this item references a stat
|
||||||
|
if (itemDef.StatType.HasValue)
|
||||||
|
{
|
||||||
|
state.VisibleStats.Add(itemDef.StatType.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock font if this item references a font style
|
||||||
|
if (itemDef.FontStyle.HasValue)
|
||||||
|
{
|
||||||
|
state.AvailableFonts.Add(itemDef.FontStyle.Value);
|
||||||
|
}
|
||||||
|
|
||||||
// Track cosmetic unlocks
|
// Track cosmetic unlocks
|
||||||
if (itemDef.CosmeticSlot.HasValue && itemDef.CosmeticValue is not null)
|
if (itemDef.CosmeticSlot.HasValue && itemDef.CosmeticValue is not null)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -33,13 +33,20 @@ public static class WeightedRandom
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Picks multiple items from a weighted list (with replacement) using the provided random source.
|
/// Picks multiple items from a weighted list (without replacement) using the provided random source.
|
||||||
|
/// If count exceeds available entries, returns one of each.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<T> PickMultiple<T>(IReadOnlyList<(T item, float weight)> entries, Random rng, int count)
|
public static List<T> PickMultiple<T>(IReadOnlyList<(T item, float weight)> entries, Random rng, int count)
|
||||||
{
|
{
|
||||||
var results = new List<T>(count);
|
var results = new List<T>(count);
|
||||||
for (var i = 0; i < count; i++)
|
var remaining = new List<(T item, float weight)>(entries);
|
||||||
results.Add(Pick(entries, rng));
|
|
||||||
|
for (var i = 0; i < count && remaining.Count > 0; i++)
|
||||||
|
{
|
||||||
|
var picked = Pick(remaining, rng);
|
||||||
|
results.Add(picked);
|
||||||
|
remaining.RemoveAll(e => EqualityComparer<T>.Default.Equals(e.item, picked));
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1001
tests/OpenTheBox.Tests/RendererTests.cs
Normal file
1001
tests/OpenTheBox.Tests/RendererTests.cs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -382,6 +382,62 @@ public class ContentValidationTests
|
||||||
Assert.Contains(events, e => e is ItemReceivedEvent);
|
Assert.Contains(events, e => e is ItemReceivedEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Black Box invariant: simulation owns all state mutations ─────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Simulation_ProcessAction_HandlesAllStateMutations()
|
||||||
|
{
|
||||||
|
// The simulation (ProcessAction) must be the SOLE mutator of GameState.
|
||||||
|
// After calling ProcessAction, the inventory should already reflect all
|
||||||
|
// items received and consumed. External code (game loop, renderer) must
|
||||||
|
// NOT call AddItem/RemoveItem — that would cause duplicates.
|
||||||
|
|
||||||
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
|
||||||
|
var simulation = new GameSimulation(registry, new Random(42));
|
||||||
|
var state = GameState.Create("TestPlayer", Locale.EN);
|
||||||
|
|
||||||
|
var starterBox = ItemInstance.Create("box_starter");
|
||||||
|
state.AddItem(starterBox);
|
||||||
|
|
||||||
|
int inventoryBefore = state.Inventory.Count; // 1 (the starter box)
|
||||||
|
|
||||||
|
var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" };
|
||||||
|
var events = simulation.ProcessAction(action, state);
|
||||||
|
|
||||||
|
// Count events
|
||||||
|
int received = events.OfType<ItemReceivedEvent>().Count();
|
||||||
|
int consumed = events.OfType<ItemConsumedEvent>().Count();
|
||||||
|
|
||||||
|
// The simulation already mutated state. Verify the inventory matches
|
||||||
|
// exactly: initial - consumed + received
|
||||||
|
int expectedCount = inventoryBefore - consumed + received;
|
||||||
|
Assert.Equal(expectedCount, state.Inventory.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Simulation_NoDuplicateItemInstances_InInventory()
|
||||||
|
{
|
||||||
|
// Each ItemInstance has a unique Guid. After opening a box,
|
||||||
|
// no two inventory entries should share the same Id.
|
||||||
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
|
||||||
|
var simulation = new GameSimulation(registry, new Random(42));
|
||||||
|
var state = GameState.Create("TestPlayer", Locale.EN);
|
||||||
|
|
||||||
|
var starterBox = ItemInstance.Create("box_starter");
|
||||||
|
state.AddItem(starterBox);
|
||||||
|
|
||||||
|
var action = new OpenBoxAction(starterBox.Id) { BoxDefinitionId = "box_starter" };
|
||||||
|
simulation.ProcessAction(action, state);
|
||||||
|
|
||||||
|
var duplicateIds = state.Inventory
|
||||||
|
.GroupBy(i => i.Id)
|
||||||
|
.Where(g => g.Count() > 1)
|
||||||
|
.Select(g => g.Key)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
Assert.Empty(duplicateIds);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Adventures ───────────────────────────────────────────────────────
|
// ── Adventures ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
|
|
@ -416,6 +472,300 @@ public class ContentValidationTests
|
||||||
Assert.True(File.Exists(path), $"Missing French translation: {path}");
|
Assert.True(File.Exists(path), $"Missing French translation: {path}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Full run integration tests ─────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullRun_AllReachableContentIsObtained()
|
||||||
|
{
|
||||||
|
// Simulates an entire game playthrough by repeatedly opening boxes
|
||||||
|
// until all content reachable via box openings is unlocked.
|
||||||
|
// Uses only the simulation (zero I/O) to prove game completability.
|
||||||
|
|
||||||
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
|
||||||
|
var simulation = new GameSimulation(registry, new Random(42));
|
||||||
|
var state = GameState.Create("CompletionTest", Locale.EN);
|
||||||
|
|
||||||
|
// ── Compute the "reachable set" dynamically from item definitions ──
|
||||||
|
|
||||||
|
var allItems = registry.Items.Values.ToList();
|
||||||
|
|
||||||
|
// All MetaUnlock values that exist on items → expected UI features
|
||||||
|
var expectedUIFeatures = allItems
|
||||||
|
.Where(i => i.MetaUnlock.HasValue)
|
||||||
|
.Select(i => i.MetaUnlock!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
// All cosmetic definition IDs → expected cosmetics
|
||||||
|
var expectedCosmetics = allItems
|
||||||
|
.Where(i => i.CosmeticSlot.HasValue)
|
||||||
|
.Select(i => i.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
// All AdventureTheme values that exist on items → expected adventures
|
||||||
|
var expectedAdventures = allItems
|
||||||
|
.Where(i => i.AdventureTheme.HasValue)
|
||||||
|
.Select(i => i.AdventureTheme!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
// All ResourceType values that exist on items → expected visible resources
|
||||||
|
var expectedResources = allItems
|
||||||
|
.Where(i => i.ResourceType.HasValue)
|
||||||
|
.Select(i => i.ResourceType!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
// All lore fragment definition IDs
|
||||||
|
var expectedLore = allItems
|
||||||
|
.Where(i => i.Category == ItemCategory.LoreFragment)
|
||||||
|
.Select(i => i.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
// All StatType values that exist on items → expected visible stats
|
||||||
|
var expectedStats = allItems
|
||||||
|
.Where(i => i.StatType.HasValue)
|
||||||
|
.Select(i => i.StatType!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
// All FontStyle values that exist on items → expected fonts
|
||||||
|
var expectedFonts = allItems
|
||||||
|
.Where(i => i.FontStyle.HasValue)
|
||||||
|
.Select(i => i.FontStyle!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
// Track all unique item definition IDs ever received
|
||||||
|
var seenDefinitionIds = new HashSet<string>();
|
||||||
|
|
||||||
|
// ── Give starter box and run the game loop ──
|
||||||
|
|
||||||
|
var starterBox = ItemInstance.Create("box_starter");
|
||||||
|
state.AddItem(starterBox);
|
||||||
|
|
||||||
|
const int maxBoxOpenings = 10_000;
|
||||||
|
int totalBoxesOpened = 0;
|
||||||
|
|
||||||
|
for (int i = 0; i < maxBoxOpenings; i++)
|
||||||
|
{
|
||||||
|
// Pick one box from inventory
|
||||||
|
var box = state.Inventory
|
||||||
|
.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
|
||||||
|
|
||||||
|
if (box is null)
|
||||||
|
break; // Game loop broke — will be caught by asserts
|
||||||
|
|
||||||
|
var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId };
|
||||||
|
var events = simulation.ProcessAction(action, state);
|
||||||
|
|
||||||
|
// Track all received items
|
||||||
|
foreach (var evt in events.OfType<ItemReceivedEvent>())
|
||||||
|
{
|
||||||
|
seenDefinitionIds.Add(evt.Item.DefinitionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
totalBoxesOpened++;
|
||||||
|
|
||||||
|
// Check if we've covered everything
|
||||||
|
bool allUIFeatures = expectedUIFeatures.IsSubsetOf(state.UnlockedUIFeatures);
|
||||||
|
bool allCosmetics = expectedCosmetics.IsSubsetOf(state.UnlockedCosmetics);
|
||||||
|
bool allAdventures = expectedAdventures.IsSubsetOf(state.UnlockedAdventures);
|
||||||
|
bool allResources = expectedResources.IsSubsetOf(state.VisibleResources);
|
||||||
|
bool allLore = expectedLore.IsSubsetOf(seenDefinitionIds);
|
||||||
|
bool allStats = expectedStats.IsSubsetOf(state.VisibleStats);
|
||||||
|
bool allFonts = expectedFonts.IsSubsetOf(state.AvailableFonts);
|
||||||
|
|
||||||
|
if (allUIFeatures && allCosmetics && allAdventures && allResources && allLore && allStats && allFonts)
|
||||||
|
break; // 100% completion reached
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Assertions with detailed failure messages ──
|
||||||
|
|
||||||
|
var missingUIFeatures = expectedUIFeatures.Except(state.UnlockedUIFeatures).ToList();
|
||||||
|
Assert.True(missingUIFeatures.Count == 0,
|
||||||
|
$"Missing UI features after {totalBoxesOpened} boxes: {string.Join(", ", missingUIFeatures)}");
|
||||||
|
|
||||||
|
var missingCosmetics = expectedCosmetics.Except(state.UnlockedCosmetics).ToList();
|
||||||
|
Assert.True(missingCosmetics.Count == 0,
|
||||||
|
$"Missing cosmetics after {totalBoxesOpened} boxes: {string.Join(", ", missingCosmetics)}");
|
||||||
|
|
||||||
|
var missingAdventures = expectedAdventures.Except(state.UnlockedAdventures).ToList();
|
||||||
|
Assert.True(missingAdventures.Count == 0,
|
||||||
|
$"Missing adventures after {totalBoxesOpened} boxes: {string.Join(", ", missingAdventures)}");
|
||||||
|
|
||||||
|
var missingResources = expectedResources.Except(state.VisibleResources).ToList();
|
||||||
|
Assert.True(missingResources.Count == 0,
|
||||||
|
$"Missing visible resources after {totalBoxesOpened} boxes: {string.Join(", ", missingResources)}");
|
||||||
|
|
||||||
|
var missingLore = expectedLore.Except(seenDefinitionIds).ToList();
|
||||||
|
Assert.True(missingLore.Count == 0,
|
||||||
|
$"Missing lore fragments after {totalBoxesOpened} boxes: {string.Join(", ", missingLore)}");
|
||||||
|
|
||||||
|
var missingStats = expectedStats.Except(state.VisibleStats).ToList();
|
||||||
|
Assert.True(missingStats.Count == 0,
|
||||||
|
$"Missing visible stats after {totalBoxesOpened} boxes: {string.Join(", ", missingStats)}");
|
||||||
|
|
||||||
|
var missingFonts = expectedFonts.Except(state.AvailableFonts).ToList();
|
||||||
|
Assert.True(missingFonts.Count == 0,
|
||||||
|
$"Missing fonts after {totalBoxesOpened} boxes: {string.Join(", ", missingFonts)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullRun_GameLoopNeverBreaks()
|
||||||
|
{
|
||||||
|
// After 500 box openings, the player must still have at least 1 box
|
||||||
|
// in inventory. This validates the box_of_boxes guaranteed roll
|
||||||
|
// sustains the game loop indefinitely.
|
||||||
|
|
||||||
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
|
||||||
|
var simulation = new GameSimulation(registry, new Random(123));
|
||||||
|
var state = GameState.Create("LoopTest", Locale.EN);
|
||||||
|
|
||||||
|
var starterBox = ItemInstance.Create("box_starter");
|
||||||
|
state.AddItem(starterBox);
|
||||||
|
|
||||||
|
for (int i = 0; i < 500; i++)
|
||||||
|
{
|
||||||
|
var box = state.Inventory
|
||||||
|
.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
|
||||||
|
|
||||||
|
Assert.True(box is not null,
|
||||||
|
$"No boxes left in inventory after opening {i} boxes. Game loop is broken.");
|
||||||
|
|
||||||
|
var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId };
|
||||||
|
simulation.ProcessAction(action, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// After 500 openings, player should still have boxes
|
||||||
|
var remainingBoxes = state.Inventory.Count(i => registry.IsBox(i.DefinitionId));
|
||||||
|
Assert.True(remainingBoxes > 0,
|
||||||
|
"No boxes remaining after 500 openings. Game loop is unsustainable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FullRun_PacingReport()
|
||||||
|
{
|
||||||
|
// Diagnostic test: outputs a pacing report showing when each piece
|
||||||
|
// of content is first unlocked. Not a pass/fail test — it always
|
||||||
|
// passes but prints progression milestones to the test output.
|
||||||
|
|
||||||
|
var registry = ContentRegistry.LoadFromFiles(ItemsPath, BoxesPath, InteractionsPath);
|
||||||
|
var simulation = new GameSimulation(registry, new Random(42));
|
||||||
|
var state = GameState.Create("PacingTest", Locale.EN);
|
||||||
|
|
||||||
|
var allItems = registry.Items.Values.ToList();
|
||||||
|
|
||||||
|
var expectedUIFeatures = allItems
|
||||||
|
.Where(i => i.MetaUnlock.HasValue)
|
||||||
|
.Select(i => i.MetaUnlock!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
var expectedCosmetics = allItems
|
||||||
|
.Where(i => i.CosmeticSlot.HasValue)
|
||||||
|
.Select(i => i.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
var expectedAdventures = allItems
|
||||||
|
.Where(i => i.AdventureTheme.HasValue)
|
||||||
|
.Select(i => i.AdventureTheme!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
var expectedResources = allItems
|
||||||
|
.Where(i => i.ResourceType.HasValue)
|
||||||
|
.Select(i => i.ResourceType!.Value)
|
||||||
|
.ToHashSet();
|
||||||
|
var expectedLore = allItems
|
||||||
|
.Where(i => i.Category == ItemCategory.LoreFragment)
|
||||||
|
.Select(i => i.Id)
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var seenDefinitionIds = new HashSet<string>();
|
||||||
|
|
||||||
|
// Track unlock milestones: (box#, description)
|
||||||
|
var milestones = new List<(int boxNum, string description)>();
|
||||||
|
|
||||||
|
// Track previous counts to detect new unlocks
|
||||||
|
int prevUI = 0, prevCos = 0, prevAdv = 0, prevRes = 0, prevLore = 0;
|
||||||
|
|
||||||
|
var starterBox = ItemInstance.Create("box_starter");
|
||||||
|
state.AddItem(starterBox);
|
||||||
|
|
||||||
|
const int maxBoxOpenings = 10_000;
|
||||||
|
int totalBoxesOpened = 0;
|
||||||
|
bool complete = false;
|
||||||
|
|
||||||
|
for (int i = 0; i < maxBoxOpenings && !complete; i++)
|
||||||
|
{
|
||||||
|
var box = state.Inventory
|
||||||
|
.FirstOrDefault(item => registry.IsBox(item.DefinitionId));
|
||||||
|
if (box is null) break;
|
||||||
|
|
||||||
|
var action = new OpenBoxAction(box.Id) { BoxDefinitionId = box.DefinitionId };
|
||||||
|
var events = simulation.ProcessAction(action, state);
|
||||||
|
|
||||||
|
foreach (var evt in events.OfType<ItemReceivedEvent>())
|
||||||
|
seenDefinitionIds.Add(evt.Item.DefinitionId);
|
||||||
|
|
||||||
|
totalBoxesOpened++;
|
||||||
|
|
||||||
|
// Detect new unlocks
|
||||||
|
int curUI = state.UnlockedUIFeatures.Count(f => expectedUIFeatures.Contains(f));
|
||||||
|
int curCos = state.UnlockedCosmetics.Count(c => expectedCosmetics.Contains(c));
|
||||||
|
int curAdv = state.UnlockedAdventures.Count(a => expectedAdventures.Contains(a));
|
||||||
|
int curRes = state.VisibleResources.Count(r => expectedResources.Contains(r));
|
||||||
|
int curLore = seenDefinitionIds.Count(id => expectedLore.Contains(id));
|
||||||
|
|
||||||
|
if (curUI > prevUI)
|
||||||
|
milestones.Add((totalBoxesOpened, $"UI Feature {curUI}/{expectedUIFeatures.Count}: +{string.Join(", ", state.UnlockedUIFeatures.Where(f => expectedUIFeatures.Contains(f)).Except(milestones.Where(m => m.description.StartsWith("UI")).SelectMany(_ => Array.Empty<UIFeature>())))}"));
|
||||||
|
if (curCos > prevCos)
|
||||||
|
milestones.Add((totalBoxesOpened, $"Cosmetics: {curCos}/{expectedCosmetics.Count}"));
|
||||||
|
if (curAdv > prevAdv)
|
||||||
|
milestones.Add((totalBoxesOpened, $"Adventures: {curAdv}/{expectedAdventures.Count}"));
|
||||||
|
if (curRes > prevRes)
|
||||||
|
milestones.Add((totalBoxesOpened, $"Resources: {curRes}/{expectedResources.Count}"));
|
||||||
|
if (curLore > prevLore)
|
||||||
|
milestones.Add((totalBoxesOpened, $"Lore: {curLore}/{expectedLore.Count}"));
|
||||||
|
|
||||||
|
prevUI = curUI; prevCos = curCos; prevAdv = curAdv; prevRes = curRes; prevLore = curLore;
|
||||||
|
|
||||||
|
complete = curUI == expectedUIFeatures.Count
|
||||||
|
&& curCos == expectedCosmetics.Count
|
||||||
|
&& curAdv == expectedAdventures.Count
|
||||||
|
&& curRes == expectedResources.Count
|
||||||
|
&& curLore == expectedLore.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the pacing report
|
||||||
|
var report = new System.Text.StringBuilder();
|
||||||
|
report.AppendLine();
|
||||||
|
report.AppendLine("╔══════════════════════════════════════════════════════╗");
|
||||||
|
report.AppendLine("║ PACING REPORT (seed=42) ║");
|
||||||
|
report.AppendLine("╠══════════════════════════════════════════════════════╣");
|
||||||
|
report.AppendLine($"║ Total boxes opened: {totalBoxesOpened,-32}║");
|
||||||
|
report.AppendLine($"║ Game completed: {(complete ? "YES" : "NO"),-36}║");
|
||||||
|
report.AppendLine("╠══════════════════════════════════════════════════════╣");
|
||||||
|
report.AppendLine("║ Box# │ Milestone ║");
|
||||||
|
report.AppendLine("╟────────┼────────────────────────────────────────────╢");
|
||||||
|
foreach (var (boxNum, desc) in milestones)
|
||||||
|
{
|
||||||
|
report.AppendLine($"║ {boxNum,5} │ {desc,-42} ║");
|
||||||
|
}
|
||||||
|
report.AppendLine("╠══════════════════════════════════════════════════════╣");
|
||||||
|
|
||||||
|
// Summary by category with completion box#
|
||||||
|
int uiDoneAt = milestones.LastOrDefault(m => m.description.Contains($"UI Feature {expectedUIFeatures.Count}/")).boxNum;
|
||||||
|
int cosDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Cosmetics: {expectedCosmetics.Count}/")).boxNum;
|
||||||
|
int advDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Adventures: {expectedAdventures.Count}/")).boxNum;
|
||||||
|
int resDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Resources: {expectedResources.Count}/")).boxNum;
|
||||||
|
int loreDoneAt = milestones.LastOrDefault(m => m.description.Contains($"Lore: {expectedLore.Count}/")).boxNum;
|
||||||
|
|
||||||
|
report.AppendLine($"║ UI Features ({expectedUIFeatures.Count,2}) complete at box #{uiDoneAt,-17}║");
|
||||||
|
report.AppendLine($"║ Cosmetics ({expectedCosmetics.Count,2}) complete at box #{cosDoneAt,-17}║");
|
||||||
|
report.AppendLine($"║ Adventures ({expectedAdventures.Count,2}) complete at box #{advDoneAt,-17}║");
|
||||||
|
report.AppendLine($"║ Resources ({expectedResources.Count,2}) complete at box #{resDoneAt,-17}║");
|
||||||
|
report.AppendLine($"║ Lore ({expectedLore.Count,2}) complete at box #{loreDoneAt,-17}║");
|
||||||
|
report.AppendLine("╚══════════════════════════════════════════════════════╝");
|
||||||
|
|
||||||
|
// Output via ITestOutputHelper would be ideal, but Assert message works too
|
||||||
|
Assert.True(true, report.ToString());
|
||||||
|
|
||||||
|
// Also write to console for visibility in test runners
|
||||||
|
Console.WriteLine(report.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private static List<ItemDefinition> LoadItems()
|
private static List<ItemDefinition> LoadItems()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue