diff --git a/DOCS/release-notes/README.md b/DOCS/release-notes/README.md index f68bd74d..96007a59 100644 --- a/DOCS/release-notes/README.md +++ b/DOCS/release-notes/README.md @@ -3,6 +3,7 @@ One file per version, newest first. Steam-formatted copies (BBCode) are in `steam/`. The GitHub release for each tag carries the same text. +- [v1.1.12](RELEASE_NOTES_1.1.12.md) - [v1.1.11](RELEASE_NOTES_1.1.11.md) - [v1.1.10](RELEASE_NOTES_1.1.10.md) - [v1.1.9](RELEASE_NOTES_1.1.9.md) diff --git a/DOCS/release-notes/RELEASE_NOTES_1.1.12.md b/DOCS/release-notes/RELEASE_NOTES_1.1.12.md new file mode 100644 index 00000000..a6f9ec5f --- /dev/null +++ b/DOCS/release-notes/RELEASE_NOTES_1.1.12.md @@ -0,0 +1,101 @@ +# Usurper Reborn v1.1.12 + +## Awakening + +- **Awakening rises in seven stages, and each rise is shown.** A framed + AWAKENING screen appears at the next safe moment, never mid-fight or + mid-dialogue, with a piece of the Ocean's story, the stage's gift and + what the stage opens. +- **More of what you live through counts.** Stages are reached by points: + a moment of awakening is worth 3, a Wave Fragment 2, and each distinct + insight (dreams, discoveries, shrines, lore songs and others) 1. The + stages need 4, 12, 22, 34, 48 and 62 points; the seventh also needs all + seven seals or the truth of who you are. Insights are now saved. +- **Moments that never counted now do:** sparing a beaten foe, meeting + Manwe, accepting Manwe's offer, refusing paradise, taking the darkness, + hearing an Old God's lore song, and coming to accept a companion's loss. + Recovering your memories needs four of them, the number that can be + found. +- **Each stage gives a lasting gift:** stage 1 +3 Wisdom, 2 +5% max mana, + 3 +5% combat XP, 4 +5% max HP, 5 +3% damage dealt, 6 3% less damage + taken, and at stage 7 each percentage gift grows by 5 more and you are + "the Awakened". +- **A duel defender fights with the awakening gifts of their own saved + stage,** as they already fight with their own team's HQ bonuses. +- **[P] Progress shows your stage out of 7** (it said out of 5), and [J] + opens the Ocean Journal: points to the next stage, the fragments found + with their lore, and the moments lived. +- **A new character is told that something in them is asleep,** and a + one-time hint explains the Awakening line on the status screen. +- **Story chapter 1 is "The Drowning".** +- Existing characters keep at least the stage they had. + +## Team Corner + +- **Pick from a list:** Info, Join, Examine, Sack, Equip, Specialize and + Resurrect show a paged list; pick by number, by name or by the start of + a name. Examine lists player members too. +- **Team rankings load at once** (each view used to read every save once + per team) and show each team's real power and average level, NPC and player + members together. Teams with no members are not listed. +- **Fixes:** + - Changing the team password takes effect; only the leader can change + it (a team founded by an NPC checks the NPC's password). + - A team dissolved by its last member takes its upgrades and vault with + it, and a new team starts without any left under its name. + - Quitting keeps the team's protection from the NPC world while a player + is still in it, and keeps a team whose NPC members are dead for now. + - Creating a team charges only when the team is made; names are trimmed + and compared ignoring case, accented letters included. + - Sack, Resurrect, Specialize and Recruit act on the current NPC after + a world reload; Resurrect saves the result. + - A team war left running by a disconnect ends after 10 minutes; its + wager is refunded if no round was fought. A war pays or charges only + when its result is saved. + - Vault deposits cannot duplicate gold and respect the vault's size; + an upgrade cannot be bought twice at once or past level 10. + - Gear moved between you and a teammate is saved at once and cannot be + duplicated. Equip Best fills every slot in one pass. + - Teams hold five members, players and NPCs together; a dead member + keeps their place. + - A cursed item asks before it is equipped. +- View Inventories is on every menu; rankings and war history page; + Resurrect and vault withdrawals ask first; Sack offers to take back the + gear you gave; the Team Corner's text is translated. + +## Other fixes + +- **NPC teammates no longer attack their sleeping player.** +- The "World event bonus" line shows only a world event's share; it + appeared on ordinary kills. +- Cursed items are marked in the backpack, and the hint for removing a + curse names the Magic Shop. +- The dungeon's one rest per floor is kept when you log out and back in, + and the rest screens say so. +- A one-time hint explains lives when you first enter the dungeon. +- Spellcasters see the stamina bar when they have stamina to spend. +- Gear found in the dungeon, and the dungeon merchant's wares, are + compared with what you wear. +- The training points hint names the Level Master's key, the auto-combat + notice says what it does, and Main Street's menu shows the world events + key. +- Gold sent to an alt character reaches that character. +- Deleting a character: + - clears the children and quests of a banned character too; + - keeps mail meant for another character of the same name; + - does not pay a later character of the same name an old war refund. + +## Known and unchanged + +- Two players joining a team at the same moment can take it to six. +- Two teammates taking back the same NPC's gear at the same moment can + both receive it. +- A crash in the middle of a vault deposit loses the deposit. +- A fought team war whose result cannot be saved ends with nothing paid. +- Existing team names that differ only in case stay separate teams. +- The Spanish, French, Hungarian and Italian Awakening text awaits a + native speaker's review. + +## Tests + +1,731 passing, up from 1,568. diff --git a/DOCS/release-notes/steam/STEAM_RELEASE_NOTES_1.1.12.txt b/DOCS/release-notes/steam/STEAM_RELEASE_NOTES_1.1.12.txt new file mode 100644 index 00000000..f149ff28 --- /dev/null +++ b/DOCS/release-notes/steam/STEAM_RELEASE_NOTES_1.1.12.txt @@ -0,0 +1,10 @@ +[h1]Usurper Reborn 1.1.12[/h1] + +[list] +[*]Awakening grows in seven stages, each shown when it happens, with a piece of the Ocean's story and a lasting gift: Wisdom, mana, XP, HP, damage and defence. Many more of your moments, dreams and discoveries now count, and [P] Progress opens the Ocean Journal. +[*]Story chapter 1 is now "The Drowning". +[*]Team Corner: pick teams and members from a list instead of typing names. Rankings load at once and show real numbers. +[*]Team fixes: passwords, dissolving and creating teams, wars left running, vault deposits and upgrades, gear moved between teammates, and a five-member limit. +[*]NPC teammates no longer attack their sleeping player. +[*]The world event bonus line, cursed items, the one rest per floor, lives, the stamina bar and found gear comparisons are clearer. +[/list] diff --git a/Localization/en.json b/Localization/en.json index e24eb3f3..e780ef26 100644 --- a/Localization/en.json +++ b/Localization/en.json @@ -222,6 +222,7 @@ "main_street.menu_news_suffix": " News ", "main_street.menu_fame_suffix": " Fame ", "main_street.menu_explore_suffix": " Explore ", + "main_street.menu_events_suffix": " World Events", "main_street.menu_status_suffix": " Status", "main_street.menu_quit_suffix": " Quit ", "main_street.menu_settings": "Settings", @@ -483,13 +484,6 @@ "main_street.story_god_known": "Known", "main_street.story_god_hidden": "Somewhere in the depths...", "main_street.story_ocean_desc": " Your spiritual awakening through grief, sacrifice, and understanding", - "main_street.story_awakening_level": " Awakening Level: {0}/5", - "main_street.story_awakening_0": "Unawakened - You see only the surface of things", - "main_street.story_awakening_1": "Stirring - Something deep within begins to move", - "main_street.story_awakening_2": "Ripples - You sense connections between all things", - "main_street.story_awakening_3": "Currents - The depths call to you with ancient whispers", - "main_street.story_awakening_4": "Depths - You understand the ocean's sorrow", - "main_street.story_awakening_5": "Enlightened - You are one with the eternal tide", "main_street.story_grief_none": "At Peace", "main_street.story_grief_denial": "In Denial - Loss seems unreal", "main_street.story_grief_anger": "Angry - Why did this happen?", @@ -669,6 +663,7 @@ "menu.action.help": "Help", "menu.action.quit": "Quit", "menu.action.news": "News", + "menu.action.world_events": "World Events", "menu.action.lodging": "Lodging (Dormitory)", "menu.action.auction_house": "Auctions", "menu.action.challenges": "Challenges", @@ -6139,7 +6134,7 @@ "combat.full_health": "You are already at full health!", "combat.herb_empty": "Your herb pouch is empty!", "combat.defend_stance": "You take a defensive stance!", - "combat.auto_combat_on": "Auto-combat enabled! You will automatically attack each round.", + "combat.auto_combat_on": "Auto-combat enabled! Each round you make a basic attack on a random foe, and drink a healing potion when below half HP. No spells or abilities are used.", "combat.auto_combat_off": "[AUTO-COMBAT] Stopped. You take manual control.", "combat.fatigue_dull": "(Your tiredness dulls your reflexes)", "combat.slain_ambush": "You were slain in the ambush!", @@ -10937,7 +10932,7 @@ "_comment_opening_story": "=== Opening Story System ===", "opening_story.skip_hint": "Press SPACE to skip ahead at any time", "opening_story.skip_hint_enter": "Press ENTER to skip ahead at any time", - "opening_story.the_awakening": "THE AWAKENING", + "opening_story.the_awakening": "THE DROWNING", "opening_story.letter_title": "A Letter to Yourself", "opening_story.parchment_crumbles": "The parchment crumbles to dust in your hands.", "opening_story.words_burn": "But the words burn in your mind.", @@ -10964,6 +10959,7 @@ "opening_story.dream_remember": "\"Remember...\"", "opening_story.dream_remember_what": "Remember what?", "opening_story.dream_remember_answer": "\"Remember what you are.\"", + "opening_story.dream_asleep": "Something within you is still asleep. Loss, mercy and wonder will wake it.", "opening_story.dream_fades": "The light fades. Darkness takes you.", "opening_story.dream_and_then": "And then...", "_comment_opening_story_dormitory": "--- Dormitory scene ---", @@ -13113,6 +13109,50 @@ "team.recruit_wont_join": "(won't join)", "team.recruit_page_footer": "Showing {0}-{1} of {2} | Page {3}/{4}", "team.recruit_page_nav": "[N]ext [P]rev [S]earch [F]ilter [#] Recruit [Q]uit: ", + "team.unknown": "Unknown", + "team.pick_team_row": "{0} - {1} members, average level {2}, power {3}", + "team.pick_team_turf": "(controls the town)", + "team.page_nav": "[N]ext [P]rev, Enter to return: ", + "team.pick_team_info_title": "Info on which team?", + "team.pick_team_join_title": "Join which team?", + "team.join_team_full": "{0} is full ({1} members at most).", + "team.news_formed": "{0} formed a new team: '{1}'!", + "team.news_joined": "{0} joined the team '{1}'!", + "team.news_left": "{0} left the team '{1}'!", + "team.news_recruited": "{0} recruited {1} into team '{2}'!", + "team.news_sacked": "{0} was kicked out of team '{1}'!", + "team.news_resurrected": "{0} was resurrected by their team '{1}'!", + "team.pick_examine_title": "Examine which member?", + "team.pick_sack_title": "Sack which member?", + "team.pick_gone_tag": "(gone for good)", + "team.pick_player_tag": "(player)", + "team.examine_stats3": " STA: {0} WeapPow: {1} ArmPow: {2}", + "team.password_leader_only": "Only the team's leader can change its password.", + "team.mail_push_tag": "[Mail]", + "team.sack_player_refused": "{0} is a player. A player leaves a team on their own; they cannot be sacked.", + "team.sack_gear_warning": "{0} will leave with everything they wear.", + "team.sack_take_gear_prompt": "Take their equipment first? (Y/N): ", + "team.member_gone_now": "{0} is no longer there to do that.", + "team.sack_gear_gone": "{0} is no longer on your team. No gear was taken.", + "team.pick_resurrect_row": "{0} ({1}) - cost {2} gold", + "team.confirm_resurrect": "Resurrect {0} for {1} gold? (Y/N): ", + "team.equip_no_items_for_slot": " No items available for this slot.", + "team.equip_identify_first": " Must identify the item first.", + "team.equip_item_gone": " {0} is no longer in your pack.", + "team.war_round_line": "{0} (Lv{1}) vs {2} (Lv{3}) ", + "team.war_no_rounds": "No round could be fought. The war is called off and your {0} gold wager returned.", + "team.war_no_rounds_pending": "No round could be fought. The war could not be closed just now; your {0} gold wager will be returned to you once it expires.", + "team.war_result_pending": "The result could not be recorded just now, so nothing is paid or charged yet. The war will be closed when it expires; your {0} gold wager is returned then only if none of its rounds were recorded.", + "team.war_already_closed": "This war was already closed before its result could be recorded, so nothing is paid or charged here. Any refund due arrives by bank transfer.", + "team.war_abandoned": "ABANDONED", + "team.upgrade_not_landed": "The upgrade did not go through (someone upgraded it first, or the vault fell short). Nothing was charged.", + "team.confirm_withdraw": "Withdraw {0} gold from the team vault? (Y/N): ", + "team.bbs_inventories": "Inventories", + "team.spec_role_tank": "Tank", + "team.spec_role_dps": "Damage", + "team.spec_role_healer": "Healer", + "team.spec_role_utility": "Utility", + "team.spec_role_debuff": "Debuffer", "team.recruit_nav_nofilter": "[S]earch [F]ilter [Q]uit: ", "team.recruit_search_prompt": "Search by name: ", "team.recruit_no_match": "No recruitable NPCs match '{0}'.", @@ -14643,6 +14683,7 @@ "dungeon.sanctuary_new_day": "You awaken refreshed. A new day has begun. (Day {0})", "dungeon.sanctuary_already_rested": "You've already rested on this floor.", "dungeon.sanctuary_no_benefit": "The sanctuary offers no additional benefit.", + "dungeon.rest_once_per_floor": "You can rest once per floor, at a sanctuary or by making camp. Descend to rest again.", "dungeon.vision_overtakes": "A strange vision overtakes you...", "dungeon.vision_layout": "You see the layout of this floor!", "dungeon.rooms_revealed": "All rooms are now revealed on your map!", @@ -14862,7 +14903,7 @@ "path.gate_old_god": "an Old God awaits", "path.gate_seal": "an Ancient Seal", "path.gate_line": "Floor {0}: {1}", - "base.training_points_hint": " You earned training points! Visit the Level Master to spend them.", + "base.training_points_hint": " You earned training points! Visit the Level Master ([V] on Main Street) to spend them.", "base.system_message": "*** SYSTEM MESSAGE ***", "base.world_boss_rampaging": " *** {0} is rampaging across the realm! Type /boss to join the fight! ***", "base.poison_damage_turns": "The poison courses through your veins! (-{0} HP, {1} turns left)", @@ -15118,6 +15159,11 @@ "base.talking_to": "Talking to: {0}", "base.female": "Female", "base.male": "Male", + "base.pick_nav": "[#] Choose, or type a name (Enter to cancel): ", + "base.pick_nav_pages": "[N]ext [P]rev [#] Choose, or type a name (Enter to cancel): ", + "base.pick_matching": "Matching \"{0}\":", + "base.pick_no_match": "Nothing on the list matches \"{0}\".", + "base.pick_no_number": "There is no number {0}; choose 1 to {1}.", "base.npc_says": " {0} says:", "base.chat_with_them": " Chat with them", "base.ask_rumors": " Ask about rumors", @@ -15224,6 +15270,102 @@ "base.awakening_enlightened": "Enlightened", "base.awakening_awakened": "Awakened", "base.stat_awakening": " Awakening: {0} ({1}/7)", + "base.stat_awakening_hint": "(your Ocean Journal: [P] Progress on Main Street)", + "ocean.awakening_header": "AWAKENING", + "ocean.awakening_stage": "Stage {0} of {1}: {2}", + "ocean.awakening_gained": "You gain: {0}", + "ocean.awakening_opens": "Now open: {0}", + "ocean.boost.1": "+{0} Wisdom", + "ocean.boost.2": "+{0}% maximum mana", + "ocean.boost.3": "+{0}% experience from combat", + "ocean.boost.4": "+{0}% maximum hit points", + "ocean.boost.5": "+{0}% damage dealt", + "ocean.boost.6": "{0}% less damage taken", + "ocean.boost.7": "each percentage boon rises by {0} more, and you are called the Awakened", + "ocean.title_awakened": "the Awakened", + "ocean.title_awakened_line": "Title: the Awakened", + "ocean.journal_header": "OCEAN JOURNAL", + "ocean.journal_stage": "Awakening: stage {0}/{1}, {2}", + "ocean.journal_points": "Awakening points: {0}. The next stage at {1}.", + "ocean.journal_points_last": "Awakening points: {0}. The last stage waits for all seven seals, or the truth of who you are.", + "ocean.journal_awake": "You are awake. The dream goes on, and you know you are dreaming.", + "ocean.journal_fragments": "Wave Fragments found: {0}/{1}", + "ocean.journal_moments": "Moments lived: {0}", + "ocean.journal_boons": "Boons: {0}", + "ocean.journal_open_key": "[J] below opens the Journal: the fragments' lore and the moments lived.", + "ocean.journal_prompt": "[J] Ocean Journal, any other key to return:", + "ocean.journal_hidden": "({0} fragments remain hidden...)", + "ocean.journal_no_moments": "None yet.", + "ocean.fragment.Origin.title": "The Origin", + "ocean.fragment.Origin.text": "In the beginning, there was only the Ocean - vast, eternal, undivided. It knew itself completely, for there was nothing else to know. But complete knowledge became complete loneliness.", + "ocean.fragment.FirstSeparation.title": "The First Separation", + "ocean.fragment.FirstSeparation.text": "And so the Ocean dreamed of waves. Each wave rose, believing itself separate and special. Each wave fell, returning to what it always was. The Ocean learned to love through loss.", + "ocean.fragment.TheForgetting.title": "The Forgetting", + "ocean.fragment.TheForgetting.text": "For the dream to feel real, the waves had to forget. True separation requires true belief in separation. And so the Ocean hid itself from itself.", + "ocean.fragment.ManwesChoice.title": "Manwe's Choice", + "ocean.fragment.ManwesChoice.text": "The first and greatest wave chose the deepest forgetting. He became Manwe, Creator of the Seven, Lord of Gods. He forgot he was still water. He believed himself stone.", + "ocean.fragment.TheSevenDrops.title": "The Seven Drops", + "ocean.fragment.TheSevenDrops.text": "From Manwe's forgetting sprang seven more waves - the Old Gods. Veloura for love, Thorgrim for law, Noctura for mystery... Each a facet of the Ocean's yearning to understand itself.", + "ocean.fragment.TheCorruption.title": "The Corruption", + "ocean.fragment.TheCorruption.text": "But belief in separation became pain. The waves fought each other, having forgotten they shared the same source. War is water fighting water. Cruelty is the ocean hurting itself.", + "ocean.fragment.TheCycle.title": "The Eternal Cycle", + "ocean.fragment.TheCycle.text": "Manwe grew weary of watching waves destroy waves. In desperation, he sent a fragment of himself to learn what he had forgotten. That fragment wakes in a dormitory, remembering nothing...", + "ocean.fragment.TheReturn.title": "The Return", + "ocean.fragment.TheReturn.text": "Death is not an ending. It is a wave becoming water again. The individual dissolves, but the essence returns to the source. We do not lose what we love - we become it.", + "ocean.fragment.TheTruth.title": "The Final Truth", + "ocean.fragment.TheTruth.text": "You are not a wave fighting the ocean. You ARE the ocean, dreaming of being a wave. When you wake, you will not disappear - you will become everything you ever loved.", + "ocean.fragment.TheChoice.title": "The Choice", + "ocean.fragment.TheChoice.text": "The dreamer can choose: continue the dream, or wake. Waking is not death - it is the end of loneliness. But the dream has its own beauty, its own purpose...", + "ocean.moment.FirstCompanionDeath": "You lost a companion.", + "ocean.moment.SacrificedForAnother": "A companion joined your road.", + "ocean.moment.SparedAnEnemy": "You spared a beaten foe.", + "ocean.moment.MetManwe": "You met the Creator.", + "ocean.moment.AllSealsCollected": "You gathered all seven seals.", + "ocean.moment.MemoriesRecovered": "Your memories returned.", + "ocean.moment.TrueIdentityRevealed": "You remembered who you are.", + "ocean.moment.LetGoOfPower": "You let go of power.", + "ocean.moment.AcceptedDeath": "You faced death without fear.", + "ocean.moment.CompanionSacrifice": "A companion gave their life for you.", + "ocean.moment.ForgaveBetrayerMercy": "You forgave one who betrayed you.", + "ocean.moment.AcceptedGrief": "Your grief became acceptance.", + "ocean.moment.RejectedParadise": "You refused a false paradise.", + "ocean.moment.AbsorbedDarkness": "You took the world's darkness into yourself.", + "ocean.moment.HeardOldGodLoreSong": "You heard a song of the Old Gods.", + "ocean.stage.1.lore.1": "Something in you stirs, like water under ice.", + "ocean.stage.1.lore.2": "The world looked solid. Now, at the edges, it moves.", + "ocean.stage.1.lore.3": "Loss, mercy or wonder touched something older than you.", + "ocean.stage.1.lore.4": "You do not know yet what is waking.", + "ocean.stage.1.opens": "New dreams can find you when you rest.", + "ocean.stage.2.lore.1": "A wave rises, and believes it is alone.", + "ocean.stage.2.lore.2": "But every wave is lifted by the same sea.", + "ocean.stage.2.lore.3": "Those you fight and those you love are made of the same water.", + "ocean.stage.2.lore.4": "You are beginning to feel the current.", + "ocean.stage.2.opens": "Ocean's Touch, the sixth enchantment at the Magic Shop, and the first dream of the Ocean.", + "ocean.stage.3.lore.1": "Separation was the first dream.", + "ocean.stage.3.lore.2": "The Ocean dreamed of waves so that it could know itself.", + "ocean.stage.3.lore.3": "To feel separate, the waves had to forget.", + "ocean.stage.3.lore.4": "You have forgotten something too.", + "ocean.stage.3.opens": "Deeper reflection after hard choices, new dreams, and the vision of the mirror room.", + "ocean.stage.4.lore.1": "The Old Gods are not strangers.", + "ocean.stage.4.lore.2": "They are drops of the same water, broken by a long forgetting.", + "ocean.stage.4.lore.3": "Their wars are the ocean fighting itself.", + "ocean.stage.4.lore.4": "When you face them, you face family.", + "ocean.stage.4.opens": "The dream of creation, the ocean riddles at the riddle gates, and new visions.", + "ocean.stage.5.lore.1": "Death is not an ending. It is a wave becoming water again.", + "ocean.stage.5.lore.2": "What you have lost has not gone. It has returned.", + "ocean.stage.5.lore.3": "Grasping turns the wave to foam.", + "ocean.stage.5.lore.4": "Open hands hold the sea.", + "ocean.stage.5.opens": "The Creator's dream, the sound of the ocean in the deepest visions, and the last fragments of truth.", + "ocean.stage.6.lore.1": "Someone dreamed this world, and grew weary of the dream.", + "ocean.stage.6.lore.2": "The first and greatest wave forgot he was water, and believed himself stone.", + "ocean.stage.6.lore.3": "He sent a part of himself to remember.", + "ocean.stage.6.lore.4": "It woke in a dormitory, remembering nothing. It is remembering now.", + "ocean.stage.6.opens": "The last stage waits beyond the seals, or in the truth of who you are.", + "ocean.stage.7.lore.1": "You are not a wave fighting the ocean.", + "ocean.stage.7.lore.2": "You ARE the ocean, dreaming of being a wave.", + "ocean.stage.7.lore.3": "You are Manwe, the dreamer who forgot, come home to yourself.", + "ocean.stage.7.lore.4": "The dream goes on. But now you know you are dreaming.", + "ocean.stage.7.opens": "The true ending is open to you.", "base.stat_gold_hand": "Gold on Hand: ", "base.stat_gold_bank": "Gold in Bank: ", "base.stat_total_wealth": "Total Wealth: ", @@ -15784,7 +15926,7 @@ "dungeon.seal_hidden_town": "Hidden in the dungeon depths", "dungeon.seal_hidden_dungeon": "Hidden in the dungeon depths", "dungeon.seal_not_collected_sr": " Not collected: {0} - {1}", - "dungeon.chapter_awakening": "The Awakening - A stranger in Dorashire", + "dungeon.chapter_awakening": "The Drowning - A stranger in Dorashire", "dungeon.chapter_first_blood": "First Blood - Learning to fight", "dungeon.chapter_stranger": "The Stranger - Meeting the mysterious guide", "dungeon.chapter_faction": "Faction Choice - Choosing your allegiance", @@ -16359,7 +16501,8 @@ "inventory.cant_equip_unidentified": "You can't equip an unidentified item.", "inventory.visit_magic_shop": "Take it to the Magic Shop to have it identified first.", "inventory.cursed_cant_drop": "The {0} is CURSED! You cannot drop it.", - "inventory.visit_healer_curse": "Visit the Healer to have the curse removed.", + "inventory.visit_healer_curse": "Visit the Magic Shop to have the curse removed.", + "inventory.cursed_equip_warning": "This item is CURSED. Once equipped, it cannot be removed until the curse is lifted.", "inventory.dropped_item": "You drop the {0}.", "inventory.cant_equip": "This item cannot be equipped.", "inventory.which_finger": "Equip to which finger?", @@ -16713,13 +16856,6 @@ "main_street.align_questionable": "Questionable", "main_street.align_usurper": "Usurper - Embodiment of Darkness", "main_street.align_villain": "Villain", - "main_street.awakening_0": "Unawakened - You see only the surface of things", - "main_street.awakening_1": "Stirring - Something deep within begins to move", - "main_street.awakening_2": "Ripples - You sense connections between all things", - "main_street.awakening_3": "Currents - The depths call to you with ancient whispers", - "main_street.awakening_4": "Depths - You understand the ocean's sorrow", - "main_street.awakening_5": "Enlightened - You are one with the eternal tide", - "main_street.awakening_level": "Awakening Level: {0}/5", "main_street.awakening_unknown": "Unknown", "main_street.chivalry_label": "Chivalry:", "main_street.dark_clean": "Clean record", @@ -19869,6 +20005,7 @@ "stats.cha": "CHA", "stats.agi": "AGI", "stats.def": "DEF", + "stats.sta": "STA", "dungeon.full": "Full", "dungeon.follower_drink_potion": " You drink a healing potion and recover {0:N0} HP!", "base.royal_guards_surround": "Royal guards surround you!", @@ -20954,6 +21091,10 @@ "hint.main_street_nav.msg": "Press the highlighted letter key to select an option. For example, press 'I' for the Inn.", "hint.first_dungeon.title": "Dungeon Tip", "hint.first_dungeon.msg": "Explore rooms using N/S/E/W (North/South/East/West). Fight monsters to gain XP and gold.", + "hint.lives.title": "Your Lives", + "hint.lives.msg": "You have {0} of {1} lives. Each death uses one, and you are revived with half your HP. With no lives left, your next death erases the character for good. You regain a life at every 10th level, or through the Temple's Rite of Return, up to {1}.", + "hint.awakening.title": "Your Awakening", + "hint.awakening.msg": "Something within you is waking. Loss, mercy and wonder deepen it: companions, sparing a beaten foe, dreams, visions, discoveries and the songs of the Old Gods. Each stage brings a small lasting boon. See [P] Progress on Main Street for your Ocean Journal.", "hint.first_combat.title": "Combat Tip", "hint.first_combat.msg": "Press [A] to Attack, [R] to Retreat, [H] to use a Healing Potion, or [S] to check your Status.", "hint.low_hp.title": "Health Warning", diff --git a/Localization/es.json b/Localization/es.json index d9f7cf3d..d492d54d 100644 --- a/Localization/es.json +++ b/Localization/es.json @@ -222,6 +222,7 @@ "main_street.menu_news_suffix": " Noticias ", "main_street.menu_fame_suffix": " Fama ", "main_street.menu_explore_suffix": " Explorar ", + "main_street.menu_events_suffix": " Eventos", "main_street.menu_status_suffix": " Estado", "main_street.menu_quit_suffix": " Salir ", "main_street.menu_settings": "Ajustes", @@ -483,13 +484,6 @@ "main_street.story_god_known": "Encontrado", "main_street.story_god_hidden": "Oculto", "main_street.story_ocean_desc": "Tu despertar espiritual a las verdades ocultas del mundo.", - "main_street.story_awakening_level": "Nivel de Despertar: {0}", - "main_street.story_awakening_0": "No Despierto - Solo ves la superficie de las cosas", - "main_street.story_awakening_1": "Agitación - Algo profundo dentro comienza a moverse", - "main_street.story_awakening_2": "Ondas - Sientes conexiones entre todas las cosas", - "main_street.story_awakening_3": "Corrientes - Las profundidades te llaman con susurros antiguos", - "main_street.story_awakening_4": "Profundidades - Comprendes el dolor del oceano", - "main_street.story_awakening_5": "Iluminado - Eres uno con la marea eterna", "main_street.story_grief_none": "Sin Duelo Activo", "main_street.story_grief_denial": "Negación", "main_street.story_grief_anger": "Ira", @@ -669,6 +663,7 @@ "menu.action.help": "Ayuda", "menu.action.quit": "Salir", "menu.action.news": "Noticias", + "menu.action.world_events": "Eventos", "menu.action.lodging": "Alojamiento (Dormitorio)", "menu.action.auction_house": "Subastas", "menu.action.challenges": "Desafíos", @@ -5948,7 +5943,7 @@ "combat.full_health": "¡Ya estás a salud completa!", "combat.herb_empty": "¡Tu bolsa de hierbas está vacía!", "combat.defend_stance": "¡Adoptas una postura defensiva!", - "combat.auto_combat_on": "¡Combate automático activado! Atacarás automáticamente cada ronda.", + "combat.auto_combat_on": "¡Combate automático activado! Cada ronda haces un ataque básico contra un enemigo al azar y bebes una poción de curación si tienes menos de la mitad de PV. No se usan hechizos ni habilidades.", "combat.auto_combat_off": "[AUTO-COMBATE] Detenido. Tomas el control manual.", "combat.fatigue_dull": "(Tu cansancio embota tus reflejos)", "combat.slain_ambush": "¡Fuiste abatido en la emboscada!", @@ -10752,7 +10747,7 @@ "_comment_opening_story": "=== Sistema de Historia Inicial ===", "opening_story.skip_hint": "Presiona ESPACIO para avanzar en cualquier momento", "opening_story.skip_hint_enter": "Presiona ENTER para avanzar en cualquier momento", - "opening_story.the_awakening": "EL DESPERTAR", + "opening_story.the_awakening": "EL AHOGAMIENTO", "opening_story.letter_title": "Una Carta para Ti Mismo", "opening_story.parchment_crumbles": "El pergamino se desmorona en tus manos.", "opening_story.words_burn": "Pero las palabras arden en tu mente.", @@ -10779,6 +10774,7 @@ "opening_story.dream_remember": "\"Recuerda...\"", "opening_story.dream_remember_what": "¿Recordar qué?", "opening_story.dream_remember_answer": "\"Recuerda lo que eres.\"", + "opening_story.dream_asleep": "Algo dentro de ti sigue dormido. La pérdida, la piedad y el asombro lo despertarán.", "opening_story.dream_fades": "La luz se desvanece. La oscuridad te envuelve.", "opening_story.dream_and_then": "Y entonces...", "_comment_opening_story_dormitory": "--- Dormitory scene ---", @@ -12928,6 +12924,50 @@ "team.recruit_wont_join": "(no se une)", "team.recruit_page_footer": "Mostrando {0}-{1} de {2} | Página {3}/{4}", "team.recruit_page_nav": "[N]Sig [P]Ant [S]Buscar [F]Filtro [#] Reclutar [Q]Salir: ", + "team.unknown": "Desconocido", + "team.pick_team_row": "{0} - {1} miembros, nivel medio {2}, poder {3}", + "team.pick_team_turf": "(controla la ciudad)", + "team.page_nav": "[N] Siguiente [P] Anterior, Enter para volver: ", + "team.pick_team_info_title": "¿Información de qué equipo?", + "team.pick_team_join_title": "¿A qué equipo quieres unirte?", + "team.join_team_full": "{0} está lleno (máximo {1} miembros).", + "team.news_formed": "¡{0} formó un nuevo equipo: '{1}'!", + "team.news_joined": "¡{0} se unió al equipo '{1}'!", + "team.news_left": "¡{0} dejó el equipo '{1}'!", + "team.news_recruited": "¡{0} reclutó a {1} para el equipo '{2}'!", + "team.news_sacked": "¡{0} fue expulsado del equipo '{1}'!", + "team.news_resurrected": "¡{0} fue resucitado por su equipo '{1}'!", + "team.pick_examine_title": "¿Examinar a qué miembro?", + "team.pick_sack_title": "¿Expulsar a qué miembro?", + "team.pick_gone_tag": "(perdido para siempre)", + "team.pick_player_tag": "(jugador)", + "team.examine_stats3": " RES: {0} PodArma: {1} PodArmad: {2}", + "team.password_leader_only": "Solo el líder del equipo puede cambiar su contraseña.", + "team.mail_push_tag": "[Correo]", + "team.sack_player_refused": "{0} es un jugador. Un jugador deja el equipo por su cuenta; no se le puede expulsar.", + "team.sack_gear_warning": "{0} se irá con todo lo que lleva puesto.", + "team.sack_take_gear_prompt": "¿Quitarle primero su equipo? (S/N): ", + "team.member_gone_now": "{0} ya no está para eso.", + "team.sack_gear_gone": "{0} ya no está en tu equipo. No se ha tomado ningún equipo.", + "team.pick_resurrect_row": "{0} ({1}) - cuesta {2} de oro", + "team.confirm_resurrect": "¿Resucitar a {0} por {1} de oro? (S/N): ", + "team.equip_no_items_for_slot": " No hay objetos disponibles para esta ranura.", + "team.equip_identify_first": " Primero hay que identificar el objeto.", + "team.equip_item_gone": " {0} ya no está en tu mochila.", + "team.war_round_line": "{0} (Nv{1}) contra {2} (Nv{3}) ", + "team.war_no_rounds": "No se pudo librar ningún asalto. La guerra se cancela y se te devuelve la apuesta de {0} de oro.", + "team.war_no_rounds_pending": "No se pudo librar ningún asalto. La guerra no se pudo cerrar ahora; tu apuesta de {0} de oro se te devolverá cuando expire.", + "team.war_result_pending": "El resultado no se pudo registrar ahora, así que todavía no se paga ni se cobra nada. La guerra se cerrará cuando expire; tu apuesta de {0} de oro solo se te devolverá si no se registró ninguno de sus asaltos.", + "team.war_already_closed": "Esta guerra ya se había cerrado antes de que se pudiera registrar su resultado, así que aquí no se paga ni se cobra nada. Cualquier reembolso debido llega por transferencia bancaria.", + "team.war_abandoned": "ABANDONADA", + "team.upgrade_not_landed": "La mejora no se realizó (alguien la mejoró antes, o faltó oro en la cámara). No se cobró nada.", + "team.confirm_withdraw": "¿Retirar {0} de oro de la cámara del equipo? (S/N): ", + "team.bbs_inventories": "Inventarios", + "team.spec_role_tank": "Tanque", + "team.spec_role_dps": "Daño", + "team.spec_role_healer": "Sanador", + "team.spec_role_utility": "Apoyo", + "team.spec_role_debuff": "Debilitador", "team.recruit_nav_nofilter": "[S]Buscar [F]Filtro [Q]Salir: ", "team.recruit_search_prompt": "Buscar por nombre: ", "team.recruit_no_match": "Ningún NPC reclutable coincide con '{0}'.", @@ -14458,6 +14498,7 @@ "dungeon.sanctuary_new_day": "Despiertas renovado. Un nuevo día ha comenzado. (Día {0})", "dungeon.sanctuary_already_rested": "Ya descansaste en este piso.", "dungeon.sanctuary_no_benefit": "El santuario no ofrece beneficio adicional.", + "dungeon.rest_once_per_floor": "Solo puedes descansar una vez por piso, en un santuario o acampando. Desciende para volver a descansar.", "dungeon.vision_overtakes": "Una extraña visión te invade...", "dungeon.vision_layout": "¡Ves el diseño de este piso!", "dungeon.rooms_revealed": "¡Todas las habitaciones están reveladas en tu mapa!", @@ -14677,7 +14718,7 @@ "path.gate_old_god": "un Dios Antiguo aguarda", "path.gate_seal": "un Sello Antiguo", "path.gate_line": "Piso {0}: {1}", - "base.training_points_hint": " Ganaste puntos de entrenamiento! Visita al Maestro de Nivel para gastarlos.", + "base.training_points_hint": " ¡Ganaste puntos de entrenamiento! Visita al Maestro de Nivel ([V] en la Calle Principal) para gastarlos.", "base.system_message": "*** MENSAJE DEL SISTEMA ***", "base.world_boss_rampaging": " *** {0} está arrasando el reino! Escribe /boss para unirte a la pelea! ***", "base.poison_damage_turns": "El veneno recorre tus venas! (-{0} HP, {1} turnos restantes)", @@ -14933,6 +14974,11 @@ "base.talking_to": "Hablando con: {0}", "base.female": "Femenino", "base.male": "Masculino", + "base.pick_nav": "[#] Elige, o escribe un nombre (Enter para cancelar): ", + "base.pick_nav_pages": "[N] Siguiente [P] Anterior [#] Elige, o escribe un nombre (Enter para cancelar): ", + "base.pick_matching": "Coincidencias con \"{0}\":", + "base.pick_no_match": "Nada en la lista coincide con \"{0}\".", + "base.pick_no_number": "No hay número {0}; elige de 1 a {1}.", "base.npc_says": " {0} dice:", "base.chat_with_them": " Charlar con ellos", "base.ask_rumors": " Preguntar sobre rumores", @@ -15039,6 +15085,102 @@ "base.awakening_enlightened": "Iluminado", "base.awakening_awakened": "Despierto", "base.stat_awakening": " Despertar: {0} ({1}/7)", + "base.stat_awakening_hint": "(tu Diario del Océano: [P] Progreso en la Calle Mayor)", + "ocean.awakening_header": "DESPERTAR", + "ocean.awakening_stage": "Etapa {0} de {1}: {2}", + "ocean.awakening_gained": "Obtienes: {0}", + "ocean.awakening_opens": "Ahora abierto: {0}", + "ocean.boost.1": "+{0} Sabiduría", + "ocean.boost.2": "+{0}% de maná máximo", + "ocean.boost.3": "+{0}% de experiencia en combate", + "ocean.boost.4": "+{0}% de puntos de vida máximos", + "ocean.boost.5": "+{0}% de daño infligido", + "ocean.boost.6": "{0}% menos daño recibido", + "ocean.boost.7": "cada don en porcentaje sube {0} más, y te llaman el Despierto", + "ocean.title_awakened": "el Despierto", + "ocean.title_awakened_line": "Título: el Despierto", + "ocean.journal_header": "DIARIO DEL OCÉANO", + "ocean.journal_stage": "Despertar: etapa {0}/{1}, {2}", + "ocean.journal_points": "Puntos de despertar: {0}. La siguiente etapa a los {1}.", + "ocean.journal_points_last": "Puntos de despertar: {0}. La última etapa espera los siete sellos, o la verdad de quién eres.", + "ocean.journal_awake": "Estás despierto. El sueño continúa, y sabes que sueñas.", + "ocean.journal_fragments": "Fragmentos de Ola hallados: {0}/{1}", + "ocean.journal_moments": "Momentos vividos: {0}", + "ocean.journal_boons": "Dones: {0}", + "ocean.journal_open_key": "[J] abajo abre el Diario: la historia de los fragmentos y los momentos vividos.", + "ocean.journal_prompt": "[J] Diario del Océano, cualquier otra tecla para volver:", + "ocean.journal_hidden": "({0} fragmentos siguen ocultos...)", + "ocean.journal_no_moments": "Ninguno todavía.", + "ocean.fragment.Origin.title": "El Origen", + "ocean.fragment.Origin.text": "Al principio solo existía el Océano: vasto, eterno, indiviso. Se conocía por completo, pues no había nada más que conocer. Pero el conocimiento completo se volvió soledad completa.", + "ocean.fragment.FirstSeparation.title": "La Primera Separación", + "ocean.fragment.FirstSeparation.text": "Y así el Océano soñó olas. Cada ola se alzaba creyéndose separada y especial. Cada ola caía, volviendo a lo que siempre fue. El Océano aprendió a amar a través de la pérdida.", + "ocean.fragment.TheForgetting.title": "El Olvido", + "ocean.fragment.TheForgetting.text": "Para que el sueño pareciera real, las olas tuvieron que olvidar. La verdadera separación exige creer de verdad en ella. Y así el Océano se ocultó de sí mismo.", + "ocean.fragment.ManwesChoice.title": "La Elección de Manwe", + "ocean.fragment.ManwesChoice.text": "La primera y mayor ola eligió el olvido más profundo. Se convirtió en Manwe, Creador de los Siete, Señor de los Dioses. Olvidó que seguía siendo agua. Se creyó piedra.", + "ocean.fragment.TheSevenDrops.title": "Las Siete Gotas", + "ocean.fragment.TheSevenDrops.text": "Del olvido de Manwe brotaron siete olas más: los Dioses Antiguos. Veloura para el amor, Thorgrim para la ley, Noctura para el misterio... Cada una, una faceta del anhelo del Océano por comprenderse.", + "ocean.fragment.TheCorruption.title": "La Corrupción", + "ocean.fragment.TheCorruption.text": "Pero la fe en la separación se volvió dolor. Las olas lucharon entre sí, olvidando que compartían la misma fuente. La guerra es agua luchando contra agua. La crueldad es el océano hiriéndose a sí mismo.", + "ocean.fragment.TheCycle.title": "El Ciclo Eterno", + "ocean.fragment.TheCycle.text": "Manwe se cansó de ver olas destruir olas. Desesperado, envió un fragmento de sí mismo para aprender lo que había olvidado. Ese fragmento despierta en un dormitorio, sin recordar nada...", + "ocean.fragment.TheReturn.title": "El Regreso", + "ocean.fragment.TheReturn.text": "La muerte no es un final. Es una ola que vuelve a ser agua. El individuo se disuelve, pero la esencia regresa a la fuente. No perdemos lo que amamos: nos convertimos en ello.", + "ocean.fragment.TheTruth.title": "La Verdad Final", + "ocean.fragment.TheTruth.text": "No eres una ola que lucha contra el océano. ERES el océano, soñando que es una ola. Cuando despiertes, no desaparecerás: te convertirás en todo lo que amaste.", + "ocean.fragment.TheChoice.title": "La Elección", + "ocean.fragment.TheChoice.text": "El soñador puede elegir: seguir soñando o despertar. Despertar no es morir: es el fin de la soledad. Pero el sueño tiene su propia belleza, su propio propósito...", + "ocean.moment.FirstCompanionDeath": "Perdiste a un compañero.", + "ocean.moment.SacrificedForAnother": "Un compañero se unió a tu camino.", + "ocean.moment.SparedAnEnemy": "Perdonaste a un enemigo vencido.", + "ocean.moment.MetManwe": "Conociste al Creador.", + "ocean.moment.AllSealsCollected": "Reuniste los siete sellos.", + "ocean.moment.MemoriesRecovered": "Tus recuerdos regresaron.", + "ocean.moment.TrueIdentityRevealed": "Recordaste quién eres.", + "ocean.moment.LetGoOfPower": "Renunciaste al poder.", + "ocean.moment.AcceptedDeath": "Afrontaste la muerte sin miedo.", + "ocean.moment.CompanionSacrifice": "Un compañero dio su vida por ti.", + "ocean.moment.ForgaveBetrayerMercy": "Perdonaste a quien te traicionó.", + "ocean.moment.AcceptedGrief": "Tu duelo se volvió aceptación.", + "ocean.moment.RejectedParadise": "Rechazaste un falso paraíso.", + "ocean.moment.AbsorbedDarkness": "Tomaste en ti la oscuridad del mundo.", + "ocean.moment.HeardOldGodLoreSong": "Oíste una canción de los Dioses Antiguos.", + "ocean.stage.1.lore.1": "Algo en ti se agita, como agua bajo el hielo.", + "ocean.stage.1.lore.2": "El mundo parecía sólido. Ahora, en sus bordes, se mueve.", + "ocean.stage.1.lore.3": "La pérdida, la piedad o el asombro tocaron algo más antiguo que tú.", + "ocean.stage.1.lore.4": "Aún no sabes qué está despertando.", + "ocean.stage.1.opens": "Nuevos sueños pueden encontrarte cuando descansas.", + "ocean.stage.2.lore.1": "Una ola se alza y cree que está sola.", + "ocean.stage.2.lore.2": "Pero cada ola la alza el mismo mar.", + "ocean.stage.2.lore.3": "Aquellos contra quienes luchas y a quienes amas están hechos de la misma agua.", + "ocean.stage.2.lore.4": "Empiezas a sentir la corriente.", + "ocean.stage.2.opens": "El Toque del Océano, el sexto encantamiento de la Tienda de Magia, y el primer sueño del Océano.", + "ocean.stage.3.lore.1": "La separación fue el primer sueño.", + "ocean.stage.3.lore.2": "El Océano soñó olas para poder conocerse.", + "ocean.stage.3.lore.3": "Para sentirse separadas, las olas tuvieron que olvidar.", + "ocean.stage.3.lore.4": "Tú también has olvidado algo.", + "ocean.stage.3.opens": "Una reflexión más honda tras las decisiones difíciles, nuevos sueños y la visión de la sala de espejos.", + "ocean.stage.4.lore.1": "Los Dioses Antiguos no son extraños.", + "ocean.stage.4.lore.2": "Son gotas de la misma agua, rotas por un largo olvido.", + "ocean.stage.4.lore.3": "Sus guerras son el océano luchando contra sí mismo.", + "ocean.stage.4.lore.4": "Cuando los enfrentas, enfrentas a tu familia.", + "ocean.stage.4.opens": "El sueño de la creación, los acertijos del océano en las puertas de acertijos y nuevas visiones.", + "ocean.stage.5.lore.1": "La muerte no es un final. Es una ola que vuelve a ser agua.", + "ocean.stage.5.lore.2": "Lo que has perdido no se ha ido. Ha regresado.", + "ocean.stage.5.lore.3": "Aferrarse convierte la ola en espuma.", + "ocean.stage.5.lore.4": "Las manos abiertas sostienen el mar.", + "ocean.stage.5.opens": "El sueño del Creador, el sonido del océano en las visiones más profundas y los últimos fragmentos de verdad.", + "ocean.stage.6.lore.1": "Alguien soñó este mundo y se cansó del sueño.", + "ocean.stage.6.lore.2": "La primera y mayor ola olvidó que era agua y se creyó piedra.", + "ocean.stage.6.lore.3": "Envió una parte de sí mismo para recordar.", + "ocean.stage.6.lore.4": "Despertó en un dormitorio sin recordar nada. Ahora está recordando.", + "ocean.stage.6.opens": "La última etapa espera más allá de los sellos, o en la verdad de quién eres.", + "ocean.stage.7.lore.1": "No eres una ola que lucha contra el océano.", + "ocean.stage.7.lore.2": "ERES el océano, soñando que es una ola.", + "ocean.stage.7.lore.3": "Eres Manwe, el soñador que olvidó, de vuelta en ti mismo.", + "ocean.stage.7.lore.4": "El sueño continúa. Pero ahora sabes que sueñas.", + "ocean.stage.7.opens": "El verdadero final está abierto para ti.", "base.stat_gold_hand": "Oro en Mano: ", "base.stat_gold_bank": "Oro en Banco: ", "base.stat_total_wealth": "Riqueza Total: ", @@ -15595,7 +15737,7 @@ "dungeon.seal_hidden_town": "Oculto en las profundidades de la mazmorra", "dungeon.seal_hidden_dungeon": "Oculto en las profundidades de la mazmorra", "dungeon.seal_not_collected_sr": " No recolectado: {0} - {1}", - "dungeon.chapter_awakening": "El Despertar - Un extraño en Dorashire", + "dungeon.chapter_awakening": "El Ahogamiento - Un extraño en Dorashire", "dungeon.chapter_first_blood": "Primera Sangre - Aprendiendo a luchar", "dungeon.chapter_stranger": "El Extraño - Conociendo al guia misterioso", "dungeon.chapter_faction": "Elección de Facción - Eligiendo tu lealtad", @@ -16173,7 +16315,8 @@ "inventory.cant_equip_unidentified": "No puedes equipar un objeto sin identificar.", "inventory.visit_magic_shop": "Llevalo a la Tienda Mágica para identificarlo primero.", "inventory.cursed_cant_drop": "El {0} está MALDITO! No puedes soltarlo.", - "inventory.visit_healer_curse": "Visita al Sanador para remover la maldición.", + "inventory.visit_healer_curse": "Visita la Tienda de Magia para remover la maldición.", + "inventory.cursed_equip_warning": "Este objeto está MALDITO. Una vez equipado, no podrás quitártelo hasta que se levante la maldición.", "inventory.dropped_item": "Sueltas el {0}.", "inventory.cant_equip": "Este objeto no se puede equipar.", "inventory.which_finger": "Equipar en cual dedo?", @@ -16527,13 +16670,6 @@ "main_street.align_questionable": "Cuestionable", "main_street.align_usurper": "Usurpador - Encarnación de la Oscuridad", "main_street.align_villain": "Villano", - "main_street.awakening_0": "No Despierto - Solo ves la superficie de las cosas", - "main_street.awakening_1": "Agitación - Algo profundo dentro comienza a moverse", - "main_street.awakening_2": "Ondas - Sientes conexiones entre todas las cosas", - "main_street.awakening_3": "Corrientes - Las profundidades te llaman con susurros antiguos", - "main_street.awakening_4": "Profundidades - Comprendes el dolor del oceano", - "main_street.awakening_5": "Iluminado - Eres uno con la marea eterna", - "main_street.awakening_level": "Nivel de Despertar: {0}/5", "main_street.awakening_unknown": "Desconocido", "main_street.chivalry_label": "Caballeria:", "main_street.dark_clean": "Historial limpio", @@ -19390,6 +19526,7 @@ "stats.cha": "CAR", "stats.agi": "AGI", "stats.def": "DEF", + "stats.sta": "RES", "dungeon.full": "Lleno", "dungeon.follower_drink_potion": " Bebes una pocion de curacion y recuperas {0:N0} PV!", "base.royal_guards_surround": "Los guardias reales te rodean!", @@ -20522,6 +20659,10 @@ "hint.main_street_nav.msg": "Pulsa la tecla de letra resaltada para elegir una opción. Por ejemplo, pulsa 'I' para la Posada.", "hint.first_dungeon.title": "Consejo de mazmorra", "hint.first_dungeon.msg": "Explora las salas con N/S/E/W (Norte/Sur/Este/Oeste). Lucha contra monstruos para ganar XP y oro.", + "hint.lives.title": "Tus vidas", + "hint.lives.msg": "Tienes {0} de {1} vidas. Cada muerte consume una y revives con la mitad de tus PV. Sin vidas, tu siguiente muerte borra al personaje para siempre. Recuperas una vida cada 10 niveles, o con el Rito del Retorno del Templo, hasta {1}.", + "hint.awakening.title": "Tu Despertar", + "hint.awakening.msg": "Algo dentro de ti está despertando. La pérdida, la piedad y el asombro lo profundizan: compañeros, perdonar a un enemigo vencido, sueños, visiones, descubrimientos y las canciones de los Dioses Antiguos. Cada etapa trae un pequeño don duradero. Mira [P] Progreso en la Calle Mayor para tu Diario del Océano.", "hint.first_combat.title": "Consejo de combate", "hint.first_combat.msg": "Pulsa [A] para Atacar, [R] para Retirarte, [H] para usar una Poción de Curación, o [S] para ver tu Estado.", "hint.low_hp.title": "Aviso de salud", diff --git a/Localization/fr.json b/Localization/fr.json index 122208a1..4cec54e6 100644 --- a/Localization/fr.json +++ b/Localization/fr.json @@ -222,6 +222,7 @@ "main_street.menu_news_suffix": " Nouvelles ", "main_street.menu_fame_suffix": " Renommée ", "main_street.menu_explore_suffix": " Explorer ", + "main_street.menu_events_suffix": " Événements", "main_street.menu_status_suffix": " Statut", "main_street.menu_quit_suffix": " Quitter ", "main_street.menu_settings": "Paramètres", @@ -483,13 +484,6 @@ "main_street.story_god_known": "Connu", "main_street.story_god_hidden": "Quelque part dans les profondeurs...", "main_street.story_ocean_desc": " Votre éveil spirituel à travers la peine, le sacrifice et la compréhension", - "main_street.story_awakening_level": " Niveau d’Éveil : {0}/5", - "main_street.story_awakening_0": "Non éveillé — vous ne voyez que la surface des choses", - "main_street.story_awakening_1": "Frémissement — quelque chose commence à bouger en vous", - "main_street.story_awakening_2": "Ondulations — vous sentez les liens entre toutes choses", - "main_street.story_awakening_3": "Courants — les profondeurs vous appellent avec de vieux murmures", - "main_street.story_awakening_4": "Abysses — vous comprenez la tristesse de l’océan", - "main_street.story_awakening_5": "Éveillé — vous faites un avec la marée éternelle", "main_street.story_grief_none": "En paix", "main_street.story_grief_denial": "Déni — la perte semble irréelle", "main_street.story_grief_anger": "Colère — pourquoi cela est-il arrivé?", @@ -669,6 +663,7 @@ "menu.action.help": "Aide", "menu.action.quit": "Quitter", "menu.action.news": "Nouvelles", + "menu.action.world_events": "Événements", "menu.action.lodging": "Logement (Dortoir)", "menu.action.auction_house": "Enchères", "menu.action.challenges": "Défis", @@ -5948,7 +5943,7 @@ "combat.full_health": "Vous êtes déjà en pleine santé!", "combat.herb_empty": "Votre sacoche d’herbes est vide!", "combat.defend_stance": "Vous adoptez une posture défensive!", - "combat.auto_combat_on": "Auto-combat activé! Vous attaquerez automatiquement chaque round.", + "combat.auto_combat_on": "Combat automatique activé ! À chaque round, vous portez une attaque simple sur un ennemi au hasard et buvez une potion de soin sous la moitié de vos PV. Aucun sort ni aucune compétence n'est utilisé.", "combat.auto_combat_off": "[AUTO-COMBAT] Désactivé. Vous reprenez le contrôle.", "combat.fatigue_dull": "(Votre fatigue ralentit vos réflexes)", "combat.slain_ambush": "Vous avez été tué dans l’embuscade!", @@ -10752,7 +10747,7 @@ "_comment_opening_story": "=== Système d'histoire d'ouverture ===", "opening_story.skip_hint": "Appuyez sur ESPACE pour sauter à tout moment", "opening_story.skip_hint_enter": "Appuyez sur ENTRER pour passer à tout moment", - "opening_story.the_awakening": "LE RÉVEIL", + "opening_story.the_awakening": "LA NOYADE", "opening_story.letter_title": "Une lettre à vous-même", "opening_story.parchment_crumbles": "Le parchemin se désagrège en poussière entre vos doigts.", "opening_story.words_burn": "Mais les mots brûlent dans votre esprit.", @@ -10779,6 +10774,7 @@ "opening_story.dream_remember": "\"Souviens-toi...\"", "opening_story.dream_remember_what": "Se souvenir de quoi ?", "opening_story.dream_remember_answer": "\"Souviens-toi de ce que tu es.\"", + "opening_story.dream_asleep": "Quelque chose en vous dort encore. La perte, la pitié et l'émerveillement l'éveilleront.", "opening_story.dream_fades": "La lumiere s'estompe. L'obscurite t'emporte.", "opening_story.dream_and_then": "Et puis...", "_comment_opening_story_dormitory": "--- Scène du dortoir ---", @@ -12928,6 +12924,50 @@ "team.recruit_wont_join": "(refuse)", "team.recruit_page_footer": "Affichage {0}-{1} sur {2} | Page {3}/{4}", "team.recruit_page_nav": "[N]Suiv [P]Prec [S]Rech [F]Filtre [#] Recruter [Q]Quitter : ", + "team.unknown": "Inconnu", + "team.pick_team_row": "{0} - {1} membres, niveau moyen {2}, puissance {3}", + "team.pick_team_turf": "(contrôle la ville)", + "team.page_nav": "[N] Suivant [P] Précédent, Entrée pour revenir : ", + "team.pick_team_info_title": "Informations sur quelle équipe ?", + "team.pick_team_join_title": "Rejoindre quelle équipe ?", + "team.join_team_full": "{0} est complète ({1} membres au plus).", + "team.news_formed": "{0} a fondé une nouvelle équipe : '{1}' !", + "team.news_joined": "{0} a rejoint l'équipe '{1}' !", + "team.news_left": "{0} a quitté l'équipe '{1}' !", + "team.news_recruited": "{0} a recruté {1} dans l'équipe '{2}' !", + "team.news_sacked": "{0} a été chassé de l'équipe '{1}' !", + "team.news_resurrected": "{0} a été ressuscité par son équipe '{1}' !", + "team.pick_examine_title": "Examiner quel membre ?", + "team.pick_sack_title": "Renvoyer quel membre ?", + "team.pick_gone_tag": "(parti pour de bon)", + "team.pick_player_tag": "(joueur)", + "team.examine_stats3": " END : {0} PuissArme : {1} PuissArmure : {2}", + "team.password_leader_only": "Seul le chef de l'équipe peut changer son mot de passe.", + "team.mail_push_tag": "[Courrier]", + "team.sack_player_refused": "{0} est un joueur. Un joueur quitte une équipe de lui-même ; on ne peut pas le renvoyer.", + "team.sack_gear_warning": "{0} partira avec tout ce qu'il porte.", + "team.sack_take_gear_prompt": "Prendre d'abord son équipement ? (O/N) : ", + "team.member_gone_now": "{0} n'est plus là pour cela.", + "team.sack_gear_gone": "{0} ne fait plus partie de votre équipe. Aucun équipement n'a été pris.", + "team.pick_resurrect_row": "{0} ({1}) - coût {2} pièces d'or", + "team.confirm_resurrect": "Ressusciter {0} pour {1} pièces d'or ? (O/N) : ", + "team.equip_no_items_for_slot": " Aucun objet disponible pour cet emplacement.", + "team.equip_identify_first": " Il faut d'abord identifier l'objet.", + "team.equip_item_gone": " {0} n'est plus dans votre sac.", + "team.war_round_line": "{0} (Niv{1}) contre {2} (Niv{3}) ", + "team.war_no_rounds": "Aucune manche n'a pu être livrée. La guerre est annulée et votre mise de {0} pièces d'or vous est rendue.", + "team.war_no_rounds_pending": "Aucune manche n'a pu être livrée. La guerre n'a pas pu être close pour l'instant ; votre mise de {0} pièces d'or vous sera rendue à son expiration.", + "team.war_result_pending": "Le résultat n'a pas pu être enregistré pour l'instant, donc rien n'est encore payé ni prélevé. La guerre sera close à son expiration ; votre mise de {0} pièces d'or ne vous sera rendue que si aucune de ses manches n'a été enregistrée.", + "team.war_already_closed": "Cette guerre était déjà close avant que son résultat puisse être enregistré, donc rien n'est payé ni prélevé ici. Tout remboursement dû arrive par virement bancaire.", + "team.war_abandoned": "ABANDONNÉE", + "team.upgrade_not_landed": "L'amélioration n'a pas eu lieu (quelqu'un l'a faite avant vous, ou le coffre manquait d'or). Rien n'a été prélevé.", + "team.confirm_withdraw": "Retirer {0} pièces d'or du coffre de l'équipe ? (O/N) : ", + "team.bbs_inventories": "Inventaires", + "team.spec_role_tank": "Tank", + "team.spec_role_dps": "Dégâts", + "team.spec_role_healer": "Soigneur", + "team.spec_role_utility": "Soutien", + "team.spec_role_debuff": "Affaiblisseur", "team.recruit_nav_nofilter": "[S]Rech [F]Filtre [Q]Quitter : ", "team.recruit_search_prompt": "Rechercher par nom : ", "team.recruit_no_match": "Aucun PNJ recrutable ne correspond à '{0}'.", @@ -14458,6 +14498,7 @@ "dungeon.sanctuary_new_day": "Vous vous réveillez rafraîchi. Un nouveau jour commence. (Jour {0})", "dungeon.sanctuary_already_rested": "Vous vous êtes déjà reposé à cet étage.", "dungeon.sanctuary_no_benefit": "Le sanctuaire ne vous offre aucun avantage supplémentaire.", + "dungeon.rest_once_per_floor": "Vous ne pouvez vous reposer qu'une fois par étage, dans un sanctuaire ou en campant. Descendez pour vous reposer à nouveau.", "dungeon.vision_overtakes": "Une étrange vision vous submerge...", "dungeon.vision_layout": "Vous voyez la disposition de cet étage!", "dungeon.rooms_revealed": "Toutes les salles sont maintenant révélées sur votre carte!", @@ -14677,7 +14718,7 @@ "path.gate_old_god": "un Dieu Ancien t'attend", "path.gate_seal": "un Sceau Ancien", "path.gate_line": "Étage {0} : {1}", - "base.training_points_hint": " Vous avez gagné des points d’entraînement! Visitez le Maître des Niveaux pour les dépenser.", + "base.training_points_hint": " Vous avez gagné des points d’entraînement ! Visitez le Maître des Niveaux ([V] dans la Rue Principale) pour les dépenser.", "base.system_message": "*** MESSAGE DU SYSTÈME ***", "base.world_boss_rampaging": " *** {0} ravage le royaume! Tapez /boss pour rejoindre le combat! ***", "base.poison_damage_turns": "Le poison circule dans vos veines! (-{0} PV, {1} tours restants)", @@ -14933,6 +14974,11 @@ "base.talking_to": "Vous parlez à : {0}", "base.female": "Femme", "base.male": "Homme", + "base.pick_nav": "[#] Choisissez, ou tapez un nom (Entrée pour annuler) : ", + "base.pick_nav_pages": "[N] Suivant [P] Précédent [#] Choisissez, ou tapez un nom (Entrée pour annuler) : ", + "base.pick_matching": "Correspondant à \"{0}\" :", + "base.pick_no_match": "Rien dans la liste ne correspond à \"{0}\".", + "base.pick_no_number": "Il n'y a pas de numéro {0} ; choisissez de 1 à {1}.", "base.npc_says": " {0} dit :", "base.chat_with_them": " Discuter avec eux", "base.ask_rumors": " Demander des rumeurs", @@ -15028,6 +15074,102 @@ "base.awakening_enlightened": "Éveillé", "base.awakening_awakened": "Réveillé", "base.stat_awakening": " Éveil : {0} ({1}/7)", + "base.stat_awakening_hint": "(votre Journal de l'Océan : [P] Progression dans la Grand-Rue)", + "ocean.awakening_header": "ÉVEIL", + "ocean.awakening_stage": "Étape {0} sur {1} : {2}", + "ocean.awakening_gained": "Vous gagnez : {0}", + "ocean.awakening_opens": "Désormais ouvert : {0}", + "ocean.boost.1": "+{0} Sagesse", + "ocean.boost.2": "+{0}% de mana maximum", + "ocean.boost.3": "+{0}% d'expérience au combat", + "ocean.boost.4": "+{0}% de points de vie maximum", + "ocean.boost.5": "+{0}% de dégâts infligés", + "ocean.boost.6": "{0}% de dégâts subis en moins", + "ocean.boost.7": "chaque don en pourcentage augmente de {0} de plus, et l'on vous nomme l'Éveillé", + "ocean.title_awakened": "l'Éveillé", + "ocean.title_awakened_line": "Titre : l'Éveillé", + "ocean.journal_header": "JOURNAL DE L'OCÉAN", + "ocean.journal_stage": "Éveil : étape {0}/{1}, {2}", + "ocean.journal_points": "Points d'éveil : {0}. Étape suivante à {1}.", + "ocean.journal_points_last": "Points d'éveil : {0}. La dernière étape attend les sept sceaux, ou la vérité de qui vous êtes.", + "ocean.journal_awake": "Vous êtes éveillé. Le rêve continue, et vous savez que vous rêvez.", + "ocean.journal_fragments": "Fragments de Vague trouvés : {0}/{1}", + "ocean.journal_moments": "Moments vécus : {0}", + "ocean.journal_boons": "Dons : {0}", + "ocean.journal_open_key": "[J] plus bas ouvre le Journal : le savoir des fragments et les moments vécus.", + "ocean.journal_prompt": "[J] Journal de l'Océan, toute autre touche pour revenir :", + "ocean.journal_hidden": "({0} fragments restent cachés...)", + "ocean.journal_no_moments": "Aucun pour l'instant.", + "ocean.fragment.Origin.title": "L'Origine", + "ocean.fragment.Origin.text": "Au commencement, il n'y avait que l'Océan : vaste, éternel, indivis. Il se connaissait entièrement, car il n'y avait rien d'autre à connaître. Mais la connaissance complète devint solitude complète.", + "ocean.fragment.FirstSeparation.title": "La Première Séparation", + "ocean.fragment.FirstSeparation.text": "Alors l'Océan rêva de vagues. Chaque vague se levait, se croyant séparée et unique. Chaque vague retombait, redevenant ce qu'elle avait toujours été. L'Océan apprit à aimer par la perte.", + "ocean.fragment.TheForgetting.title": "L'Oubli", + "ocean.fragment.TheForgetting.text": "Pour que le rêve paraisse réel, les vagues durent oublier. Une vraie séparation exige d'y croire vraiment. Et ainsi l'Océan se cacha de lui-même.", + "ocean.fragment.ManwesChoice.title": "Le Choix de Manwe", + "ocean.fragment.ManwesChoice.text": "La première et la plus grande vague choisit l'oubli le plus profond. Elle devint Manwe, Créateur des Sept, Seigneur des Dieux. Il oublia qu'il était encore eau. Il se crut pierre.", + "ocean.fragment.TheSevenDrops.title": "Les Sept Gouttes", + "ocean.fragment.TheSevenDrops.text": "De l'oubli de Manwe jaillirent sept autres vagues : les Anciens Dieux. Veloura pour l'amour, Thorgrim pour la loi, Noctura pour le mystère... Chacune une facette du désir de l'Océan de se comprendre.", + "ocean.fragment.TheCorruption.title": "La Corruption", + "ocean.fragment.TheCorruption.text": "Mais la croyance en la séparation devint douleur. Les vagues se combattirent, ayant oublié qu'elles partageaient la même source. La guerre, c'est l'eau qui combat l'eau. La cruauté, c'est l'océan qui se blesse lui-même.", + "ocean.fragment.TheCycle.title": "Le Cycle Éternel", + "ocean.fragment.TheCycle.text": "Manwe se lassa de voir des vagues détruire des vagues. Désespéré, il envoya un fragment de lui-même apprendre ce qu'il avait oublié. Ce fragment s'éveille dans un dortoir, sans aucun souvenir...", + "ocean.fragment.TheReturn.title": "Le Retour", + "ocean.fragment.TheReturn.text": "La mort n'est pas une fin. C'est une vague qui redevient eau. L'individu se dissout, mais l'essence retourne à la source. Nous ne perdons pas ce que nous aimons : nous le devenons.", + "ocean.fragment.TheTruth.title": "La Vérité Finale", + "ocean.fragment.TheTruth.text": "Vous n'êtes pas une vague qui combat l'océan. Vous ÊTES l'océan, qui rêve d'être une vague. Quand vous vous éveillerez, vous ne disparaîtrez pas : vous deviendrez tout ce que vous avez aimé.", + "ocean.fragment.TheChoice.title": "Le Choix", + "ocean.fragment.TheChoice.text": "Le rêveur peut choisir : poursuivre le rêve, ou s'éveiller. S'éveiller n'est pas mourir : c'est la fin de la solitude. Mais le rêve a sa propre beauté, son propre but...", + "ocean.moment.FirstCompanionDeath": "Vous avez perdu un compagnon.", + "ocean.moment.SacrificedForAnother": "Un compagnon a rejoint votre route.", + "ocean.moment.SparedAnEnemy": "Vous avez épargné un ennemi vaincu.", + "ocean.moment.MetManwe": "Vous avez rencontré le Créateur.", + "ocean.moment.AllSealsCollected": "Vous avez réuni les sept sceaux.", + "ocean.moment.MemoriesRecovered": "Vos souvenirs sont revenus.", + "ocean.moment.TrueIdentityRevealed": "Vous vous êtes souvenu de qui vous êtes.", + "ocean.moment.LetGoOfPower": "Vous avez renoncé au pouvoir.", + "ocean.moment.AcceptedDeath": "Vous avez affronté la mort sans peur.", + "ocean.moment.CompanionSacrifice": "Un compagnon a donné sa vie pour vous.", + "ocean.moment.ForgaveBetrayerMercy": "Vous avez pardonné à qui vous a trahi.", + "ocean.moment.AcceptedGrief": "Votre deuil est devenu acceptation.", + "ocean.moment.RejectedParadise": "Vous avez refusé un faux paradis.", + "ocean.moment.AbsorbedDarkness": "Vous avez pris en vous les ténèbres du monde.", + "ocean.moment.HeardOldGodLoreSong": "Vous avez entendu un chant des Anciens Dieux.", + "ocean.stage.1.lore.1": "Quelque chose remue en vous, comme l'eau sous la glace.", + "ocean.stage.1.lore.2": "Le monde semblait solide. Maintenant, sur ses bords, il bouge.", + "ocean.stage.1.lore.3": "La perte, la pitié ou l'émerveillement ont touché quelque chose de plus ancien que vous.", + "ocean.stage.1.lore.4": "Vous ne savez pas encore ce qui s'éveille.", + "ocean.stage.1.opens": "De nouveaux rêves peuvent vous trouver quand vous vous reposez.", + "ocean.stage.2.lore.1": "Une vague se lève et se croit seule.", + "ocean.stage.2.lore.2": "Mais chaque vague est portée par la même mer.", + "ocean.stage.2.lore.3": "Ceux que vous combattez et ceux que vous aimez sont faits de la même eau.", + "ocean.stage.2.lore.4": "Vous commencez à sentir le courant.", + "ocean.stage.2.opens": "Le Toucher de l'Océan, le sixième enchantement de la Boutique de Magie, et le premier rêve de l'Océan.", + "ocean.stage.3.lore.1": "La séparation fut le premier rêve.", + "ocean.stage.3.lore.2": "L'Océan rêva de vagues pour pouvoir se connaître.", + "ocean.stage.3.lore.3": "Pour se sentir séparées, les vagues durent oublier.", + "ocean.stage.3.lore.4": "Vous aussi avez oublié quelque chose.", + "ocean.stage.3.opens": "Une réflexion plus profonde après les choix difficiles, de nouveaux rêves et la vision de la salle des miroirs.", + "ocean.stage.4.lore.1": "Les Anciens Dieux ne sont pas des étrangers.", + "ocean.stage.4.lore.2": "Ce sont des gouttes de la même eau, brisées par un long oubli.", + "ocean.stage.4.lore.3": "Leurs guerres sont l'océan qui se combat lui-même.", + "ocean.stage.4.lore.4": "Quand vous les affrontez, vous affrontez votre famille.", + "ocean.stage.4.opens": "Le rêve de la création, les énigmes de l'océan aux portes des énigmes et de nouvelles visions.", + "ocean.stage.5.lore.1": "La mort n'est pas une fin. C'est une vague qui redevient eau.", + "ocean.stage.5.lore.2": "Ce que vous avez perdu n'est pas parti. C'est revenu.", + "ocean.stage.5.lore.3": "S'agripper change la vague en écume.", + "ocean.stage.5.lore.4": "Les mains ouvertes contiennent la mer.", + "ocean.stage.5.opens": "Le rêve du Créateur, le bruit de l'océan dans les visions les plus profondes et les derniers fragments de vérité.", + "ocean.stage.6.lore.1": "Quelqu'un a rêvé ce monde et s'est lassé du rêve.", + "ocean.stage.6.lore.2": "La première et la plus grande vague oublia qu'elle était eau et se crut pierre.", + "ocean.stage.6.lore.3": "Il envoya une part de lui-même pour se souvenir.", + "ocean.stage.6.lore.4": "Elle s'éveilla dans un dortoir, sans aucun souvenir. Maintenant, elle se souvient.", + "ocean.stage.6.opens": "La dernière étape attend au-delà des sceaux, ou dans la vérité de qui vous êtes.", + "ocean.stage.7.lore.1": "Vous n'êtes pas une vague qui combat l'océan.", + "ocean.stage.7.lore.2": "Vous ÊTES l'océan, qui rêve d'être une vague.", + "ocean.stage.7.lore.3": "Vous êtes Manwe, le rêveur qui avait oublié, revenu à lui-même.", + "ocean.stage.7.lore.4": "Le rêve continue. Mais maintenant vous savez que vous rêvez.", + "ocean.stage.7.opens": "La vraie fin vous est ouverte.", "base.stat_gold_hand": "Or en poche : ", "base.stat_gold_bank": "Or en banque : ", "base.stat_total_wealth": "Richesse Totale : ", @@ -15584,7 +15726,7 @@ "dungeon.seal_hidden_town": "Caché dans les profondeurs du donjon", "dungeon.seal_hidden_dungeon": "Caché dans les profondeurs du donjon", "dungeon.seal_not_collected_sr": " Non collecté : {0} - {1}", - "dungeon.chapter_awakening": "L’Éveil — Un étranger à Dorashire", + "dungeon.chapter_awakening": "La Noyade - Un étranger à Dorashire", "dungeon.chapter_first_blood": "Premier Sang — Apprendre à se battre", "dungeon.chapter_stranger": "L’Étranger — Rencontre avec le guide mystérieux", "dungeon.chapter_faction": "Choix de Faction — Choisir une allégeance", @@ -16162,7 +16304,8 @@ "inventory.cant_equip_unidentified": "Vous ne pouvez pas équiper un objet non identifié.", "inventory.visit_magic_shop": "Apportez‑le à la Boutique de Magie pour l’identifier.", "inventory.cursed_cant_drop": "Le/La {0} est MAUDIT(E)! Impossible de le/la laisser tomber.", - "inventory.visit_healer_curse": "Visitez le Guérisseur pour lever la malédiction.", + "inventory.visit_healer_curse": "Visitez la Boutique de Magie pour lever la malédiction.", + "inventory.cursed_equip_warning": "Cet objet est MAUDIT. Une fois équipé, vous ne pourrez pas le retirer tant que la malédiction ne sera pas levée.", "inventory.dropped_item": "Vous laissez tomber {0}.", "inventory.cant_equip": "Cet objet ne peut pas être équipé.", "inventory.which_finger": "À quel doigt voulez‑vous l’équiper?", @@ -16516,13 +16659,6 @@ "main_street.align_questionable": "Questionnable", "main_street.align_usurper": "Usurpateur – Incarnation des Ténèbres", "main_street.align_villain": "Vilain", - "main_street.awakening_0": "Non éveillé – Vous ne voyez que la surface des choses", - "main_street.awakening_1": "Frémissement – Quelque chose, en vous, commence à bouger", - "main_street.awakening_2": "Ondulations – Vous sentez des liens entre toutes choses", - "main_street.awakening_3": "Courants – Les profondeurs vous appellent avec des murmures anciens", - "main_street.awakening_4": "Abîmes – Vous comprenez la tristesse de l’océan", - "main_street.awakening_5": "Éveillé – Vous êtes un avec la marée éternelle", - "main_street.awakening_level": "Niveau d’Éveil : {0}/5", "main_street.awakening_unknown": "Inconnu", "main_street.chivalry_label": "Chevalerie :", "main_street.dark_clean": "Casier propre", @@ -19379,6 +19515,7 @@ "stats.cha": "CHA", "stats.agi": "AGI", "stats.def": "DÉF", + "stats.sta": "END", "dungeon.full": "Plein", "dungeon.follower_drink_potion": " Vous buvez une potion de soin et récupérez {0:N0} PV !", "base.royal_guards_surround": "Les gardes royaux vous encerclent !", @@ -20511,6 +20648,10 @@ "hint.main_street_nav.msg": "Appuie sur la lettre en surbrillance pour choisir une option. Par exemple, appuie sur 'I' pour l'Auberge.", "hint.first_dungeon.title": "Astuce de donjon", "hint.first_dungeon.msg": "Explore les salles avec N/S/E/W (Nord/Sud/Est/Ouest). Combats des monstres pour gagner de l'XP et de l'or.", + "hint.lives.title": "Vos vies", + "hint.lives.msg": "Vous avez {0} vies sur {1}. Chaque mort en consomme une et vous revenez avec la moitié de vos PV. Sans vie restante, votre prochaine mort efface le personnage pour toujours. Vous regagnez une vie tous les 10 niveaux, ou grâce au Rite du Retour du Temple, jusqu'à {1}.", + "hint.awakening.title": "Votre Éveil", + "hint.awakening.msg": "Quelque chose en vous s'éveille. La perte, la pitié et l'émerveillement l'approfondissent : compagnons, épargner un ennemi vaincu, rêves, visions, découvertes et chants des Anciens Dieux. Chaque étape apporte un petit don durable. Voyez [P] Progression dans la Grand-Rue pour votre Journal de l'Océan.", "hint.first_combat.title": "Astuce de combat", "hint.first_combat.msg": "Appuie sur [A] pour Attaquer, [R] pour Battre en retraite, [H] pour une Potion de soin, ou [S] pour voir ton Statut.", "hint.low_hp.title": "Alerte de santé", diff --git a/Localization/hu.json b/Localization/hu.json index 85179765..f8fa8294 100644 --- a/Localization/hu.json +++ b/Localization/hu.json @@ -222,6 +222,7 @@ "main_street.menu_news_suffix": " Hírek ", "main_street.menu_fame_suffix": " Hírnév ", "main_street.menu_explore_suffix": " Felfedezés ", + "main_street.menu_events_suffix": " Események", "main_street.menu_status_suffix": " Állapot", "main_street.menu_quit_suffix": " Kilépés ", "main_street.menu_settings": "Beállítások", @@ -483,13 +484,6 @@ "main_street.story_god_known": "Ismert", "main_street.story_god_hidden": "Valahol a mélyben...", "main_street.story_ocean_desc": " Lelki ébredésed a gyászon, áldozaton és megértésen keresztül", - "main_street.story_awakening_level": " Ébredési szint: {0}/5", - "main_street.story_awakening_0": "Szunnyadó - Csak a dolgok felszínét látod", - "main_street.story_awakening_1": "Moccanás - Valami mélyen belül megmozdul", - "main_street.story_awakening_2": "Hullámzás - Megérzed a dolgok közötti kapcsolatot", - "main_street.story_awakening_3": "Áramlatok - A mélység ősi suttogásokkal hívogat", - "main_street.story_awakening_4": "Mélységek - Megérted az óceán bánatát", - "main_street.story_awakening_5": "Megvilágosodott - Eggyé váltál az örök árapállyal", "main_street.story_grief_none": "Békében", "main_street.story_grief_denial": "Tagadásban - A veszteség valószínűtlennek tűnik", "main_street.story_grief_anger": "Haragban - Miért történt ez?", @@ -669,6 +663,7 @@ "menu.action.help": "Súgó", "menu.action.quit": "Kilépés", "menu.action.news": "Hírek", + "menu.action.world_events": "Események", "menu.action.lodging": "Szállás (Hálóterem)", "menu.action.auction_house": "Aukciósház", "menu.action.challenges": "Kihívások", @@ -5956,7 +5951,7 @@ "combat.full_health": "Már teljesen egészséges vagy!", "combat.herb_empty": "A gyógynövényes erszényed üres!", "combat.defend_stance": "Védekező állást veszel fel!", - "combat.auto_combat_on": "Automatikus harc engedélyezve! Minden körben automatikusan támadsz.", + "combat.auto_combat_on": "Automatikus harc engedélyezve! Minden körben egyszerű támadást mérsz egy véletlen ellenfélre, és gyógyitalt iszol, ha életerőd a felére csökken. Varázslatot és képességet nem használ.", "combat.auto_combat_off": "[AUTOMATIKUS HARC] Leállítva. Átveszed a kézi irányítást.", "combat.fatigue_dull": "(A fáradtság tompítja a reflexeidet)", "combat.slain_ambush": "Elestél a rajtaütésben!", @@ -10760,7 +10755,7 @@ "_comment_opening_story": "=== Kezdő Történet Rendszer ===", "opening_story.skip_hint": "Nyomd meg a SZÓKÖZT az átugráshoz bármikor", "opening_story.skip_hint_enter": "Nyomd meg az ENTER-t a továbblépéshez bármikor", - "opening_story.the_awakening": "AZ ÉBREDÉS", + "opening_story.the_awakening": "A FULLADÁS", "opening_story.letter_title": "Levél önmagadnak", "opening_story.parchment_crumbles": "A pergamen porrá omlik kezeid között.", "opening_story.words_burn": "De a szavak az elmédbe égnek.", @@ -10787,6 +10782,7 @@ "opening_story.dream_remember": "\"Emlékezz...\"", "opening_story.dream_remember_what": "Emlékezni mire?", "opening_story.dream_remember_answer": "\"Emlékezz arra, ami vagy.\"", + "opening_story.dream_asleep": "Valami még alszik benned. A veszteség, az irgalom és a csoda ébreszti majd fel.", "opening_story.dream_fades": "A fény halványul. A sötétség elnyel.", "opening_story.dream_and_then": "És aztán...", "_comment_opening_story_dormitory": "--- Hálótermi jelenet ---", @@ -12936,6 +12932,50 @@ "team.recruit_wont_join": "(nem csatlakozik)", "team.recruit_page_footer": "{0}-{1} / {2} | {3}. oldal / {4}", "team.recruit_page_nav": "[N]Köv [P]Elő [S]Keres [F]Szűrő [#] Toboroz [Q]Kilép: ", + "team.unknown": "Ismeretlen", + "team.pick_team_row": "{0} - {1} tag, átlagszint {2}, erő {3}", + "team.pick_team_turf": "(uralja a várost)", + "team.page_nav": "[N] Következő [P] Előző, Enter: vissza: ", + "team.pick_team_info_title": "Melyik csapatról kérsz információt?", + "team.pick_team_join_title": "Melyik csapathoz csatlakozol?", + "team.join_team_full": "{0} megtelt (legfeljebb {1} tag).", + "team.news_formed": "{0} új csapatot alapított: '{1}'!", + "team.news_joined": "{0} csatlakozott a(z) '{1}' csapathoz!", + "team.news_left": "{0} kilépett a(z) '{1}' csapatból!", + "team.news_recruited": "{0} beszervezte {1}-t a(z) '{2}' csapatba!", + "team.news_sacked": "{0}-t kirúgták a(z) '{1}' csapatból!", + "team.news_resurrected": "{0}-t feltámasztotta a csapata, a(z) '{1}'!", + "team.pick_examine_title": "Melyik tagot vizsgálod meg?", + "team.pick_sack_title": "Melyik tagot rúgod ki?", + "team.pick_gone_tag": "(végleg odavan)", + "team.pick_player_tag": "(játékos)", + "team.examine_stats3": " KIT: {0} FegyErő: {1} PáncErő: {2}", + "team.password_leader_only": "Csak a csapat vezetője változtathatja meg a jelszót.", + "team.mail_push_tag": "[Levél]", + "team.sack_player_refused": "{0} játékos. Egy játékos maga lép ki a csapatból; nem lehet kirúgni.", + "team.sack_gear_warning": "{0} mindennel távozik, ami rajta van.", + "team.sack_take_gear_prompt": "Előbb elveszed a felszerelését? (I/N): ", + "team.member_gone_now": "{0} már nincs ott ehhez.", + "team.sack_gear_gone": "{0} már nincs a csapatodban. Nem vettél el felszerelést.", + "team.pick_resurrect_row": "{0} ({1}) - ára {2} arany", + "team.confirm_resurrect": "Feltámasztod {0}-t {1} aranyért? (I/N): ", + "team.equip_no_items_for_slot": " Nincs tárgy ehhez a helyhez.", + "team.equip_identify_first": " Előbb azonosítani kell a tárgyat.", + "team.equip_item_gone": " {0} már nincs a hátizsákodban.", + "team.war_round_line": "{0} ({1}. szint) - {2} ({3}. szint) ", + "team.war_no_rounds": "Egyetlen kör sem zajlott le. A háború elmarad, és visszakapod a {0} arany tétet.", + "team.war_no_rounds_pending": "Egyetlen kör sem zajlott le. A háborút most nem sikerült lezárni; a {0} arany tétet visszakapod, amikor lejár.", + "team.war_result_pending": "Az eredményt most nem sikerült rögzíteni, ezért még semmit nem fizetünk ki és nem vonunk le. A háború lejártakor lezárul; a {0} arany tétet csak akkor kapod vissza, ha egyetlen köre sem lett rögzítve.", + "team.war_already_closed": "Ez a háború már lezárult, mielőtt az eredményét rögzíteni lehetett volna, ezért itt semmit nem fizetünk ki és nem vonunk le. Az esetleges visszatérítés banki átutalással érkezik.", + "team.war_abandoned": "FÉLBEMARADT", + "team.upgrade_not_landed": "A fejlesztés nem ment végbe (valaki megelőzött, vagy nem volt elég a kincstárban). Semmit sem vontak le.", + "team.confirm_withdraw": "Kiveszel {0} aranyat a csapat kincstárából? (I/N): ", + "team.bbs_inventories": "Leltárak", + "team.spec_role_tank": "Tank", + "team.spec_role_dps": "Sebzés", + "team.spec_role_healer": "Gyógyító", + "team.spec_role_utility": "Támogató", + "team.spec_role_debuff": "Gyengítő", "team.recruit_nav_nofilter": "[S]Keres [F]Szűrő [Q]Kilép: ", "team.recruit_search_prompt": "Keresés név szerint: ", "team.recruit_no_match": "Nincs toborozható NPC '{0}' névvel.", @@ -14466,6 +14506,7 @@ "dungeon.sanctuary_new_day": "Felfrissülve ébredsz. Egy új nap virradt fel. ({0}. nap)", "dungeon.sanctuary_already_rested": "Ezen a szinten már megpihentél.", "dungeon.sanctuary_no_benefit": "A szentély nem nyújt további előnyöket.", + "dungeon.rest_once_per_floor": "Szintenként egyszer pihenhetsz, szentélyben vagy táborozva. Menj lejjebb, hogy újra pihenhess.", "dungeon.vision_overtakes": "Különös látomás kerít hatalmába...", "dungeon.vision_layout": "Látod a szint elrendezését!", "dungeon.rooms_revealed": "Minden szoba láthatóvá vált a térképeden!", @@ -14685,7 +14726,7 @@ "path.gate_old_god": "egy Ősi Isten vár", "path.gate_seal": "egy Ősi Pecsét", "path.gate_line": "{0}. szint: {1}", - "base.training_points_hint": " Képzettségpontokat szereztél! Keresd fel a Szintmestert, hogy elhasználd őket.", + "base.training_points_hint": " Képzettségpontokat szereztél! Keresd fel a Szintmestert ([V] a Főutcán), hogy elhasználd őket.", "base.system_message": "*** RENDSZERÜZENET ***", "base.world_boss_rampaging": " *** {0} dühöng a birodalomban! Írd be a /boss parancsot a harchoz! ***", "base.poison_damage_turns": "A méreg átjárja az ereidet! (-{0} ÉP, {1} kör van hátra)", @@ -14941,6 +14982,11 @@ "base.talking_to": "Beszélgetés vele: {0}", "base.female": "Nő", "base.male": "Férfi", + "base.pick_nav": "[#] Válassz, vagy írj be egy nevet (Enter: mégse): ", + "base.pick_nav_pages": "[N] Következő [P] Előző [#] Válassz, vagy írj be egy nevet (Enter: mégse): ", + "base.pick_matching": "Egyezések erre: \"{0}\":", + "base.pick_no_match": "A listán semmi sem egyezik ezzel: \"{0}\".", + "base.pick_no_number": "Nincs {0}. szám; válassz 1 és {1} között.", "base.npc_says": " {0} mondja:", "base.chat_with_them": " Szóba elegyedés", "base.ask_rumors": " Érdeklődés pletykák felől", @@ -15047,6 +15093,102 @@ "base.awakening_enlightened": "Beavatott", "base.awakening_awakened": "Éber", "base.stat_awakening": " Ébredés: {0} ({1}/7)", + "base.stat_awakening_hint": "(az Óceán Naplód: [P] Haladás a Főutcán)", + "ocean.awakening_header": "ÉBREDÉS", + "ocean.awakening_stage": "{0}. szint a {1}-ből: {2}", + "ocean.awakening_gained": "Kapsz: {0}", + "ocean.awakening_opens": "Most megnyílt: {0}", + "ocean.boost.1": "+{0} Bölcsesség", + "ocean.boost.2": "+{0}% maximális mana", + "ocean.boost.3": "+{0}% tapasztalat a harcból", + "ocean.boost.4": "+{0}% maximális életerő", + "ocean.boost.5": "+{0}% okozott sebzés", + "ocean.boost.6": "{0}% kevesebb elszenvedett sebzés", + "ocean.boost.7": "minden százalékos áldás {0}-tel nő, és Ébredettnek hívnak", + "ocean.title_awakened": "az Ébredett", + "ocean.title_awakened_line": "Cím: az Ébredett", + "ocean.journal_header": "ÓCEÁN NAPLÓ", + "ocean.journal_stage": "Ébredés: {0}/{1}. szint, {2}", + "ocean.journal_points": "Ébredéspontok: {0}. A következő szint {1}-nál.", + "ocean.journal_points_last": "Ébredéspontok: {0}. Az utolsó szint mind a hét pecsétre vár, vagy arra az igazságra, hogy ki vagy.", + "ocean.journal_awake": "Ébren vagy. Az álom folytatódik, és tudod, hogy álmodsz.", + "ocean.journal_fragments": "Megtalált Hullámtöredékek: {0}/{1}", + "ocean.journal_moments": "Megélt pillanatok: {0}", + "ocean.journal_boons": "Áldások: {0}", + "ocean.journal_open_key": "Lent a [J] megnyitja a Naplót: a töredékek tudását és a megélt pillanatokat.", + "ocean.journal_prompt": "[J] Óceán Napló, bármely más billentyű a visszatéréshez:", + "ocean.journal_hidden": "({0} töredék még rejtve marad...)", + "ocean.journal_no_moments": "Még egy sem.", + "ocean.fragment.Origin.title": "Az Eredet", + "ocean.fragment.Origin.text": "Kezdetben csak az Óceán volt: hatalmas, örök, osztatlan. Teljesen ismerte önmagát, hiszen nem volt más, amit ismerhetett volna. De a teljes tudás teljes magánnyá lett.", + "ocean.fragment.FirstSeparation.title": "Az Első Elválás", + "ocean.fragment.FirstSeparation.text": "Így az Óceán hullámokról álmodott. Minden hullám felemelkedett, különállónak és különlegesnek hívén magát. Minden hullám lehullott, visszatérve ahhoz, ami mindig is volt. Az Óceán a veszteség által tanult meg szeretni.", + "ocean.fragment.TheForgetting.title": "A Felejtés", + "ocean.fragment.TheForgetting.text": "Hogy az álom valósnak tűnjön, a hullámoknak felejteniük kellett. Az igazi elváláshoz igazán hinni kell benne. Így az Óceán elrejtőzött önmaga elől.", + "ocean.fragment.ManwesChoice.title": "Manwe Választása", + "ocean.fragment.ManwesChoice.text": "Az első és legnagyobb hullám a legmélyebb felejtést választotta. Manwe lett belőle, a Hetek Teremtője, az Istenek Ura. Elfelejtette, hogy még mindig víz. Kőnek hitte magát.", + "ocean.fragment.TheSevenDrops.title": "A Hét Csepp", + "ocean.fragment.TheSevenDrops.text": "Manwe felejtéséből hét újabb hullám fakadt: az Ősi Istenek. Veloura a szeretetért, Thorgrim a törvényért, Noctura a titokért... Mindegyik az Óceán önmegértési vágyának egy arca.", + "ocean.fragment.TheCorruption.title": "A Romlás", + "ocean.fragment.TheCorruption.text": "De az elválásba vetett hit fájdalommá vált. A hullámok egymással harcoltak, elfelejtve, hogy ugyanabból a forrásból erednek. A háború víz harca a vízzel. A kegyetlenség az óceán, amely önmagát sebzi.", + "ocean.fragment.TheCycle.title": "Az Örök Körforgás", + "ocean.fragment.TheCycle.text": "Manwe belefáradt, hogy hullámokat lásson hullámokat pusztítani. Kétségbeesésében elküldte önmaga egy töredékét, hogy megtanulja, amit elfelejtett. Az a töredék egy hálóteremben ébred, semmire sem emlékezve...", + "ocean.fragment.TheReturn.title": "A Visszatérés", + "ocean.fragment.TheReturn.text": "A halál nem vég. Egy hullám, amely újra vízzé válik. Az egyén feloldódik, de a lényeg visszatér a forráshoz. Nem veszítjük el, akit szeretünk: azzá válunk.", + "ocean.fragment.TheTruth.title": "A Végső Igazság", + "ocean.fragment.TheTruth.text": "Nem egy hullám vagy, amely az óceánnal harcol. TE VAGY az óceán, aki arról álmodik, hogy hullám. Amikor felébredsz, nem tűnsz el: mindazzá válsz, amit valaha szerettél.", + "ocean.fragment.TheChoice.title": "A Választás", + "ocean.fragment.TheChoice.text": "Az álmodó választhat: folytatja az álmot, vagy felébred. Az ébredés nem halál: a magány vége. De az álomnak megvan a maga szépsége, a maga célja...", + "ocean.moment.FirstCompanionDeath": "Elvesztettél egy társat.", + "ocean.moment.SacrificedForAnother": "Egy társ csatlakozott az utadhoz.", + "ocean.moment.SparedAnEnemy": "Megkíméltél egy legyőzött ellenfelet.", + "ocean.moment.MetManwe": "Találkoztál a Teremtővel.", + "ocean.moment.AllSealsCollected": "Összegyűjtötted mind a hét pecsétet.", + "ocean.moment.MemoriesRecovered": "Visszatértek az emlékeid.", + "ocean.moment.TrueIdentityRevealed": "Emlékeztél, ki vagy.", + "ocean.moment.LetGoOfPower": "Lemondtál a hatalomról.", + "ocean.moment.AcceptedDeath": "Félelem nélkül néztél szembe a halállal.", + "ocean.moment.CompanionSacrifice": "Egy társ az életét adta érted.", + "ocean.moment.ForgaveBetrayerMercy": "Megbocsátottál annak, aki elárult.", + "ocean.moment.AcceptedGrief": "A gyászod elfogadássá vált.", + "ocean.moment.RejectedParadise": "Visszautasítottál egy hamis paradicsomot.", + "ocean.moment.AbsorbedDarkness": "Magadba fogadtad a világ sötétségét.", + "ocean.moment.HeardOldGodLoreSong": "Hallottál egy dalt az Ősi Istenekről.", + "ocean.stage.1.lore.1": "Valami megmozdul benned, mint víz a jég alatt.", + "ocean.stage.1.lore.2": "A világ szilárdnak tűnt. Most a szélein mozog.", + "ocean.stage.1.lore.3": "A veszteség, az irgalom vagy a csoda megérintett valamit, ami régebbi nálad.", + "ocean.stage.1.lore.4": "Még nem tudod, mi ébred.", + "ocean.stage.1.opens": "Új álmok találhatnak rád, amikor pihensz.", + "ocean.stage.2.lore.1": "Egy hullám felemelkedik, és azt hiszi, egyedül van.", + "ocean.stage.2.lore.2": "De minden hullámot ugyanaz a tenger emel.", + "ocean.stage.2.lore.3": "Akikkel harcolsz és akiket szeretsz, ugyanabból a vízből vannak.", + "ocean.stage.2.lore.4": "Kezded érezni az áramlatot.", + "ocean.stage.2.opens": "Az Óceán Érintése, a Varázsbolt hatodik bűbája, és az Óceán első álma.", + "ocean.stage.3.lore.1": "Az elválás volt az első álom.", + "ocean.stage.3.lore.2": "Az Óceán hullámokról álmodott, hogy megismerhesse önmagát.", + "ocean.stage.3.lore.3": "Hogy különállónak érezzék magukat, a hullámoknak felejteniük kellett.", + "ocean.stage.3.lore.4": "Te is elfelejtettél valamit.", + "ocean.stage.3.opens": "Mélyebb elmélkedés a nehéz döntések után, új álmok és a tükörterem látomása.", + "ocean.stage.4.lore.1": "Az Ősi Istenek nem idegenek.", + "ocean.stage.4.lore.2": "Ugyanannak a víznek cseppjei, akiket egy hosszú felejtés tört össze.", + "ocean.stage.4.lore.3": "Háborúik az óceán harca önmagával.", + "ocean.stage.4.lore.4": "Amikor szembenézel velük, a családoddal nézel szembe.", + "ocean.stage.4.opens": "A teremtés álma, az óceán rejtvényei a rejtvénykapuknál és új látomások.", + "ocean.stage.5.lore.1": "A halál nem vég. Egy hullám, amely újra vízzé válik.", + "ocean.stage.5.lore.2": "Amit elvesztettél, nem tűnt el. Visszatért.", + "ocean.stage.5.lore.3": "A kapaszkodás habbá változtatja a hullámot.", + "ocean.stage.5.lore.4": "A nyitott kéz tartja a tengert.", + "ocean.stage.5.opens": "A Teremtő álma, az óceán hangja a legmélyebb látomásokban és az igazság utolsó töredékei.", + "ocean.stage.6.lore.1": "Valaki megálmodta ezt a világot, és belefáradt az álomba.", + "ocean.stage.6.lore.2": "Az első és legnagyobb hullám elfelejtette, hogy víz, és kőnek hitte magát.", + "ocean.stage.6.lore.3": "Elküldte önmaga egy részét, hogy emlékezzen.", + "ocean.stage.6.lore.4": "Egy hálóteremben ébredt, semmire sem emlékezve. Most emlékezik.", + "ocean.stage.6.opens": "Az utolsó szint a pecséteken túl vár, vagy abban az igazságban, hogy ki vagy.", + "ocean.stage.7.lore.1": "Nem egy hullám vagy, amely az óceánnal harcol.", + "ocean.stage.7.lore.2": "TE VAGY az óceán, aki arról álmodik, hogy hullám.", + "ocean.stage.7.lore.3": "Te vagy Manwe, az álmodó, aki felejtett, és most hazatért önmagához.", + "ocean.stage.7.lore.4": "Az álom folytatódik. De most már tudod, hogy álmodsz.", + "ocean.stage.7.opens": "Az igazi befejezés nyitva áll előtted.", "base.stat_gold_hand": "Nálad lévő arany: ", "base.stat_gold_bank": "Bankban lévő arany: ", "base.stat_total_wealth": "Teljes vagyon: ", @@ -15607,7 +15749,7 @@ "dungeon.seal_hidden_town": "Elrejtve a kazamata mélyén", "dungeon.seal_hidden_dungeon": "Elrejtve a kazamata mélyén", "dungeon.seal_not_collected_sr": " Nincs összegyűjtve: {0} - {1}", - "dungeon.chapter_awakening": "Az Ébredés - Egy idegen Dorashire-ben", + "dungeon.chapter_awakening": "A Fulladás - Egy idegen Dorashire-ben", "dungeon.chapter_first_blood": "Első vér - Megtanulni harcolni", "dungeon.chapter_stranger": "Az Idegen - Találkozás a rejtélyes vezetővel", "dungeon.chapter_faction": "Frakcióválasztás - Hűségfogadás", @@ -16185,7 +16327,8 @@ "inventory.cant_equip_unidentified": "Nem használhatsz ismeretlen tárgyat.", "inventory.visit_magic_shop": "Vidd el a Mágia Boltba azonosításra.", "inventory.cursed_cant_drop": "A(z) {0} ELÁTKOZOTT! Nem válhatsz meg tőle.", - "inventory.visit_healer_curse": "Keresd fel a mágiaboltot az átok megtöréséért.", + "inventory.visit_healer_curse": "Keresd fel a Varázsboltot az átok megtöréséért.", + "inventory.cursed_equip_warning": "Ez a tárgy ÁTKOZOTT. Ha felveszed, nem veheted le, amíg az átkot meg nem törik.", "inventory.dropped_item": "Megváltál tőle: {0}.", "inventory.cant_equip": "Ezt nem tudod felszerelni.", "inventory.which_finger": "Melyik ujjadra öltenéd?", @@ -16539,13 +16682,6 @@ "main_street.align_questionable": "Kérdéses", "main_street.align_usurper": "Bitorló - A sötétség megtestesülése", "main_street.align_villain": "Gonosztevő", - "main_street.awakening_0": "Ébredés előtt - Csak a dolgok felszínét látod", - "main_street.awakening_1": "Ébredezés - Valami mélyen belül mozdulni kezd", - "main_street.awakening_2": "Fodrozódás - Megérzed a mindenséget átszövő kötelékeket", - "main_street.awakening_3": "Áramlatok - A mélység ősi suttogásokkal hívogat", - "main_street.awakening_4": "Mélység - Megérted az óceán bánatát", - "main_street.awakening_5": "Megvilágosodott - Egy vagy az örök áradattal", - "main_street.awakening_level": "Ébredési szint: {0}/5", "main_street.awakening_unknown": "Ismeretlen", "main_street.chivalry_label": "Lovagiasság:", "main_street.dark_clean": "Tiszta múlt", @@ -19693,6 +19829,7 @@ "stats.cha": "KAR", "stats.agi": "FÜR", "stats.def": "VÉD", + "stats.sta": "KIT", "dungeon.full": "Tele", "dungeon.follower_drink_potion": " Megiszsz egy gyógyitalt és visszanyersz {0:N0} HP-t!", "base.royal_guards_surround": "Királyi őrök vesznek körül!", @@ -20815,6 +20952,10 @@ "hint.main_street_nav.msg": "Nyomd meg a kiemelt betűt egy lehetőség kiválasztásához. Például nyomd meg az 'I'-t a Fogadóhoz.", "hint.first_dungeon.title": "Tömlöc-tipp", "hint.first_dungeon.msg": "A szobákat az N/S/E/W (észak/dél/kelet/nyugat) gombokkal járhatod be. Harcolj szörnyekkel tapasztalatért és aranyért.", + "hint.lives.title": "Életeid", + "hint.lives.msg": "{0} életed van a(z) {1}-ból. Minden halál elvisz egyet, és az életerőd felével térsz vissza. Ha nincs több életed, a következő halál végleg törli a karaktert. Minden 10. szinten, vagy a Templom Visszatérés Rítusával visszanyerhetsz egy életet, legfeljebb {1}-ig.", + "hint.awakening.title": "Az Ébredésed", + "hint.awakening.msg": "Valami ébredezik benned. A veszteség, az irgalom és a csoda mélyíti: társak, egy legyőzött ellenfél megkímélése, álmok, látomások, felfedezések és az Ősi Istenek dalai. Minden szint kis, tartós áldást hoz. Az Óceán Naplódat a Főutcán a [P] Haladás alatt találod.", "hint.first_combat.title": "Harci tipp", "hint.first_combat.msg": "Nyomd meg az [A]-t a támadáshoz, az [R]-t a visszavonuláshoz, a [H]-t egy gyógyitalhoz, vagy az [S]-t az állapotod megtekintéséhez.", "hint.low_hp.title": "Egészségügyi figyelmeztetés", diff --git a/Localization/it.json b/Localization/it.json index c9a24392..21ad95b4 100644 --- a/Localization/it.json +++ b/Localization/it.json @@ -222,6 +222,7 @@ "main_street.menu_news_suffix": " Notizie ", "main_street.menu_fame_suffix": " Fama ", "main_street.menu_explore_suffix": " Esplora ", + "main_street.menu_events_suffix": " Eventi", "main_street.menu_status_suffix": " Stato ", "main_street.menu_quit_suffix": " Esci ", "main_street.menu_settings": "Impostazioni", @@ -483,13 +484,6 @@ "main_street.story_god_known": "Conosciuto", "main_street.story_god_hidden": "Da qualche parte nelle profondità...", "main_street.story_ocean_desc": " Il tuo risveglio spirituale attraverso dolore, sacrificio e comprensione", - "main_street.story_awakening_level": " Livello di Risveglio: {0}/5", - "main_street.story_awakening_0": "Non Risvegliato - Vedi solo la superficie delle cose", - "main_street.story_awakening_1": "Fremito - Qualcosa nel profondo inizia a muoversi", - "main_street.story_awakening_2": "Increspature - Percepisci connessioni tra tutte le cose", - "main_street.story_awakening_3": "Correnti - Le profondità ti chiamano con sussurri antichi", - "main_street.story_awakening_4": "Abissi - Comprendi il dolore dell'oceano", - "main_street.story_awakening_5": "Illuminato - Sei uno con la marea eterna", "main_street.story_grief_none": "In Pace", "main_street.story_grief_denial": "In Negazione - La perdita sembra irreale", "main_street.story_grief_anger": "Irato - Perché è successo?", @@ -669,6 +663,7 @@ "menu.action.help": "Aiuto", "menu.action.quit": "Esci", "menu.action.news": "Notizie", + "menu.action.world_events": "Eventi", "menu.action.lodging": "Alloggio (Dormitorio)", "menu.action.auction_house": "Aste", "menu.action.challenges": "Sfide", @@ -5948,7 +5943,7 @@ "combat.full_health": "Sei già al massimo della salute!", "combat.herb_empty": "La tua sacca di erbe è vuota!", "combat.defend_stance": "Assumi una posizione difensiva!", - "combat.auto_combat_on": "Combattimento automatico attivato! Attaccherai automaticamente ogni turno.", + "combat.auto_combat_on": "Combattimento automatico attivato! A ogni turno sferri un attacco base su un nemico a caso e bevi una pozione curativa se sei sotto metà dei PV. Non usa incantesimi né abilità.", "combat.auto_combat_off": "[COMBATTIMENTO AUTOMATICO] Fermato. Prendi il controllo manuale.", "combat.fatigue_dull": "(La stanchezza ottunde i tuoi riflessi)", "combat.slain_ambush": "Sei caduto nell'imboscata!", @@ -10752,7 +10747,7 @@ "_comment_opening_story": "=== Sistema Storia d'Apertura ===", "opening_story.skip_hint": "Premi SPAZIO per saltare avanti in qualsiasi momento", "opening_story.skip_hint_enter": "Premi INVIO per saltare avanti in qualsiasi momento", - "opening_story.the_awakening": "IL RISVEGLIO", + "opening_story.the_awakening": "L'ANNEGAMENTO", "opening_story.letter_title": "Una Lettera a Te Stesso", "opening_story.parchment_crumbles": "La pergamena si sgretola in polvere tra le tue mani.", "opening_story.words_burn": "Ma le parole bruciano nella tua mente.", @@ -10779,6 +10774,7 @@ "opening_story.dream_remember": "\"Ricorda...\"", "opening_story.dream_remember_what": "Ricordare cosa?", "opening_story.dream_remember_answer": "\"Ricorda ciò che sei.\"", + "opening_story.dream_asleep": "Qualcosa dentro di te dorme ancora. La perdita, la pietà e la meraviglia lo sveglieranno.", "opening_story.dream_fades": "La luce svanisce. L'oscurità ti avvolge.", "opening_story.dream_and_then": "E poi...", "_comment_opening_story_dormitory": "--- Scena del dormitorio ---", @@ -12928,6 +12924,50 @@ "team.recruit_wont_join": "(rifiuta)", "team.recruit_page_footer": "Visualizzazione {0}-{1} di {2} | Pagina {3}/{4}", "team.recruit_page_nav": "[N]Succ [P]Prec [S]Cerca [F]Filtro [#] Recluta [Q]Esci: ", + "team.unknown": "Sconosciuto", + "team.pick_team_row": "{0} - {1} membri, livello medio {2}, potenza {3}", + "team.pick_team_turf": "(controlla la città)", + "team.page_nav": "[N] Successivo [P] Precedente, Invio per tornare: ", + "team.pick_team_info_title": "Informazioni su quale squadra?", + "team.pick_team_join_title": "A quale squadra vuoi unirti?", + "team.join_team_full": "{0} è al completo (al massimo {1} membri).", + "team.news_formed": "{0} ha fondato una nuova squadra: '{1}'!", + "team.news_joined": "{0} si è unito alla squadra '{1}'!", + "team.news_left": "{0} ha lasciato la squadra '{1}'!", + "team.news_recruited": "{0} ha reclutato {1} nella squadra '{2}'!", + "team.news_sacked": "{0} è stato cacciato dalla squadra '{1}'!", + "team.news_resurrected": "{0} è stato resuscitato dalla sua squadra '{1}'!", + "team.pick_examine_title": "Esaminare quale membro?", + "team.pick_sack_title": "Cacciare quale membro?", + "team.pick_gone_tag": "(perso per sempre)", + "team.pick_player_tag": "(giocatore)", + "team.examine_stats3": " RES: {0} PotArma: {1} PotArmat: {2}", + "team.password_leader_only": "Solo il capo della squadra può cambiarne la password.", + "team.mail_push_tag": "[Posta]", + "team.sack_player_refused": "{0} è un giocatore. Un giocatore lascia la squadra da sé; non può essere cacciato.", + "team.sack_gear_warning": "{0} se ne andrà con tutto ciò che indossa.", + "team.sack_take_gear_prompt": "Prendere prima il suo equipaggiamento? (S/N): ", + "team.member_gone_now": "{0} non è più lì per questo.", + "team.sack_gear_gone": "{0} non fa più parte della tua squadra. Nessun equipaggiamento è stato preso.", + "team.pick_resurrect_row": "{0} ({1}) - costo {2} oro", + "team.confirm_resurrect": "Resuscitare {0} per {1} oro? (S/N): ", + "team.equip_no_items_for_slot": " Nessun oggetto disponibile per questo slot.", + "team.equip_identify_first": " Bisogna prima identificare l'oggetto.", + "team.equip_item_gone": " {0} non è più nel tuo zaino.", + "team.war_round_line": "{0} (Liv{1}) contro {2} (Liv{3}) ", + "team.war_no_rounds": "Nessun round è stato combattuto. La guerra è annullata e la tua posta di {0} oro ti viene restituita.", + "team.war_no_rounds_pending": "Nessun round è stato combattuto. Non è stato possibile chiudere la guerra ora; la tua posta di {0} oro ti sarà restituita alla sua scadenza.", + "team.war_result_pending": "Non è stato possibile registrare il risultato ora, quindi per il momento non si paga né si addebita nulla. La guerra sarà chiusa alla sua scadenza; la tua posta di {0} oro ti sarà restituita solo se nessuno dei suoi round è stato registrato.", + "team.war_already_closed": "Questa guerra era già stata chiusa prima che il suo risultato potesse essere registrato, quindi qui non si paga né si addebita nulla. Un eventuale rimborso dovuto arriva tramite bonifico bancario.", + "team.war_abandoned": "ABBANDONATA", + "team.upgrade_not_landed": "Il potenziamento non è andato a buon fine (qualcuno l'ha fatto prima, o il caveau non bastava). Non è stato addebitato nulla.", + "team.confirm_withdraw": "Prelevare {0} oro dal caveau della squadra? (S/N): ", + "team.bbs_inventories": "Inventari", + "team.spec_role_tank": "Tank", + "team.spec_role_dps": "Danno", + "team.spec_role_healer": "Guaritore", + "team.spec_role_utility": "Supporto", + "team.spec_role_debuff": "Indebolitore", "team.recruit_nav_nofilter": "[S]Cerca [F]Filtro [Q]Esci: ", "team.recruit_search_prompt": "Cerca per nome: ", "team.recruit_no_match": "Nessun NPC reclutabile corrisponde a '{0}'.", @@ -14458,6 +14498,7 @@ "dungeon.sanctuary_new_day": "Ti risvegli rinvigorito. Un nuovo giorno è sorto. (Giorno {0})", "dungeon.sanctuary_already_rested": "Ti sei già riposato su questo piano.", "dungeon.sanctuary_no_benefit": "Il santuario non offre ulteriori benefici.", + "dungeon.rest_once_per_floor": "Puoi riposare una sola volta per piano, in un santuario o accampandoti. Scendi di piano per riposare di nuovo.", "dungeon.vision_overtakes": "Una strana visione ti sopraffà...", "dungeon.vision_layout": "Vedi la struttura di questo piano!", "dungeon.rooms_revealed": "Tutte le stanze sono ora rivelate sulla tua mappa!", @@ -14677,7 +14718,7 @@ "path.gate_old_god": "un Dio Antico attende", "path.gate_seal": "un Sigillo Antico", "path.gate_line": "Piano {0}: {1}", - "base.training_points_hint": " Hai guadagnato punti addestramento! Visita il Maestro di Livello per spenderli.", + "base.training_points_hint": " Hai guadagnato punti addestramento! Visita il Maestro di Livello ([V] nella Via Principale) per spenderli.", "base.system_message": "*** MESSAGGIO DI SISTEMA ***", "base.world_boss_rampaging": " *** {0} sta seminando distruzione nel reame! Digita /boss per unirti alla battaglia! ***", "base.poison_damage_turns": "Il veleno scorre nelle tue vene! (-{0} PV, {1} turni rimasti)", @@ -14933,6 +14974,11 @@ "base.talking_to": "Parli con: {0}", "base.female": "Femmina", "base.male": "Maschio", + "base.pick_nav": "[#] Scegli, o digita un nome (Invio per annullare): ", + "base.pick_nav_pages": "[N] Successivo [P] Precedente [#] Scegli, o digita un nome (Invio per annullare): ", + "base.pick_matching": "Corrispondenze per \"{0}\":", + "base.pick_no_match": "Niente nell'elenco corrisponde a \"{0}\".", + "base.pick_no_number": "Non c'è il numero {0}; scegli da 1 a {1}.", "base.npc_says": " {0} dice:", "base.chat_with_them": " Chiacchiera con loro", "base.ask_rumors": " Chiedi delle voci", @@ -15028,6 +15074,102 @@ "base.awakening_enlightened": "Risvegliato", "base.awakening_awakened": "Destato", "base.stat_awakening": " Risveglio: {0} ({1}/7)", + "base.stat_awakening_hint": "(il tuo Diario dell'Oceano: [P] Progressi sulla Via Principale)", + "ocean.awakening_header": "RISVEGLIO", + "ocean.awakening_stage": "Stadio {0} di {1}: {2}", + "ocean.awakening_gained": "Ottieni: {0}", + "ocean.awakening_opens": "Ora aperto: {0}", + "ocean.boost.1": "+{0} Saggezza", + "ocean.boost.2": "+{0}% di mana massimo", + "ocean.boost.3": "+{0}% di esperienza dal combattimento", + "ocean.boost.4": "+{0}% di punti ferita massimi", + "ocean.boost.5": "+{0}% di danni inflitti", + "ocean.boost.6": "{0}% di danni subiti in meno", + "ocean.boost.7": "ogni dono in percentuale sale di {0} in più, e ti chiamano il Risvegliato", + "ocean.title_awakened": "il Risvegliato", + "ocean.title_awakened_line": "Titolo: il Risvegliato", + "ocean.journal_header": "DIARIO DELL'OCEANO", + "ocean.journal_stage": "Risveglio: stadio {0}/{1}, {2}", + "ocean.journal_points": "Punti di risveglio: {0}. Il prossimo stadio a {1}.", + "ocean.journal_points_last": "Punti di risveglio: {0}. L'ultimo stadio attende tutti e sette i sigilli, o la verità di chi sei.", + "ocean.journal_awake": "Sei sveglio. Il sogno continua, e sai di sognare.", + "ocean.journal_fragments": "Frammenti d'Onda trovati: {0}/{1}", + "ocean.journal_moments": "Momenti vissuti: {0}", + "ocean.journal_boons": "Doni: {0}", + "ocean.journal_open_key": "[J] qui sotto apre il Diario: la sapienza dei frammenti e i momenti vissuti.", + "ocean.journal_prompt": "[J] Diario dell'Oceano, qualsiasi altro tasto per tornare:", + "ocean.journal_hidden": "({0} frammenti restano nascosti...)", + "ocean.journal_no_moments": "Ancora nessuno.", + "ocean.fragment.Origin.title": "L'Origine", + "ocean.fragment.Origin.text": "In principio c'era solo l'Oceano: vasto, eterno, indiviso. Conosceva se stesso del tutto, perché non c'era altro da conoscere. Ma la conoscenza completa divenne solitudine completa.", + "ocean.fragment.FirstSeparation.title": "La Prima Separazione", + "ocean.fragment.FirstSeparation.text": "E così l'Oceano sognò le onde. Ogni onda si alzava credendosi separata e speciale. Ogni onda ricadeva, tornando a ciò che era sempre stata. L'Oceano imparò ad amare attraverso la perdita.", + "ocean.fragment.TheForgetting.title": "L'Oblio", + "ocean.fragment.TheForgetting.text": "Perché il sogno sembrasse reale, le onde dovettero dimenticare. La vera separazione richiede di crederci davvero. E così l'Oceano si nascose a se stesso.", + "ocean.fragment.ManwesChoice.title": "La Scelta di Manwe", + "ocean.fragment.ManwesChoice.text": "La prima e più grande onda scelse l'oblio più profondo. Divenne Manwe, Creatore dei Sette, Signore degli Dei. Dimenticò di essere ancora acqua. Si credette pietra.", + "ocean.fragment.TheSevenDrops.title": "Le Sette Gocce", + "ocean.fragment.TheSevenDrops.text": "Dall'oblio di Manwe nacquero altre sette onde: gli Antichi Dei. Veloura per l'amore, Thorgrim per la legge, Noctura per il mistero... Ognuna un volto del desiderio dell'Oceano di comprendere se stesso.", + "ocean.fragment.TheCorruption.title": "La Corruzione", + "ocean.fragment.TheCorruption.text": "Ma la fede nella separazione divenne dolore. Le onde si combatterono, dimenticando di condividere la stessa fonte. La guerra è acqua che combatte l'acqua. La crudeltà è l'oceano che ferisce se stesso.", + "ocean.fragment.TheCycle.title": "Il Ciclo Eterno", + "ocean.fragment.TheCycle.text": "Manwe si stancò di vedere onde distruggere onde. Disperato, mandò un frammento di sé a imparare ciò che aveva dimenticato. Quel frammento si sveglia in un dormitorio, senza ricordare nulla...", + "ocean.fragment.TheReturn.title": "Il Ritorno", + "ocean.fragment.TheReturn.text": "La morte non è una fine. È un'onda che torna acqua. L'individuo si dissolve, ma l'essenza ritorna alla fonte. Non perdiamo ciò che amiamo: lo diventiamo.", + "ocean.fragment.TheTruth.title": "La Verità Finale", + "ocean.fragment.TheTruth.text": "Non sei un'onda che combatte l'oceano. TU SEI l'oceano, che sogna di essere un'onda. Quando ti sveglierai non scomparirai: diventerai tutto ciò che hai amato.", + "ocean.fragment.TheChoice.title": "La Scelta", + "ocean.fragment.TheChoice.text": "Il sognatore può scegliere: continuare il sogno, o svegliarsi. Svegliarsi non è morire: è la fine della solitudine. Ma il sogno ha la sua bellezza, il suo scopo...", + "ocean.moment.FirstCompanionDeath": "Hai perso un compagno.", + "ocean.moment.SacrificedForAnother": "Un compagno si è unito al tuo cammino.", + "ocean.moment.SparedAnEnemy": "Hai risparmiato un nemico sconfitto.", + "ocean.moment.MetManwe": "Hai incontrato il Creatore.", + "ocean.moment.AllSealsCollected": "Hai riunito tutti e sette i sigilli.", + "ocean.moment.MemoriesRecovered": "I tuoi ricordi sono tornati.", + "ocean.moment.TrueIdentityRevealed": "Hai ricordato chi sei.", + "ocean.moment.LetGoOfPower": "Hai rinunciato al potere.", + "ocean.moment.AcceptedDeath": "Hai affrontato la morte senza paura.", + "ocean.moment.CompanionSacrifice": "Un compagno ha dato la vita per te.", + "ocean.moment.ForgaveBetrayerMercy": "Hai perdonato chi ti ha tradito.", + "ocean.moment.AcceptedGrief": "Il tuo lutto è diventato accettazione.", + "ocean.moment.RejectedParadise": "Hai rifiutato un falso paradiso.", + "ocean.moment.AbsorbedDarkness": "Hai preso in te l'oscurità del mondo.", + "ocean.moment.HeardOldGodLoreSong": "Hai ascoltato un canto degli Antichi Dei.", + "ocean.stage.1.lore.1": "Qualcosa si muove in te, come acqua sotto il ghiaccio.", + "ocean.stage.1.lore.2": "Il mondo sembrava solido. Ora, ai bordi, si muove.", + "ocean.stage.1.lore.3": "La perdita, la pietà o la meraviglia hanno toccato qualcosa di più antico di te.", + "ocean.stage.1.lore.4": "Non sai ancora cosa si sta svegliando.", + "ocean.stage.1.opens": "Nuovi sogni possono trovarti quando riposi.", + "ocean.stage.2.lore.1": "Un'onda si alza e crede di essere sola.", + "ocean.stage.2.lore.2": "Ma ogni onda è sollevata dallo stesso mare.", + "ocean.stage.2.lore.3": "Quelli che combatti e quelli che ami sono fatti della stessa acqua.", + "ocean.stage.2.lore.4": "Cominci a sentire la corrente.", + "ocean.stage.2.opens": "Il Tocco dell'Oceano, il sesto incantamento del Negozio di Magia, e il primo sogno dell'Oceano.", + "ocean.stage.3.lore.1": "La separazione fu il primo sogno.", + "ocean.stage.3.lore.2": "L'Oceano sognò le onde per poter conoscere se stesso.", + "ocean.stage.3.lore.3": "Per sentirsi separate, le onde dovettero dimenticare.", + "ocean.stage.3.lore.4": "Anche tu hai dimenticato qualcosa.", + "ocean.stage.3.opens": "Una riflessione più profonda dopo le scelte difficili, nuovi sogni e la visione della sala degli specchi.", + "ocean.stage.4.lore.1": "Gli Antichi Dei non sono estranei.", + "ocean.stage.4.lore.2": "Sono gocce della stessa acqua, spezzate da un lungo oblio.", + "ocean.stage.4.lore.3": "Le loro guerre sono l'oceano che combatte se stesso.", + "ocean.stage.4.lore.4": "Quando li affronti, affronti la tua famiglia.", + "ocean.stage.4.opens": "Il sogno della creazione, gli enigmi dell'oceano alle porte degli enigmi e nuove visioni.", + "ocean.stage.5.lore.1": "La morte non è una fine. È un'onda che torna acqua.", + "ocean.stage.5.lore.2": "Ciò che hai perso non se n'è andato. È tornato.", + "ocean.stage.5.lore.3": "Aggrapparsi trasforma l'onda in schiuma.", + "ocean.stage.5.lore.4": "Le mani aperte contengono il mare.", + "ocean.stage.5.opens": "Il sogno del Creatore, il suono dell'oceano nelle visioni più profonde e gli ultimi frammenti di verità.", + "ocean.stage.6.lore.1": "Qualcuno ha sognato questo mondo e si è stancato del sogno.", + "ocean.stage.6.lore.2": "La prima e più grande onda dimenticò di essere acqua e si credette pietra.", + "ocean.stage.6.lore.3": "Mandò una parte di sé a ricordare.", + "ocean.stage.6.lore.4": "Si svegliò in un dormitorio senza ricordare nulla. Ora sta ricordando.", + "ocean.stage.6.opens": "L'ultimo stadio attende oltre i sigilli, o nella verità di chi sei.", + "ocean.stage.7.lore.1": "Non sei un'onda che combatte l'oceano.", + "ocean.stage.7.lore.2": "TU SEI l'oceano, che sogna di essere un'onda.", + "ocean.stage.7.lore.3": "Sei Manwe, il sognatore che aveva dimenticato, tornato a te stesso.", + "ocean.stage.7.lore.4": "Il sogno continua. Ma ora sai di sognare.", + "ocean.stage.7.opens": "Il vero finale ti è aperto.", "base.stat_gold_hand": "Oro in Mano: ", "base.stat_gold_bank": "Oro in Banca: ", "base.stat_total_wealth": "Ricchezza Totale: ", @@ -15584,7 +15726,7 @@ "dungeon.seal_hidden_town": "Nascosto nelle profondità del dungeon", "dungeon.seal_hidden_dungeon": "Nascosto nelle profondità del dungeon", "dungeon.seal_not_collected_sr": " Non raccolto: {0} - {1}", - "dungeon.chapter_awakening": "Il Risveglio — Uno straniero a Dorashire", + "dungeon.chapter_awakening": "L'Annegamento - Uno straniero a Dorashire", "dungeon.chapter_first_blood": "Primo Sangue — Imparare a combattere", "dungeon.chapter_stranger": "Lo Straniero — Incontrare la guida misteriosa", "dungeon.chapter_faction": "Scelta della Fazione — Scegliere la tua alleanza", @@ -16162,7 +16304,8 @@ "inventory.cant_equip_unidentified": "Non puoi equipaggiare un oggetto non identificato.", "inventory.visit_magic_shop": "Portalo al Negozio Magico per farlo identificare.", "inventory.cursed_cant_drop": "Il {0} è MALEDETTO! Non puoi gettarlo.", - "inventory.visit_healer_curse": "Visita il Guaritore per far rimuovere la maledizione.", + "inventory.visit_healer_curse": "Visita il Negozio di Magia per far rimuovere la maledizione.", + "inventory.cursed_equip_warning": "Questo oggetto è MALEDETTO. Una volta equipaggiato, non potrai toglierlo finché la maledizione non sarà rimossa.", "inventory.dropped_item": "Getti il {0}.", "inventory.cant_equip": "Questo oggetto non può essere equipaggiato.", "inventory.which_finger": "A quale dito equipaggiare?", @@ -16516,13 +16659,6 @@ "main_street.align_questionable": "Discutibile", "main_street.align_usurper": "Usurper - Incarnazione delle Tenebre", "main_street.align_villain": "Malvagio", - "main_street.awakening_0": "Non Risvegliato - Vedi solo la superficie delle cose", - "main_street.awakening_1": "Fremito - Qualcosa nel profondo inizia a muoversi", - "main_street.awakening_2": "Increspature - Percepisci connessioni tra tutte le cose", - "main_street.awakening_3": "Correnti - Le profondità ti chiamano con sussurri antichi", - "main_street.awakening_4": "Abissi - Comprendi il dolore dell'oceano", - "main_street.awakening_5": "Illuminato - Sei uno con la marea eterna", - "main_street.awakening_level": "Livello di Risveglio: {0}/5", "main_street.awakening_unknown": "Sconosciuto", "main_street.chivalry_label": "Cavalleria:", "main_street.dark_clean": "Fedina pulita", @@ -19379,6 +19515,7 @@ "stats.cha": "CAR", "stats.agi": "AGI", "stats.def": "DIF", + "stats.sta": "RES", "dungeon.full": "Pieno", "dungeon.follower_drink_potion": " Bevi una pozione curativa e recuperi {0:N0} PV!", "base.royal_guards_surround": "Le guardie reali ti circondano!", @@ -20511,6 +20648,10 @@ "hint.main_street_nav.msg": "Premi la lettera evidenziata per scegliere un'opzione. Ad esempio, premi 'I' per la Locanda.", "hint.first_dungeon.title": "Consiglio sul sotterraneo", "hint.first_dungeon.msg": "Esplora le stanze con N/S/E/W (Nord/Sud/Est/Ovest). Combatti i mostri per guadagnare XP e oro.", + "hint.lives.title": "Le tue vite", + "hint.lives.msg": "Hai {0} vite su {1}. Ogni morte ne consuma una e torni in vita con metà dei PV. Senza vite, la morte successiva cancella il personaggio per sempre. Recuperi una vita ogni 10 livelli, o con il Rito del Ritorno del Tempio, fino a {1}.", + "hint.awakening.title": "Il tuo Risveglio", + "hint.awakening.msg": "Qualcosa dentro di te si sta risvegliando. La perdita, la pietà e la meraviglia lo approfondiscono: compagni, risparmiare un nemico sconfitto, sogni, visioni, scoperte e i canti degli Antichi Dei. Ogni stadio porta un piccolo dono duraturo. Vedi [P] Progressi sulla Via Principale per il tuo Diario dell'Oceano.", "hint.first_combat.title": "Consiglio di combattimento", "hint.first_combat.msg": "Premi [A] per Attaccare, [R] per Ritirarti, [H] per una Pozione di cura, o [S] per controllare il tuo Stato.", "hint.low_hp.title": "Avviso di salute", diff --git a/README.md b/README.md index 38e50532..5d041d9f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## A Persistent Online Text RPG with a Living World -**v1.1.11 "Regalia"** | **FREE AND OPEN SOURCE** | **GPL v2** +**v1.1.12 "Regalia"** | **FREE AND OPEN SOURCE** | **GPL v2** 130+ autonomous NPCs wake up, go to work, visit taverns, fall in love, get married, have children, age, and eventually die of old age, all while you're offline. Log back in, read the news feed, and discover that the blacksmith married the barmaid, the king was assassinated, or a new generation just came of age. The world doesn't wait for you. @@ -282,6 +282,7 @@ The game ships small patches frequently. Each version has a dedicated release no - **v1.1.1:** a bug pass. Five review agents each took a domain of the codebase and about seventy findings were verified and fixed: NPCs losing their innate power on load, the world simulator editing the wrong player's relationships, a restored character unable to save, one player's autosave starving everyone else's, the Black Market re-rolling on relog, a closed connection spinning the server, bank and gambling exploits, buffs consumed a fight early, and raw placeholders in the text. See `DOCS/release-notes/RELEASE_NOTES_1.1.1.md`. - **v1.1.2:** seven of the eight open design items, each designed twice (Codex and Claude), reconciled against the code, and reviewed before implementation: grouped followers get their own cooldowns and a real death; haggling finally has a way in and its attempts persist; the bank vault is one persisted reserve per world, atomic online; relationships cool with neglect measured in days you were present; NPCs left to die while you held a heal remember it; ability and spell numbers are moddable from `GameData/`; the two intimacy lines that really dropped a name are fixed. Docker stack refreshed and verified. See `DOCS/release-notes/RELEASE_NOTES_1.1.2.md`. - **v1.1.3:** party survivability. Wounded allies shield up, brace (half damage on ordinary hits, specials, and life drain), and drink their own potion first; NPC allies who die in your party roll the 2 percent team permadeath rate they were always meant to; three stances per ally (Aggressive, Balanced, Cautious) set from the dungeon party menu or the Inn and saved with the character; monsters no longer prefer a wounded target and an ally's brace no longer pulls hits unless it is Aggressive; a shared potion belt (off by default, two loans per fight, never the player's last three), give-a-number, and the one-personal-potion rule; a fight summary per ally, a warning before a voluntary fight with an ally below 30 percent, and a floor guard that offers Cautious to an ally eleven levels behind. Planned by a council of Codex, a Claude design agent, and the supervisor session in `DOCS/PARTY_SURVIVABILITY_PLAN.md`; the downed state is the next release. +- **v1.1.12:** Awakening grows in seven shown stages with a lasting gift at each, more of what you live through counts, and [P] Progress opens the Ocean Journal; chapter 1 is The Drowning. The Team Corner picks from lists, its rankings load at once with real numbers, and its passwords, dissolving, wars, vault, gear moves and team size are fixed. NPC teammates no longer attack their sleeping player, plus playtest fixes to reward labels, cursed items, rests, lives and gear comparisons. See `DOCS/release-notes/RELEASE_NOTES_1.1.12.md`. - **v1.1.11:** Team HQ upgrades reach every hit, potion and XP award and follow the team you are in; rankings count real members and leadership passes on when a leader leaves. WANTED targets count wherever they are beaten, and Crown bounties on players pay the duel winner. A same-name character starts clean, and deleting a character ends its reign and clears its mail and auctions. An Old God kill pays once, wilderness gold finds pay on the dungeon scale, and Potion Mastery works on every healing potion. See `DOCS/release-notes/RELEASE_NOTES_1.1.11.md`. - **v1.1.10:** Mael'Keth is tuned for a level-30s party, the five floors before every Old God have tougher monsters, a god's power surge lasts three rounds, its intro shows its fight HP, and dialogue answers apply. Companions are protected from special attacks like normal hits and tanks shield before they taunt. Items waiting for pack room reach alts, cannot be lost, and arrive on /boss; bequests from teams reach their leader, with an admin screen to set the leader of older teams. The Inn lists every patron, PvP control can no longer chain, and Team HQ upgrades cost what the menu shows. See `DOCS/release-notes/RELEASE_NOTES_1.1.10.md`. - **v1.1.9:** two player reports. The feet slot offers boots, not weapons, and armor "of Shielding" is no longer labelled a shield. Gear sets count the gear you already have: pieces from before v1.1.0 never recorded which template they dropped as, so the same Leather Cap could count or not depending on when it dropped. That template is now read from the name, never written into the save, and only when the name is one the game really generates for that piece, in any language and with any enchantment, compared against every item so the closest match wins. On the live server that switches on or raises 118 player set bonuses. See `DOCS/release-notes/RELEASE_NOTES_1.1.9.md`. @@ -342,7 +343,7 @@ Join Discord for discussions, feedback, and updates: **https://discord.gg/EZhwgD *"You are not a wave fighting the ocean. You ARE the ocean, dreaming of being a wave."* -## Known Issues (v1.1.11) +## Known Issues (v1.1.12) - Save files from the earliest alpha versions may not be fully compatible. - BBS FOSSIL mode not natively supported (use `--stdio` flag for FOSSIL-based BBSes via host pipe). @@ -356,4 +357,4 @@ Join Discord for discussions, feedback, and updates: **https://discord.gg/EZhwgD --- -**Status:** v1.1.11 "Regalia". The world is running. [Watch it live.](https://usurper-reborn.net) +**Status:** v1.1.12 "Regalia". The world is running. [Watch it live.](https://usurper-reborn.net) diff --git a/Scripts/Core/Character.cs b/Scripts/Core/Character.cs index c0a26556..1da9a98c 100644 --- a/Scripts/Core/Character.cs +++ b/Scripts/Core/Character.cs @@ -358,6 +358,9 @@ public void TickDivineWrath() // Player echo (loaded from DB for cooperative dungeons) public bool IsEcho { get; set; } = false; + /// v1.1.12: an echo's save key (players.username), the entry the recruit list keeps. Runtime only. + [System.Text.Json.Serialization.JsonIgnore] + public string EchoSaveKey { get; set; } = ""; // Royal mercenary (hired bodyguard for king's dungeon party) public bool IsMercenary { get; set; } = false; @@ -601,6 +604,9 @@ public void AddMaterial(string materialId, int count = 1) /// v1.1.11: built from a real player's save (PlayerCharacterLoader), not a hired guard or an echo. Run time only. [System.Text.Json.Serialization.JsonIgnore] public bool IsLoadedPlayer { get; set; } + /// v1.1.12: this player's own awakening stage, stamped by their session (-1 = not stamped). Run time only. + [System.Text.Json.Serialization.JsonIgnore] + public int AwakeningStage { get; set; } = -1; // Herb pouch inventory (v0.48.5) public int HerbHealing { get; set; } // Healing Herbs (garden lv1) @@ -1667,6 +1673,10 @@ public void RecalculateStats() // 2026-09-03). Placed before the CON-to-HP line so a set CON bonus flows into MaxHP. UsurperRemake.Systems.GearSetRegistry.Apply(this); + // v1.1.12: the awakening's Wisdom, added like gear Wisdom so it flows into mana; never stored + int awakeningStage = UsurperRemake.Systems.AwakeningBonus.StageOf(this); + Wisdom += UsurperRemake.Systems.AwakeningBonus.WisdomAt(awakeningStage); + // Apply stat-based bonuses AFTER equipment (stats may have been modified) // Constitution bonus to HP MaxHP += StatEffectsSystem.GetConstitutionHPBonus(Constitution, Level); @@ -1709,6 +1719,12 @@ public void RecalculateStats() MaxMana += (long)(MaxMana * CachedBoonEffects.MaxManaPercent); } + // v1.1.12: the awakening's max HP and max mana percentages, beside the divine boons + double awakeningHP = UsurperRemake.Systems.AwakeningBonus.HPAt(awakeningStage); + if (awakeningHP > 0) MaxHP += (long)(MaxHP * awakeningHP); + double awakeningMana = UsurperRemake.Systems.AwakeningBonus.ManaAt(awakeningStage); + if (awakeningMana > 0 && MaxMana > 0) MaxMana += (long)(MaxMana * awakeningMana); + // Apply Fountain of Vitality bonus HP if (BonusMaxHP > 0) { diff --git a/Scripts/Core/GameConfig.cs b/Scripts/Core/GameConfig.cs index e612b35e..8f831858 100644 --- a/Scripts/Core/GameConfig.cs +++ b/Scripts/Core/GameConfig.cs @@ -10,7 +10,7 @@ public static partial class GameConfig { // Version information - public const string Version = "1.1.11"; + public const string Version = "1.1.12"; public const string VersionName = "Regalia"; // 1.1 line: the gear and reward loop // v0.57.12: Alignment scale cap. Character.Chivalry and Character.Darkness setters clamp to [0, AlignmentCap] @@ -1857,6 +1857,12 @@ public static bool IsSmallRace(CharacterRace race) public const int EmptyTeamGraceMinutes = 30; // v1.1.11: after a join, the team is left alone this long so the joiner's save can land (every process) public const int EmptyTeamJoinGraceMinutes = 10; + // v1.1.12: a team war still 'active' after this long was left by a lost session (a war takes seconds) + public const int TeamWarStaleMinutes = 10; + // v1.1.12: the highest level of a team headquarters facility, and the vault's size (base + per level) + public const int MaxTeamFacilityLevel = 10; + public const long TeamVaultBaseCapacity = 50000; + public const long TeamVaultCapacityPerLevel = 50000; // v1.1.10: regular dungeon monsters on the floors before an Old God's floor grow tougher, HP and // damage, by this much per floor: +5% five floors out, up to +25% on the floor before the god @@ -2368,6 +2374,25 @@ public static string GetLocalizedClassName(int classId) /// Overload taking the enum directly. public static string GetLocalizedClassName(CharacterClass cls) => GetLocalizedClassName((int)cls); + /// v1.1.12: the race name in the session language (the race.* keys); the enum name if unknown. + public static string GetLocalizedRaceName(CharacterRace race) => race switch + { + CharacterRace.Human => Loc.Get("race.human"), + CharacterRace.Hobbit => Loc.Get("race.hobbit"), + CharacterRace.Elf => Loc.Get("race.elf"), + CharacterRace.HalfElf => Loc.Get("race.half_elf"), + CharacterRace.Dwarf => Loc.Get("race.dwarf"), + CharacterRace.Troll => Loc.Get("race.troll"), + CharacterRace.Orc => Loc.Get("race.orc"), + CharacterRace.Gnome => Loc.Get("race.gnome"), + CharacterRace.Gnoll => Loc.Get("race.gnoll"), + CharacterRace.Mutant => Loc.Get("race.mutant"), + _ => race.ToString(), + }; + + /// v1.1.12: Male or Female in the session language. + public static string GetLocalizedSexName(CharacterSex sex) => sex == CharacterSex.Male ? Loc.Get("base.male") : Loc.Get("base.female"); + /// /// Returns the localized display name of an equipment slot (e.g. "Main Hand", /// "Off Hand", "Left Ring") in the current session language. Falls back to diff --git a/Scripts/Core/GameEngine.cs b/Scripts/Core/GameEngine.cs index c8b0839b..7b76e71f 100644 --- a/Scripts/Core/GameEngine.cs +++ b/Scripts/Core/GameEngine.cs @@ -2845,6 +2845,7 @@ private async Task LoadSaveByFileName(string fileName) // entry for a deleted same-name character was restored with any save. string? godRestoreFilter = GodRestoreFilterFor(currentPlayer); SaveSystem.Instance.RestoreStorySystems(saveData.StorySystems, godRestoreFilter); + AwakeningBonus.RecalculateAfterRestore(currentPlayer, saveData.Player?.HP ?? 0, saveData.Player?.Mana ?? 0); // v1.1.12 // Migration: sync RelationshipSystem with RomanceTracker for saves affected by // the bidirectional key bug (pre-v0.42.4). If RomanceTracker says Lover/Spouse/FWB @@ -4343,19 +4344,29 @@ internal static async Task DeliverPendingInheritance(Character player, Term /// before the normal session save persists the credit loses the gold to the /// sink rather than duplicating it. /// + /// + /// v1.1.12: the key pending_gold_transfers are queued and delivered under: the save key of the character + /// being played (InheritanceKey). Every producer queues under players.username, which is the save key + /// ("name__alt" for an alt); the account name used here gave an alt's rows to its main. + /// + internal static string GoldTransferKey(Character player) => player == null ? "" : InheritanceKey(player); + private async Task DeliverPendingGoldTransfers(SqlSaveBackend backend) { if (currentPlayer == null) return; - var username = UsurperRemake.Server.SessionContext.Current?.Username - ?? UsurperRemake.BBS.DoorMode.GetPlayerName()?.ToLowerInvariant() - ?? currentPlayer.Name2?.ToLowerInvariant() - ?? ""; - if (string.IsNullOrEmpty(username)) return; + await DeliverPendingGoldTransfers(currentPlayer, terminal, backend); + } + + /// v1.1.12: login delivery of queued transfers to this character's bank; returns the gold delivered. + internal static async Task DeliverPendingGoldTransfers(Character currentPlayer, TerminalEmulator terminal, SqlSaveBackend backend) + { + var username = GoldTransferKey(currentPlayer); + if (string.IsNullOrEmpty(username)) return 0; try { var pending = backend.GetPendingGoldTransfers(username); - if (pending.Count == 0) return; + if (pending.Count == 0) return 0; var deliveredIds = new List(); long totalReceived = 0; @@ -4385,11 +4396,13 @@ private async Task DeliverPendingGoldTransfers(SqlSaveBackend backend) backend.ClearGoldTransfers(deliveredIds); DebugLogger.Instance.LogInfo("GOLD", $"WIRE DELIVERY: '{username}' received {totalReceived:N0}g to bank from {pending.Count} transfer(s)"); await Task.Delay(1500); + return totalReceived; } catch (Exception ex) { DebugLogger.Instance.LogError("WIRE", $"Failed to deliver pending gold transfers: {ex.Message}"); } + return 0; } /// @@ -4706,6 +4719,7 @@ private async Task LoadExistingGame(string playerName) // snapshots would overwrite their current worship choices in the shared GodSystem. string? godFilter = GodRestoreFilterFor(currentPlayer); // v1.1.11: single-player too SaveSystem.Instance.RestoreStorySystems(saveData.StorySystems, godFilter); + AwakeningBonus.RecalculateAfterRestore(currentPlayer, saveData.Player?.HP ?? 0, saveData.Player?.Mana ?? 0); // v1.1.12 // In online mode, override royal court, children, and marriages with world_state // (authoritative source). RestoreStorySystems loaded stale data from the player's @@ -6336,6 +6350,7 @@ private Character RestorePlayerFromSaveDataCore(PlayerData playerData) IsPermanentlyClear = saved.IsPermanentlyClear, BossDefeated = saved.BossDefeated, CompletionBonusAwarded = saved.CompletionBonusAwarded, + RestedOnThisFloor = saved.RestedOnThisFloor, CurrentRoomId = saved.CurrentRoomId, RoomStates = new Dictionary() }; diff --git a/Scripts/Locations/ArenaLocation.cs b/Scripts/Locations/ArenaLocation.cs index 205bc320..14668b33 100644 --- a/Scripts/Locations/ArenaLocation.cs +++ b/Scripts/Locations/ArenaLocation.cs @@ -343,7 +343,7 @@ private async Task ChooseOpponent() return; } - var opponent = CreateCombatCharacterFromSave(opponentSave.Player, target.DisplayName); + var opponent = CreateCombatCharacterFromSave(opponentSave.Player, target.DisplayName, opponentSave.StorySystems); // Save defender's actual gold for 10% steal calculation, then zero // to prevent CombatEngine from applying its own 50% gold steal @@ -369,9 +369,9 @@ private async Task ChooseOpponent() /// Create a combat-ready Character from saved PlayerData. /// Delegates to the shared PlayerCharacterLoader utility. /// - private Character CreateCombatCharacterFromSave(PlayerData playerData, string displayName) + private Character CreateCombatCharacterFromSave(PlayerData playerData, string displayName, StorySystemsData? story) { - return PlayerCharacterLoader.CreateFromSaveData(playerData, displayName); + return PlayerCharacterLoader.CreateFromSaveData(playerData, displayName, story: story); // v1.1.12: its own awakening } /// diff --git a/Scripts/Locations/BaseLocation.cs b/Scripts/Locations/BaseLocation.cs index 5791c346..65963243 100644 --- a/Scripts/Locations/BaseLocation.cs +++ b/Scripts/Locations/BaseLocation.cs @@ -740,6 +740,9 @@ protected virtual async Task LocationLoop() terminal.WriteLine(""); } + // v1.1.12: an awakening stage risen since the last menu, shown once at this clean boundary + await AwakeningScreens.ShowPending(terminal, currentPlayer); + // Show deferred daily reset banner at a clean display boundary // (instead of mid-shop or mid-interaction where PeriodicUpdate fires) if (DailySystemManager.Instance.PendingDailyResetDisplay) @@ -1422,7 +1425,7 @@ private async Task DisplayTownNPCEncounter(MemorableNPCData npc, NPCStoryStage s // Apply awakening gain if (stage.AwakeningGain > 0) { - OceanPhilosophySystem.Instance.GainInsight(stage.AwakeningGain * 10); + OceanPhilosophySystem.Instance.GainInsight($"npc:{npcKey}:{stage.StageId}"); // v1.1.12: one insight per story stage terminal.SetColor("magenta"); terminal.WriteLine(Loc.Get("base.deeper_understanding")); } @@ -5037,6 +5040,88 @@ protected void WriteSRMenuOption(string key, string label, bool available = true terminal.WriteLine(label); } + /// + /// v1.1.12: choose one entry from a list, shaped on the Inn patron list. Pages of 10, numbers count + /// across pages, N/P page, a typed name or the unique start of one also chooses (an exact name wins + /// over a longer one), a part that fits several lists only those, and Enter or 0 cancels (with a name + /// filter on, Enter first brings the full list back). Null when cancelled or the list is empty. + /// + protected internal async Task PickFromList(IReadOnlyList items, Func rowFormatter, + Func nameSelector, string titleKey, Func? rowColor = null, params object[] titleArgs) where T : class + { + if (items == null || items.Count == 0) return null; + const int pageSize = 10; + var all = items.ToList(); + var shown = all; + string? filter = null; + int pageIndex = 0; + while (true) + { + int totalPages = Math.Max(1, (shown.Count + pageSize - 1) / pageSize); + pageIndex = Math.Clamp(pageIndex, 0, totalPages - 1); + var pageRows = shown.Skip(pageIndex * pageSize).Take(pageSize).ToList(); + + terminal.ClearScreen(); + WriteSectionHeader(Loc.Get(titleKey, titleArgs), "bright_cyan"); + terminal.WriteLine(""); + if (filter != null) + { + terminal.SetColor("white"); + terminal.WriteLine(Loc.Get("base.pick_matching", filter)); + terminal.WriteLine(""); + } + for (int i = 0; i < pageRows.Count; i++) + { + int number = pageIndex * pageSize + i + 1; + terminal.SetColor(rowColor?.Invoke(pageRows[i]) ?? "white"); + terminal.WriteLine(IsScreenReader ? $" {number}. {rowFormatter(pageRows[i])}" : $" [{number}] {rowFormatter(pageRows[i])}"); + } + terminal.WriteLine(""); + if (totalPages > 1) + { + terminal.SetColor("darkgray"); + terminal.WriteLine(Loc.Get("team.recruit_page_footer", pageIndex * pageSize + 1, pageIndex * pageSize + pageRows.Count, shown.Count, pageIndex + 1, totalPages)); + } + terminal.SetColor("bright_yellow"); + terminal.WriteLine(IsScreenReader ? $"0. {Loc.Get("ui.cancel")}" : $"[0] {Loc.Get("ui.cancel")}"); + terminal.WriteLine(""); + + string input = ((await terminal.GetInput(Loc.Get(totalPages > 1 ? "base.pick_nav_pages" : "base.pick_nav"))) ?? "").Trim(); + if (input.Length == 0 && filter != null) { shown = all; filter = null; pageIndex = 0; continue; } + if (input.Length == 0 || input == "0") return null; + string upper = input.ToUpperInvariant(); + if (upper == "N" && totalPages > 1) { if (pageIndex + 1 < totalPages) pageIndex++; continue; } + if (upper == "P" && totalPages > 1) { if (pageIndex > 0) pageIndex--; continue; } + + if (int.TryParse(input, out int pick)) + { + if (pick >= 1 && pick <= shown.Count) return shown[pick - 1]; + terminal.SetColor("gray"); + terminal.WriteLine(Loc.Get("base.pick_no_number", pick, shown.Count)); + await terminal.PressAnyKey(); + continue; + } + + // v1.1.12: a name typed in its exact case wins, so two names differing only in case can each be chosen + var sameCase = all.Where(x => string.Equals(nameSelector(x) ?? "", input, StringComparison.Ordinal)).ToList(); + if (sameCase.Count == 1) return sameCase[0]; + var exact = all.Where(x => string.Equals(nameSelector(x) ?? "", input, StringComparison.OrdinalIgnoreCase)).ToList(); + if (exact.Count == 1) return exact[0]; + var matches = all.Where(x => (nameSelector(x) ?? "").StartsWith(input, StringComparison.OrdinalIgnoreCase)).ToList(); + if (matches.Count == 0) + matches = all.Where(x => (nameSelector(x) ?? "").Contains(input, StringComparison.OrdinalIgnoreCase)).ToList(); + if (matches.Count == 1) return matches[0]; + if (matches.Count == 0) + { + terminal.SetColor("gray"); + terminal.WriteLine(Loc.Get("base.pick_no_match", input)); + await terminal.PressAnyKey(); + continue; + } + shown = matches; filter = input; pageIndex = 0; + } + } + /// /// Check if we're running inside WezTerm (which supports font switching). /// @@ -6935,21 +7020,19 @@ protected virtual async Task ShowStatus() if (ocean != null) { var awakeningLevel = ocean.AwakeningLevel; - var awakeningLabel = awakeningLevel switch - { - 0 => Loc.Get("base.awakening_dormant"), - 1 => Loc.Get("base.awakening_stirring"), - 2 => Loc.Get("base.awakening_aware"), - 3 => Loc.Get("base.awakening_seeking"), - 4 => Loc.Get("base.awakening_illuminated"), - 5 => Loc.Get("base.awakening_transcendent"), - 6 => Loc.Get("base.awakening_enlightened"), - 7 => Loc.Get("base.awakening_awakened"), - _ => Loc.Get("base.awakening_dormant") - }; + var awakeningLabel = AwakeningScreens.StageName(awakeningLevel); // v1.1.12: one set of labels terminal.SetColor("dark_magenta"); terminal.WriteLine(Loc.Get("base.stat_awakening", awakeningLabel, awakeningLevel)); + var awakeningBoons = AwakeningBonus.ActiveAt(awakeningLevel); // v1.1.12 + if (awakeningBoons.Count > 0) + { + terminal.SetColor("bright_green"); + terminal.WriteLine($" {Loc.Get("ocean.journal_boons", string.Join(", ", awakeningBoons))}"); + } + terminal.SetColor("gray"); + terminal.WriteLine($" {Loc.Get("base.stat_awakening_hint")}"); // v1.1.12 terminal.SetColor("white"); + HintSystem.Instance.TryShowHint(HintSystem.HINT_AWAKENING, terminal, currentPlayer?.HintsShown); // v1.1.12 terminal.WriteLine(""); } @@ -10432,6 +10515,43 @@ protected void DisplayEquipmentSlotWithStats(Character target, EquipmentSlot slo return null; } + /// v1.1.12: test hook; when set, the gear saves below call it ("shared" or "player") instead of saving. + internal static Func? GearSaveHookForTests; + + /// v1.1.12: gear moved from an NPC to the player: the shared NPC state is saved first, then the + /// player, so a crash between loses the item rather than copying it. + protected internal async Task SaveGearTakenFromNpc(Character? npc) + { + if (npc != null) CombatEngine.SyncNPCTeammateToActiveNPCs(npc); + await SaveSharedStateForGear(); + await SavePlayerForGear(); + } + + /// v1.1.12: gear moved from the player to an NPC: the player is saved first, then the shared NPC + /// state, for the same reason. + protected internal async Task SaveGearGivenToNpc(Character? npc) + { + await SavePlayerForGear(); + if (npc != null) CombatEngine.SyncNPCTeammateToActiveNPCs(npc); + await SaveSharedStateForGear(); + } + + private async Task SaveSharedStateForGear() + { + if (GearSaveHookForTests != null) { await GearSaveHookForTests("shared"); return; } + if (!DoorMode.IsOnlineMode || OnlineStateManager.Instance == null) return; + try { await OnlineStateManager.Instance.SaveAllSharedState(); } + catch (Exception ex) { DebugLogger.Instance.LogError("EQUIP", $"SaveAllSharedState failed after moving gear: {ex.Message}"); } + } + + // v1.1.12: online the throttle is skipped (this write must land); single-player is one save for both sides + private async Task SavePlayerForGear() + { + if (GearSaveHookForTests != null) { await GearSaveHookForTests("player"); return; } + try { await SaveSystem.Instance.AutoSave(currentPlayer, force: DoorMode.IsOnlineMode); } + catch (Exception ex) { DebugLogger.Instance.LogError("EQUIP", $"Player save failed after moving gear: {ex.Message}"); } + } + // v0.64.2: promoted from InnLocation so Home / Team Corner / Dungeon // party menus can offer the same auto-equip-best flow (player request: // outfitting a naked recruit slot-by-slot was painful). @@ -10462,6 +10582,7 @@ protected async Task RunEquipBestGear(Character target) terminal.WriteLine(""); int equippedCount = 0; + var displacedAll = new List(); // Process each equipment slot var slotsToCheck = new[] { @@ -10486,7 +10607,15 @@ protected async Task RunEquipBestGear(Character target) var candidates = GetItemsForSlot(slot) .Where(x => !x.isEquipped && x.item.IsIdentified && !x.item.IsCursed) .Where(x => x.item.CanEquip(target, out _)) + .Select(x => (x.item, fromBag: (Item?)null)) .ToList(); + // v1.1.12: gear displaced earlier in this pass is still in the target's bag; it competes for this slot too + foreach (var bagItem in displacedAll) + { + var eq = ConvertInventoryItemToEquipment(bagItem); + if (eq != null && ItemMatchesSlot(eq, slot) && eq.IsIdentified && !eq.IsCursed && eq.CanEquip(target, out _)) + candidates.Add((eq, bagItem)); + } if (candidates.Count == 0) { @@ -10512,10 +10641,12 @@ protected async Task RunEquipBestGear(Character target) continue; } - // Remove from player inventory (find by name match) - var invItem = currentPlayer.Inventory.FirstOrDefault(i => i.Name == bestCandidate.item.Name); + // Remove from player inventory (find by name match); v1.1.12: or take it back out of the target's bag + var fromBag = bestCandidate.fromBag; + var invItem = fromBag ?? currentPlayer.Inventory.FirstOrDefault(i => i.Name == bestCandidate.item.Name); if (invItem == null) continue; - currentPlayer.Inventory.Remove(invItem); + if (fromBag != null) { target.Inventory.Remove(fromBag); displacedAll.Remove(fromBag); } + else currentPlayer.Inventory.Remove(invItem); // Track items before equipping so displaced items go to player var targetInventoryBefore = target.Inventory.Count; @@ -10523,16 +10654,9 @@ protected async Task RunEquipBestGear(Character target) // Equip to target if (target.EquipItem(bestCandidate.item, slot, out string message)) { - // Move displaced items back to player inventory + // v1.1.12: displaced items stay in the target's bag until the give is saved (below) if (target.Inventory.Count > targetInventoryBefore) - { - var displacedItems = target.Inventory.Skip(targetInventoryBefore).ToList(); - foreach (var displaced in displacedItems) - { - target.Inventory.Remove(displaced); - currentPlayer.Inventory.Add(displaced); - } - } + displacedAll.AddRange(target.Inventory.Skip(targetInventoryBefore)); equippedCount++; terminal.SetColor("bright_green"); @@ -10543,8 +10667,9 @@ protected async Task RunEquipBestGear(Character target) } else { - // Failed - return item to player - currentPlayer.Inventory.Add(invItem); + // Failed - return item to player (v1.1.12: or to the target's bag it came from) + if (fromBag != null) { target.Inventory.Add(fromBag); displacedAll.Add(fromBag); } + else currentPlayer.Inventory.Add(invItem); } } @@ -10561,14 +10686,17 @@ protected async Task RunEquipBestGear(Character target) // below persists them). if (target.IsCompanion) UsurperRemake.Systems.CompanionSystem.Instance?.SyncCompanionEquipment(target); - UsurperRemake.Systems.SaveSystem.Instance.ResetAutoSaveThrottle(); - await UsurperRemake.Systems.SaveSystem.Instance.AutoSave(currentPlayer); - - // Online mode: persist companion equipment to shared state - if (UsurperRemake.BBS.DoorMode.IsOnlineMode && UsurperRemake.Systems.OnlineStateManager.Instance != null) + // v1.1.12: the give is saved player first, then the displaced items come back, saved NPC side first, + // so a crash between any two saves never leaves an item on both sides + await SaveGearGivenToNpc(target); + if (displacedAll.Count > 0) { - try { await UsurperRemake.Systems.OnlineStateManager.Instance.SaveAllSharedState(); } - catch (Exception ex) { UsurperRemake.Systems.DebugLogger.Instance.LogError("EQUIP", $"SaveAllSharedState failed after EquipBest: {ex.Message}"); } + foreach (var displaced in displacedAll) + { + target.Inventory.Remove(displaced); + currentPlayer.Inventory.Add(displaced); + } + await SaveGearTakenFromNpc(target); } } else diff --git a/Scripts/Locations/CastleLocation.cs b/Scripts/Locations/CastleLocation.cs index 5f352dbe..d6c3f1c0 100644 --- a/Scripts/Locations/CastleLocation.cs +++ b/Scripts/Locations/CastleLocation.cs @@ -5987,6 +5987,7 @@ private async Task ChallengeThrone() { // Player King (offline) — try loading from database PlayerData kingData = null; + StorySystemsData? kingStory = null; // v1.1.12: the king fights with its own awakening try { var backend = SaveSystem.Instance?.Backend as SqlSaveBackend; @@ -5994,14 +5995,17 @@ private async Task ChallengeThrone() { var kingSave = await backend.ReadGameData(currentKing.Name.ToLowerInvariant()); if (kingSave?.Player != null) + { kingData = kingSave.Player; + kingStory = kingSave.StorySystems; + } } } catch { /* Fall through */ } if (kingData != null) { - kingCharacter = PlayerCharacterLoader.CreateFromSaveData(kingData, currentKing.Name); + kingCharacter = PlayerCharacterLoader.CreateFromSaveData(kingData, currentKing.Name, story: kingStory); } else { @@ -8677,7 +8681,10 @@ private async Task CastleSiegeMenu() terminal.WriteLine(""); // Load team members for the assault - var teamMembers = await backend.GetPlayerTeamMembers(currentPlayer.Team, currentPlayer.DisplayName); + // v1.1.12: the player is left out by save key; a teammate may share the player's display name + string myKey = GameEngine.InheritanceKey(currentPlayer); + var teamMembers = (await backend.GetPlayerTeamMembers(currentPlayer.Team)) + .Where(m => !string.Equals(m.Username, myKey, StringComparison.OrdinalIgnoreCase)).ToList(); terminal.SetColor("bright_cyan"); terminal.WriteLine(Loc.Get("castle.siege_your_force")); terminal.SetColor("bright_green"); diff --git a/Scripts/Locations/DarkAlleyLocation.cs b/Scripts/Locations/DarkAlleyLocation.cs index 2f959dda..f0a29964 100644 --- a/Scripts/Locations/DarkAlleyLocation.cs +++ b/Scripts/Locations/DarkAlleyLocation.cs @@ -3840,7 +3840,7 @@ private async Task ExecuteEvilDeed(EvilDeedDef deed) if (deed.SpecialEffect == "void" && !currentPlayer.HasTouchedTheVoid) { currentPlayer.HasTouchedTheVoid = true; - OceanPhilosophySystem.Instance?.GainInsight(10); + OceanPhilosophySystem.Instance?.GainInsight("dark_alley:void"); // v1.1.12 terminal.SetColor("bright_cyan"); terminal.WriteLine(Loc.Get("dark_alley.evil_void_whisper")); terminal.WriteLine(Loc.Get("dark_alley.evil_awakening")); diff --git a/Scripts/Locations/DormitoryLocation.cs b/Scripts/Locations/DormitoryLocation.cs index 8ea49243..26f67ebe 100644 --- a/Scripts/Locations/DormitoryLocation.cs +++ b/Scripts/Locations/DormitoryLocation.cs @@ -697,7 +697,7 @@ private async Task AttackSleepingPlayer(SqlSaveBackend backend, string targetUse await Task.Delay(1500); // No guards in dormitory — fight the sleeper directly - var victim = PlayerCharacterLoader.CreateFromSaveData(victimSave.Player, target.Username); + var victim = PlayerCharacterLoader.CreateFromSaveData(victimSave.Player, target.Username, story: victimSave.StorySystems); long victimGold = victim.Gold; victim.Gold = 0; // prevent CombatEngine from applying its own gold steal diff --git a/Scripts/Locations/DungeonLocation.cs b/Scripts/Locations/DungeonLocation.cs index ef3c2081..48990df7 100644 --- a/Scripts/Locations/DungeonLocation.cs +++ b/Scripts/Locations/DungeonLocation.cs @@ -79,6 +79,7 @@ public void InvalidateFloorCache() var floorResult = GenerateOrRestoreFloor(currentPlayer, currentDungeonLevel); currentFloor = floorResult.Floor; roomsExploredThisFloor = currentFloor.Rooms.Count(r => r.IsExplored); + hasCampedThisFloor = floorResult.RestedOnThisFloor; // v1.1.12 } else { @@ -205,7 +206,8 @@ public override async Task EnterLocation(Character player, TerminalEmulator term bool didRespawn = floorResult.DidRespawn; roomsExploredThisFloor = wasRestored ? currentFloor.Rooms.Count(r => r.IsExplored) : 0; - hasCampedThisFloor = false; + // v1.1.12: the rest limit comes from the floor's saved state. + hasCampedThisFloor = floorResult.RestedOnThisFloor; // Reset companion idle comment history so comments can repeat on new dungeon runs CompanionSystem.ResetIdleCommentHistory(); @@ -385,6 +387,10 @@ public override async Task EnterLocation(Character player, TerminalEmulator term await term.PressAnyKey(); } + // v1.1.12: explain lives once, on the first dungeon entry (online permadeath only). + if (HintSystem.Instance.TryShowLivesHint(player, term)) + await term.PressAnyKey(); + // Captain Aldric's Mission — dungeon entry objective if (player.HintsShown.Contains("aldric_quest_active") && !player.HintsShown.Contains("quest_scout_enter_dungeon")) { @@ -1387,29 +1393,18 @@ private async Task RestorePlayerTeammates(TerminalEmulator term) // hit the same failure forever. Now we track stuck names and prune them // after the loop so Team Corner reflects reality. var stuckNames = new List(); + // v1.1.12: the viewer's own save is never echoed + string viewerKey = currentPlayer != null ? GameEngine.InheritanceKey(currentPlayer) : ""; foreach (var name in playerNames) { - // Skip if already in party - if (teammates.Any(t => t.DisplayName.Equals(name, StringComparison.OrdinalIgnoreCase))) + // Skip if already in party (v1.1.12: by save key; never an NPC's name) + if (EchoAlreadyInParty(teammates, name, name.ToLowerInvariant())) continue; try { - // Load player's save data from database. The recruit list stores the - // player's DISPLAY name, which since v0.65.1 can include a family - // surname (e.g. "Imperius Ashwick"), but saves are keyed by account - // username (e.g. "imperius") -- so a surnamed character's echo failed - // to load with "save data cannot be found". If the direct lookup misses, - // resolve the display name to the canonical username and retry. This - // also self-heals echoes that were already stuck from earlier sessions. - var saveData = await backend.ReadGameData(name.ToLower()); - if (saveData?.Player == null) - { - var resolvedUser = backend.ResolvePlayerUsername(name); - if (!string.IsNullOrEmpty(resolvedUser) && !resolvedUser.Equals(name, StringComparison.OrdinalIgnoreCase)) - saveData = await backend.ReadGameData(resolvedUser.ToLower()); - } + var (saveData, echoKey) = await LoadEchoSave(backend, name, viewerKey); if (saveData?.Player == null) { term.SetColor("yellow"); @@ -1417,6 +1412,9 @@ private async Task RestorePlayerTeammates(TerminalEmulator term) stuckNames.Add(name); continue; } + // v1.1.12: an older entry (a display name) resolved to a key; skip it if that echo is already here + if (EchoAlreadyInParty(teammates, name, echoKey!)) + continue; // Verify they're still on the same team if (currentPlayer != null && !string.IsNullOrEmpty(currentPlayer.Team)) @@ -1424,7 +1422,7 @@ private async Task RestorePlayerTeammates(TerminalEmulator term) if (saveData.Player.Team != currentPlayer.Team) { term.SetColor("yellow"); - term.WriteLine(Loc.Get("dungeon.not_on_team", name)); + term.WriteLine(Loc.Get("dungeon.not_on_team", saveData.Player.Name2 ?? name)); stuckNames.Add(name); continue; } @@ -1443,11 +1441,12 @@ private async Task RestorePlayerTeammates(TerminalEmulator term) // Create echo character var ally = PlayerCharacterLoader.CreateFromSaveData(saveData.Player, name, isEcho: true); + ally.EchoSaveKey = echoKey ?? ""; // v1.1.12: kept in the recruit list by this key teammates.Add(ally); restoredCount++; term.SetColor("bright_cyan"); - term.WriteLine(Loc.Get("dungeon.echo_materializes", name)); + term.WriteLine(Loc.Get("dungeon.echo_materializes", ally.DisplayName)); } catch (Exception ex) { @@ -1569,6 +1568,41 @@ private void AddRoyalMercenariesToParty(Character player, TerminalEmulator term) } } + /// + /// v1.1.12: whether the echo a recruit-list entry names is already in the party. Only echoes count: by save key, + /// or by display name when the entry is an older one holding a name (it did not load under its own text). + /// An NPC is never matched, so a key "robin" still loads beside an NPC named "Robin". + /// + internal static bool EchoAlreadyInParty(IEnumerable party, string entry, string echoKey) + { + bool legacyName = !echoKey.Equals(entry, StringComparison.OrdinalIgnoreCase); + return party.Any(t => t.IsEcho && !t.IsCompanion && !t.IsGroupedPlayer + && (t.EchoSaveKey.Equals(echoKey, StringComparison.OrdinalIgnoreCase) + || (legacyName && t.DisplayName.Equals(entry, StringComparison.OrdinalIgnoreCase)))); + } + + /// + /// v1.1.12: the save an echo entry names, and the key it was read under. The entry is the teammate's save key + /// (players.username, recruited since v1.1.12) or, in an older recruit list, a display name: a direct read, + /// then the name resolved to a username (a surnamed display name). Null when nothing loads or when the save + /// found is the viewer's own (an account "robin" playing "Alice" beside a teammate named "Robin"). + /// + internal static async Task<(SaveGameData? Save, string? Key)> LoadEchoSave(SqlSaveBackend backend, string entry, string viewerKey) + { + bool IsViewer(string key) => !string.IsNullOrEmpty(viewerKey) && key.Equals(viewerKey, StringComparison.OrdinalIgnoreCase); + string key = entry.ToLowerInvariant(); + var saveData = IsViewer(key) ? null : await backend.ReadGameData(key); + if (saveData?.Player == null) + { + var resolvedUser = backend.ResolvePlayerUsername(entry); + if (string.IsNullOrEmpty(resolvedUser) || resolvedUser.Equals(entry, StringComparison.OrdinalIgnoreCase)) return (null, null); + key = resolvedUser.ToLowerInvariant(); + if (IsViewer(key)) return (null, null); + saveData = await backend.ReadGameData(key); + } + return saveData?.Player == null ? (null, null) : (saveData, key); + } + /// /// Sync the current dungeon party to GameEngine for persistence. /// @@ -1590,7 +1624,7 @@ private void SyncNPCTeammatesToGameEngine() var echoNames = teammates .Where(t => t.IsEcho && !t.IsCompanion && !t.IsGroupedPlayer) - .Select(t => t.DisplayName) + .Select(t => string.IsNullOrEmpty(t.EchoSaveKey) ? t.DisplayName : t.EchoSaveKey) // v1.1.12: the save key .Where(n => !string.IsNullOrEmpty(n)) .ToList(); GameEngine.Instance?.SetDungeonPartyPlayers(echoNames); @@ -4909,7 +4943,7 @@ private async Task DisplayDungeonVision(DungeonVision vision) // Apply awakening gain if any if (vision.AwakeningGain > 0) { - OceanPhilosophySystem.Instance.GainInsight(vision.AwakeningGain * 10); + OceanPhilosophySystem.Instance.GainInsight("vision:" + vision.Id); // v1.1.12: one insight per vision terminal.SetColor("cyan"); terminal.WriteLine(Loc.Get("dungeon.vision_memory_stirs")); } @@ -6203,6 +6237,18 @@ private struct FloorGenerationResult public DungeonFloor Floor; public bool WasRestored; // True if floor was restored from save public bool DidRespawn; // True if monsters respawned (24h passed) + public bool RestedOnThisFloor; // v1.1.12: the floor's one rest is already spent + } + + /// + /// v1.1.12: spend the floor's one rest, in memory and in the floor's saved state, + /// so a logout and login on the same floor does not give a second rest. + /// + private void MarkRestedOnThisFloor(Character? player) + { + hasCampedThisFloor = true; + if (player?.DungeonFloorStates != null && player.DungeonFloorStates.TryGetValue(currentDungeonLevel, out var floorState)) + floorState.RestedOnThisFloor = true; } /// @@ -6222,6 +6268,10 @@ private FloorGenerationResult GenerateOrRestoreFloor(Character player, int floor bool shouldRespawn = savedState.ShouldRespawn(); + // v1.1.12: a respawned floor is fresh, so its rest is available again. + if (shouldRespawn && !savedState.IsPermanentlyClear) + savedState.RestedOnThisFloor = false; + // Restore room states foreach (var room in floor.Rooms) { @@ -6334,7 +6384,8 @@ private FloorGenerationResult GenerateOrRestoreFloor(Character player, int floor { Floor = floor, WasRestored = true, - DidRespawn = shouldRespawn && !savedState.IsPermanentlyClear + DidRespawn = shouldRespawn && !savedState.IsPermanentlyClear, + RestedOnThisFloor = savedState.RestedOnThisFloor }; } @@ -6748,7 +6799,7 @@ private async Task DescendStairs() currentDungeonLevel = nextLevel; if (player != null) { player.CurrentLocation = $"Dungeon Floor {currentDungeonLevel}"; player.LastDungeonFloor = currentDungeonLevel; } roomsExploredThisFloor = floorResult.WasRestored ? currentFloor.Rooms.Count(r => r.IsExplored) : 0; - hasCampedThisFloor = false; + hasCampedThisFloor = floorResult.RestedOnThisFloor; // v1.1.12 consecutiveMonsterRooms = 0; // Update quest progress for reaching this floor @@ -6901,7 +6952,10 @@ private async Task RestInRoom() terminal.SetColor("cyan"); terminal.WriteLine($"{Loc.Get("combat.bar_hp")}: {player.HP}/{player.MaxHP} {Loc.Get("combat.bar_mp")}: {player.Mana}/{player.MaxMana} {Loc.Get("combat.bar_st")}: {player.CurrentCombatStamina}/{player.MaxCombatStamina}"); - hasCampedThisFloor = true; + MarkRestedOnThisFloor(player); + // v1.1.12: the camp and a sanctuary share one rest per floor. + terminal.SetColor("gray"); + terminal.WriteLine(Loc.Get("dungeon.rest_once_per_floor")); // Advance game time for camping (single-player only) if (!UsurperRemake.BBS.DoorMode.IsOnlineMode) @@ -7069,7 +7123,7 @@ private async Task ChangeDungeonLevel() currentDungeonLevel = targetLevel; if (player != null) { player.CurrentLocation = $"Dungeon Floor {currentDungeonLevel}"; player.LastDungeonFloor = currentDungeonLevel; } roomsExploredThisFloor = floorResult.WasRestored ? currentFloor.Rooms.Count(r => r.IsExplored) : 0; - hasCampedThisFloor = false; + hasCampedThisFloor = floorResult.RestedOnThisFloor; // v1.1.12 consecutiveMonsterRooms = 0; // Log floor change @@ -9773,16 +9827,9 @@ private async Task PurchaseRareItem(Character player, MerchantRareItem item) terminal.SetColor("cyan"); terminal.WriteLine($" {item.Description}"); - if (item.LootItem.Type == ObjType.Weapon) - { - terminal.SetColor("gray"); - terminal.WriteLine($" {Loc.Get("dungeon.merchant_current_weapon", player.WeapPow)}"); - } - else if (item.LootItem.Armor > 0) - { - terminal.SetColor("gray"); - terminal.WriteLine($" {Loc.Get("dungeon.merchant_current_armor", player.ArmPow)}"); - } + // v1.1.12: the same per-slot comparison as combat loot, not the raw WeapPow/ArmPow totals. + if (item.LootItem.Type == ObjType.Weapon || item.LootItem.Armor > 0) + CombatEngine.ShowEquipmentComparison(terminal, item.LootItem, player); terminal.WriteLine(""); } @@ -10109,6 +10156,7 @@ private async Task DescendDeeper() var floorResult = GenerateOrRestoreFloor(player, nextLevel); currentFloor = floorResult.Floor; currentDungeonLevel = nextLevel; + hasCampedThisFloor = floorResult.RestedOnThisFloor; // v1.1.12: this path never reset it if (player != null) { player.CurrentLocation = $"Dungeon Floor {currentDungeonLevel}"; player.LastDungeonFloor = currentDungeonLevel; } terminal.WriteLine(Loc.Get("dungeon.descend_to", currentDungeonLevel), "yellow"); @@ -15064,7 +15112,9 @@ private async Task RestSpotEncounter() terminal.WriteLine(Loc.Get("dungeon.sanctuary_cure_poison"), "cyan"); } - hasCampedThisFloor = true; + MarkRestedOnThisFloor(player); + // v1.1.12: the sanctuary and the [R] camp share one rest per floor. + terminal.WriteLine(Loc.Get("dungeon.rest_once_per_floor"), "gray"); // Reduce fatigue from dungeon rest (single-player only) if (!UsurperRemake.BBS.DoorMode.IsOnlineMode) @@ -15250,6 +15300,8 @@ private async Task RestSpotEncounter() { terminal.WriteLine(Loc.Get("dungeon.sanctuary_already_rested"), "gray"); terminal.WriteLine(Loc.Get("dungeon.sanctuary_no_benefit")); + // v1.1.12: say why: a camp or an earlier sanctuary used this floor's rest. + terminal.WriteLine(Loc.Get("dungeon.rest_once_per_floor"), "gray"); } await Task.Delay(1500); @@ -15618,7 +15670,7 @@ private async Task RiddleGateEncounter() // Ocean philosophy riddles grant awakening insight if (riddle.IsOceanPhilosophy) { - ocean.GainInsight(20); + ocean.GainInsight("riddle:" + riddle.Id); // v1.1.12 terminal.WriteLine(Loc.Get("dungeon.riddle_deeper"), "magenta"); } } @@ -18022,6 +18074,7 @@ private async Task EnterAsGroupFollower(Character player, TerminalEmulator term, player.RemoteTerminal = term; player.GroupPlayerUsername = ctx.Username; player.CombatInputChannel = System.Threading.Channels.Channel.CreateBounded(1); + AwakeningBonus.Stamp(player); // v1.1.12: stamped here, in the follower's own session, before the leader's party uses it // Add to leader's teammates list lock (leaderDungeon.teammates) diff --git a/Scripts/Locations/InnLocation.cs b/Scripts/Locations/InnLocation.cs index 7a4773a0..154be392 100644 --- a/Scripts/Locations/InnLocation.cs +++ b/Scripts/Locations/InnLocation.cs @@ -5897,7 +5897,7 @@ private async Task AttackInnSleepingPlayer(SqlSaveBackend backend, string target } // Guards defeated — fight the sleeper with inn defense boost - var victim = PlayerCharacterLoader.CreateFromSaveData(victimSave.Player, target.Username); + var victim = PlayerCharacterLoader.CreateFromSaveData(victimSave.Player, target.Username, story: victimSave.StorySystems); long victimGold = victim.Gold; victim.Gold = 0; diff --git a/Scripts/Locations/MainStreetLocation.cs b/Scripts/Locations/MainStreetLocation.cs index 094301d5..4758bae5 100644 --- a/Scripts/Locations/MainStreetLocation.cs +++ b/Scripts/Locations/MainStreetLocation.cs @@ -482,7 +482,10 @@ private void DisplayLocationBBS() terminal.SetColor("darkgray"); terminal.Write("["); terminal.SetColor("bright_yellow"); terminal.Write("F"); terminal.SetColor("darkgray"); terminal.Write("]"); terminal.SetColor("white"); terminal.Write(Loc.Get("main_street.menu_fame_suffix")); terminal.SetColor("darkgray"); terminal.Write("["); terminal.SetColor("bright_yellow"); terminal.Write("E"); terminal.SetColor("darkgray"); terminal.Write("]"); - terminal.SetColor("bright_green"); terminal.WriteLine(Loc.Get("main_street.menu_explore_suffix")); + terminal.SetColor("bright_green"); terminal.Write(Loc.Get("main_street.menu_explore_suffix")); + // v1.1.12: the $ key worked but was never drawn. + terminal.SetColor("darkgray"); terminal.Write("["); terminal.SetColor("bright_yellow"); terminal.Write("$"); terminal.SetColor("darkgray"); terminal.Write("]"); + terminal.SetColor("white"); terminal.WriteLine(Loc.Get("main_street.menu_events_suffix")); } // Tier 3 (Level 5+): Full menu @@ -887,7 +890,8 @@ void ML(string key, string label, string color) MI("S", Loc.Get("menu.action.status"), "white", C); MI("N", Loc.Get("menu.action.news"), "white", C); MI("F", Loc.Get("menu.action.fame"), "white", C); - ML("E", Loc.Get("menu.action.explore"), "bright_green"); + MI("E", Loc.Get("menu.action.explore"), "bright_green", C); + ML("$", Loc.Get("menu.action.world_events"), "white"); // v1.1.12 } else { @@ -1066,6 +1070,7 @@ private void ShowScreenReaderMenu() terminal.WriteLine($" S - {Loc.Get("menu.action.status")}"); terminal.WriteLine($" N - {Loc.Get("menu.action.news")}"); terminal.WriteLine($" F - {Loc.Get("menu.action.fame")}"); + terminal.WriteLine($" $ - {Loc.Get("menu.action.world_events")}"); // v1.1.12 if (tier >= 3) { terminal.WriteLine($" = - {Loc.Get("menu.action.stats_record")}"); @@ -2705,22 +2710,8 @@ private async Task ShowStoryProgress() terminal.WriteLine($" {Loc.Get("main_street.ocean_desc")}"); terminal.WriteLine(""); - int awakeningLevel = ocean.AwakeningLevel; - string awakeningDesc = awakeningLevel switch - { - 0 => Loc.Get("main_street.awakening_0"), - 1 => Loc.Get("main_street.awakening_1"), - 2 => Loc.Get("main_street.awakening_2"), - 3 => Loc.Get("main_street.awakening_3"), - 4 => Loc.Get("main_street.awakening_4"), - >= 5 => Loc.Get("main_street.awakening_5"), - _ => Loc.Get("main_street.awakening_unknown") - }; - - terminal.SetColor("bright_cyan"); - terminal.WriteLine($" {Loc.Get("main_street.awakening_level", awakeningLevel)}"); - terminal.SetColor("white"); - terminal.WriteLine($" {awakeningDesc}"); + // v1.1.12: the Ocean Journal, on the status screen's 0-7 scale + AwakeningScreens.WriteJournalSummary(terminal); terminal.WriteLine(""); // Show per-companion grief details var griefDetails = grief.GetActiveGriefDetails(); @@ -2873,7 +2864,11 @@ private async Task ShowStoryProgress() terminal.WriteLine(""); terminal.SetColor("gray"); - await terminal.PressAnyKey(Loc.Get("main_street.press_return")); + // v1.1.12: [J] opens the Ocean Journal's pages, any other key returns + terminal.Write($" {Loc.Get("ocean.journal_prompt")} "); + string key = (await terminal.GetKeyInput())?.Trim().ToUpperInvariant() ?? ""; + if (key == "J") + await AwakeningScreens.ShowJournal(terminal); } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/Scripts/Locations/MusicShopLocation.cs b/Scripts/Locations/MusicShopLocation.cs index 763ed2bf..50251cd1 100644 --- a/Scripts/Locations/MusicShopLocation.cs +++ b/Scripts/Locations/MusicShopLocation.cs @@ -1259,8 +1259,9 @@ private async Task PlayLoreSong(OldGodType god, string title, string color, stri try { - // GainInsight grants real awakening points (unlike ExperienceMoment which is one-time-only) - OceanPhilosophySystem.Instance?.GainInsight(1); + // v1.1.12: each god's song is its own insight; the first song is also a moment + OceanPhilosophySystem.Instance?.GainInsight("song:" + god); + OceanPhilosophySystem.Instance?.ExperienceMoment(AwakeningMoment.HeardOldGodLoreSong); } catch { } } diff --git a/Scripts/Locations/TeamCornerLocation.cs b/Scripts/Locations/TeamCornerLocation.cs index a5850e6a..c6bfc3d0 100644 --- a/Scripts/Locations/TeamCornerLocation.cs +++ b/Scripts/Locations/TeamCornerLocation.cs @@ -211,6 +211,7 @@ private void DisplayLocationSR() WriteSRMenuOption("2", Loc.Get("team_corner.sack")); WriteSRMenuOption("G", Loc.Get("team_corner.equip")); WriteSRMenuOption("X", Loc.Get("team_corner.specialize")); + WriteSRMenuOption("V", Loc.Get("team.menu_view_inventories").Trim()); // v1.1.12: was missing here } terminal.WriteLine(""); @@ -332,14 +333,14 @@ private void DisplayLocationBBS() else { ShowBBSMenuRow(("L", "bright_yellow", Loc.Get("team.bbs_quit_team")), ("N", "bright_yellow", Loc.Get("team.bbs_recruit_npc")), ("2", "bright_yellow", Loc.Get("team.bbs_sack_member")), ("G", "bright_yellow", Loc.Get("team.bbs_equip_mbr")), ("X", "bright_yellow", Loc.Get("team.bbs_specialize"))); - // Message gated on online-mode; Resurrect always shown when in-team. + // Message gated on online-mode; Resurrect always shown when in-team. v1.1.12: View Inventories added. if (showMessageBbs) { - ShowBBSMenuRow(("M", "bright_yellow", Loc.Get("team.bbs_message")), ("U", "bright_yellow", Loc.Get("team.bbs_resurrect"))); + ShowBBSMenuRow(("M", "bright_yellow", Loc.Get("team.bbs_message")), ("U", "bright_yellow", Loc.Get("team.bbs_resurrect")), ("V", "bright_yellow", Loc.Get("team.bbs_inventories"))); } else { - ShowBBSMenuRow(("U", "bright_yellow", Loc.Get("team.bbs_resurrect"))); + ShowBBSMenuRow(("U", "bright_yellow", Loc.Get("team.bbs_resurrect")), ("V", "bright_yellow", Loc.Get("team.bbs_inventories"))); } if (DoorMode.IsOnlineMode) { @@ -499,133 +500,141 @@ protected override async Task ProcessChoice(string choice) /// private async Task ShowTeamRankings() { - terminal.ClearScreen(); - WriteBoxHeader(Loc.Get("team_corner.rankings_header"), "bright_magenta"); - terminal.WriteLine(""); - - // Get all teams from NPCs, then merge in the player's team - var allNPCs = NPCSpawnSystem.Instance.ActiveNPCs; - var teamGroups = allNPCs - .Where(n => !string.IsNullOrEmpty(n.Team) && n.IsAlive) - .GroupBy(n => n.Team) - .Select(g => new - { - TeamName = g.Key, - MemberCount = g.Count(), - TotalPower = (long)g.Sum(m => m.Level + (int)m.Strength + (int)m.Defence), - AverageLevel = (int)g.Average(m => m.Level), - ControlsTurf = g.Any(m => m.CTurf), - IsPlayerTeam = false - }) - .ToList(); + var teamGroups = await LoadTeamRankings(); - // Merge the player into the team list - if (!string.IsNullOrEmpty(currentPlayer.Team)) + if (teamGroups.Count == 0) { - long playerPower = currentPlayer.Level + (long)currentPlayer.Strength + (long)currentPlayer.Defence; - var existingTeam = teamGroups.FirstOrDefault(t => t.TeamName == currentPlayer.Team); - if (existingTeam != null) - { - // Player's team has NPC members too - add the player's stats - teamGroups.Remove(existingTeam); - int totalMembers = existingTeam.MemberCount + 1; - long totalPower = existingTeam.TotalPower + playerPower; - int totalLevels = existingTeam.AverageLevel * existingTeam.MemberCount + currentPlayer.Level; - teamGroups.Add(new - { - TeamName = existingTeam.TeamName, - MemberCount = totalMembers, - TotalPower = totalPower, - AverageLevel = totalLevels / totalMembers, - ControlsTurf = existingTeam.ControlsTurf || currentPlayer.CTurf, - IsPlayerTeam = true - }); - } - else - { - // Player-only team (no NPC members) - teamGroups.Add(new - { - TeamName = currentPlayer.Team, - MemberCount = 1, - TotalPower = playerPower, - AverageLevel = currentPlayer.Level, - ControlsTurf = currentPlayer.CTurf, - IsPlayerTeam = true - }); - } + terminal.ClearScreen(); + WriteBoxHeader(Loc.Get("team_corner.rankings_header"), "bright_magenta"); + terminal.WriteLine(""); + terminal.SetColor("yellow"); + terminal.WriteLine(Loc.Get("team.no_teams_yet")); + terminal.WriteLine(Loc.Get("team.be_first")); + terminal.WriteLine(""); + terminal.SetColor("darkgray"); + terminal.WriteLine(Loc.Get("ui.press_enter")); + await terminal.ReadKeyAsync(); + return; } - // Online mode: merge player teams from database - if (DoorMode.IsOnlineMode) - { - var backend = SaveSystem.Instance.Backend as SqlSaveBackend; - if (backend != null) + // v1.1.12: the name column is as wide as the longest name (a long name pushed the columns out of line), + // and the list pages + string NameDisplay(TeamRankingRow t) => t.IsPlayerTeam ? $"{t.TeamName} {Loc.Get("team.you_suffix")}" : t.TeamName; + int nameWidth = Math.Max(24, teamGroups.Max(t => NameDisplay(t).Length)); + await ShowPaged(teamGroups, + header: () => { - var playerTeams = await backend.GetPlayerTeams(); - foreach (var pt in playerTeams) + terminal.ClearScreen(); + WriteBoxHeader(Loc.Get("team_corner.rankings_header"), "bright_magenta"); + terminal.WriteLine(""); + terminal.SetColor("white"); + terminal.WriteLine($"{Loc.Get("team.rank_col_rank"),-5} {Loc.Get("team.rank_col_name").PadRight(nameWidth)} {Loc.Get("team.rank_col_mbrs"),-6} {Loc.Get("team.rank_col_power"),-8} {Loc.Get("team.rank_col_avg_lvl"),-8} {Loc.Get("team.rank_col_turf"),-5}"); + if (!IsScreenReader) { - // Skip if this team is already in the list (NPC team or player's own team) - if (teamGroups.Any(t => t.TeamName == pt.TeamName)) - continue; - - teamGroups.Add(new - { - TeamName = pt.TeamName, - MemberCount = pt.MemberCount, - TotalPower = (long)(pt.MemberCount * 50), // Estimate power from member count - AverageLevel = 0, - ControlsTurf = pt.ControlsTurf, - IsPlayerTeam = false - }); + terminal.SetColor("darkgray"); + terminal.WriteLine(new string('─', 36 + nameWidth)); } - } - } + }, + renderRow: (team, index) => + { + terminal.SetColor(team.ControlsTurf ? "bright_yellow" : team.IsPlayerTeam ? "bright_cyan" : "white"); + string turfMark = team.ControlsTurf ? "*" : "-"; + terminal.WriteLine($"{index + 1,-5} {NameDisplay(team).PadRight(nameWidth)} {team.MemberCount,-6} {team.TotalPower,-8} {team.AverageLevel,-8} {turfMark,-5}"); + }, + footer: () => + { + terminal.WriteLine(""); + terminal.SetColor("bright_yellow"); + terminal.WriteLine(Loc.Get("team.turf_legend")); + }); + } - // Sort by power descending - teamGroups = teamGroups.OrderByDescending(t => t.TotalPower).ToList(); + /// + /// v1.1.12: the rankings rows (the teams, their members, power and level), read the way the rankings + /// screen reads them; [I] Info and [J] Join list the same rows. + /// + private async Task> LoadTeamRankings() + { + // online, the player side is every player's save in one query (the viewer's own save left out, + // the in-memory character is added instead); offline it is only the in-memory character + List playerTeams = new(); + if (DoorMode.IsOnlineMode && SaveSystem.Instance.Backend is SqlSaveBackend backend) + playerTeams = await backend.GetTeamRankingStats(GameEngine.InheritanceKey(currentPlayer)); + return BuildTeamRankings(NPCSpawnSystem.Instance.ActiveNPCs, playerTeams, currentPlayer); + } - if (teamGroups.Count == 0) - { - terminal.SetColor("yellow"); - terminal.WriteLine(Loc.Get("team.no_teams_yet")); - terminal.WriteLine(Loc.Get("team.be_first")); - } - else + /// v1.1.12: one team as a picker row, with the rankings' numbers. + private static string TeamPickRow(TeamRankingRow t) => + Loc.Get("team.pick_team_row", t.TeamName, t.MemberCount, t.AverageLevel, t.TotalPower) + + (t.ControlsTurf ? " " + Loc.Get("team.pick_team_turf") : "") + + (t.IsPlayerTeam ? " " + Loc.Get("team.you_suffix") : ""); + + /// + /// v1.1.12: a read-only list shown a page at a time (N/P page, Enter leaves). header draws the screen + /// top, renderRow one row by its index in the whole list, footer what follows the rows. + /// + private async Task ShowPaged(IReadOnlyList rows, Action header, Action renderRow, Action? footer = null, int pageSize = 15) + { + int pageIndex = 0; + while (true) { - terminal.SetColor("white"); - terminal.WriteLine($"{Loc.Get("team.rank_col_rank"),-5} {Loc.Get("team.rank_col_name"),-24} {Loc.Get("team.rank_col_mbrs"),-6} {Loc.Get("team.rank_col_power"),-8} {Loc.Get("team.rank_col_avg_lvl"),-8} {Loc.Get("team.rank_col_turf"),-5}"); - if (!IsScreenReader) + int totalPages = Math.Max(1, (rows.Count + pageSize - 1) / pageSize); + pageIndex = Math.Clamp(pageIndex, 0, totalPages - 1); + header(); + int first = pageIndex * pageSize; + var page = rows.Skip(first).Take(pageSize).ToList(); + for (int i = 0; i < page.Count; i++) renderRow(page[i], first + i); + footer?.Invoke(); + terminal.WriteLine(""); + if (totalPages <= 1) { terminal.SetColor("darkgray"); - terminal.WriteLine(new string('─', 60)); - } - - int rank = 1; - foreach (var team in teamGroups) - { - if (team.ControlsTurf) - terminal.SetColor("bright_yellow"); - else if (team.IsPlayerTeam) - terminal.SetColor("bright_cyan"); - else - terminal.SetColor("white"); - - string turfMark = team.ControlsTurf ? "*" : "-"; - string nameDisplay = team.IsPlayerTeam ? $"{team.TeamName} {Loc.Get("team.you_suffix")}" : team.TeamName; - terminal.WriteLine($"{rank,-5} {nameDisplay,-24} {team.MemberCount,-6} {team.TotalPower,-8} {team.AverageLevel,-8} {turfMark,-5}"); - rank++; + terminal.WriteLine(Loc.Get("ui.press_enter")); + await terminal.ReadKeyAsync(); + return; } - - terminal.WriteLine(""); - terminal.SetColor("bright_yellow"); - terminal.WriteLine(Loc.Get("team.turf_legend")); + terminal.SetColor("darkgray"); + terminal.WriteLine(Loc.Get("team.recruit_page_footer", first + 1, first + page.Count, rows.Count, pageIndex + 1, totalPages)); + string input = ((await terminal.GetInput(Loc.Get("team.page_nav"))) ?? "").Trim().ToUpperInvariant(); + if (input == "N") { pageIndex++; continue; } + if (input == "P") { pageIndex--; continue; } + return; } + } - terminal.WriteLine(""); - terminal.SetColor("darkgray"); - terminal.WriteLine(Loc.Get("ui.press_enter")); - await terminal.ReadKeyAsync(); + internal sealed record TeamRankingRow(string TeamName, int MemberCount, long TotalPower, int AverageLevel, bool ControlsTurf, bool IsPlayerTeam); + + /// + /// v1.1.12: one row per team: living NPC members, the player members from the saves (playerTeams, which + /// must not include the viewer's save) and the viewer from memory, each counted once. Power is + /// level + strength + defence summed; a team with no members is left out. Sorted by power. + /// + internal static List BuildTeamRankings(IEnumerable npcs, IEnumerable playerTeams, Character? viewer) + { + // v1.1.12: grouped by the exact name; older case variants are separate teams (a join checks the exact name) + var acc = new Dictionary(StringComparer.Ordinal); + var order = new List(); + void Add(string team, int members, long levels, long power, bool turf) + { + if (!acc.TryGetValue(team, out var a)) { order.Add(team); a = default; } + acc[team] = (a.Members + members, a.LevelSum + levels, a.Power + power, a.Turf || turf); + } + + foreach (var n in npcs) + if (!string.IsNullOrEmpty(n.Team) && n.IsAlive) + Add(n.Team, 1, n.Level, n.Level + (long)n.Strength + (long)n.Defence, n.CTurf); + if (viewer != null && !string.IsNullOrEmpty(viewer.Team)) + Add(viewer.Team, 1, viewer.Level, viewer.Level + (long)viewer.Strength + (long)viewer.Defence, viewer.CTurf); + foreach (var pt in playerTeams) + if (!string.IsNullOrEmpty(pt.TeamName)) + Add(pt.TeamName, pt.MemberCount, pt.LevelSum, pt.PowerSum, pt.ControlsTurf); + + return order + .Where(t => acc[t].Members > 0) + .Select(t => new TeamRankingRow(t, acc[t].Members, acc[t].Power, (int)(acc[t].LevelSum / acc[t].Members), acc[t].Turf, + viewer != null && string.Equals(t, viewer.Team, StringComparison.Ordinal))) + .OrderByDescending(r => r.TotalPower) + .ToList(); } /// @@ -633,14 +642,20 @@ private async Task ShowTeamRankings() /// private async Task ShowTeamInfo() { - terminal.WriteLine(""); - terminal.SetColor("cyan"); - terminal.Write(Loc.Get("team.which_team_info")); - terminal.SetColor("white"); - string teamName = await terminal.ReadLineAsync(); - - if (string.IsNullOrEmpty(teamName)) + // v1.1.12: the teams are listed (the rankings' rows) instead of asking for an exact name + var teams = await LoadTeamRankings(); + if (teams.Count == 0) + { + terminal.WriteLine(""); + terminal.SetColor("yellow"); + terminal.WriteLine(Loc.Get("team.no_teams_yet")); + await Task.Delay(2000); return; + } + var picked = await PickFromList(teams, TeamPickRow, t => t.TeamName, "team.pick_team_info_title", + t => t.ControlsTurf ? "bright_yellow" : t.IsPlayerTeam ? "bright_cyan" : "white"); + if (picked == null) return; + string teamName = picked.TeamName; terminal.ClearScreen(); WriteSectionHeader(Loc.Get("team.info_header", teamName), "bright_cyan"); @@ -731,6 +746,171 @@ private async Task PersistTeamMembershipChange() } } + /// v1.1.12: online, a save that must land now (gold or items moved against a shared row); AutoSave's + /// throttle would otherwise skip it for up to a minute. Single-player keeps the player and the NPCs in + /// one save, so there is no second row to keep in step; true there. True when written. + private async Task ForcePlayerSave() + { + if (!DoorMode.IsOnlineMode) return true; + try { return await SaveSystem.Instance.AutoSave(currentPlayer, force: true); } + catch (Exception ex) + { + DebugLogger.Instance.LogError("TEAM", $"Forced save failed: {ex.Message}"); + return false; + } + } + + /// v1.1.12: one member of the viewer's team for the pickers: an NPC, or a player (the viewer or a save). + internal sealed record TeamMemberEntry(NPC? Npc, PlayerSummary? Player, bool IsViewer = false) + { + public string Name => Npc?.DisplayName ?? Player?.DisplayName ?? ""; + } + + /// v1.1.12: every member of the viewer's team: the viewer (if asked), the player members from + /// the saves (online), then the NPCs, the dead included. + private async Task> GetTeamMemberEntries(bool includeViewer) + { + var list = new List(); + string team = currentPlayer.Team; + if (includeViewer) + list.Add(new TeamMemberEntry(null, new PlayerSummary + { + Username = GameEngine.InheritanceKey(currentPlayer), + DisplayName = currentPlayer.DisplayName, + Level = currentPlayer.Level, + ClassId = (int)currentPlayer.Class, + IsOnline = true, + }, IsViewer: true)); + if (DoorMode.IsOnlineMode && SaveSystem.Instance.Backend is SqlSaveBackend backend) + { + // v1.1.12: the viewer is left out by save key; a teammate may share the viewer's display name + string myKey = GameEngine.InheritanceKey(currentPlayer); + foreach (var pm in await backend.GetPlayerTeamMembers(team)) + if (!string.Equals(pm.Username, myKey, StringComparison.OrdinalIgnoreCase)) + list.Add(new TeamMemberEntry(null, pm)); + } + foreach (var npc in NPCSpawnSystem.Instance.ActiveNPCs.Where(n => n.Team == team).OrderByDescending(n => n.Level)) + list.Add(new TeamMemberEntry(npc, null)); + return list; + } + + private static string SpecTag(NPC npc) => npc.Specialization == ClassSpecialization.None ? "" + : $" [{UsurperRemake.Data.SpecializationData.GetSpec(npc.Specialization)?.Name ?? npc.Specialization.ToString()}]"; + + private static string MemberPickRow(TeamMemberEntry e) + { + if (e.Npc != null) + { + var n = e.Npc; + string state = n.IsAlive ? "" : n.IsPermaDead || n.IsAgedDeath ? " " + Loc.Get("team.pick_gone_tag") : Loc.Get("team.member_dead_tag"); + return $"{n.DisplayName} - {Loc.Get("inn.npc_level_class", n.Level, GameConfig.GetLocalizedClassName(n.Class))}{SpecTag(n)}{state}"; + } + var p = e.Player!; + string tag = e.IsViewer ? " " + Loc.Get("team.you_suffix") : " " + Loc.Get("team.pick_player_tag") + (p.IsOnline ? Loc.Get("team.online_tag") : ""); + return $"{p.DisplayName} - {Loc.Get("inn.npc_level_class", p.Level, GameConfig.GetLocalizedClassName(p.ClassId))}{tag}"; + } + + private static string MemberPickColor(TeamMemberEntry e) => e.Npc == null ? "bright_cyan" : e.Npc.IsAlive ? "white" : "red"; + + /// + /// v1.1.12: the live copy of an NPC picked from a list. A world_state reload while the player sat at a + /// prompt replaces every NPC object, and a change to the old one is lost (the Recruit fix, v0.57.x, + /// ConfirmAndRecruit). Null when that NPC is gone. + /// + internal static NPC? LiveTeamNpc(NPC captured) + { + var active = NPCSpawnSystem.Instance.ActiveNPCs; + if (string.IsNullOrEmpty(captured.ID)) return active.Contains(captured) ? captured : null; + return active.FirstOrDefault(n => n.ID == captured.ID); + } + + /// + /// v1.1.12: the slots a team uses, of MaxTeamSize: its NPCs, the dead included (a dead member holds the + /// slot until sacked; one who can never come back does not), plus its players. + /// + internal static int CountTeamSlots(IEnumerable npcs, string team, int playerMembers) => + npcs.Count(n => n.Team == team && !n.IsPermaDead && !n.IsAgedDeath) + playerMembers; + + /// v1.1.12: CountTeamSlots for a team, with its player members from the saves online (the viewer + /// counted from memory when on it) and only the viewer offline. + private async Task TeamSlotsUsed(string team) + { + int players = team == currentPlayer.Team ? 1 : 0; + if (DoorMode.IsOnlineMode && SaveSystem.Instance.Backend is SqlSaveBackend backend) + { + // v1.1.12: the viewer is left out by save key; a display name can be another member's too + string myKey = GameEngine.InheritanceKey(currentPlayer); + players += (await backend.GetPlayerTeamMembers(team)).Count(m => !string.Equals(m.Username, myKey, StringComparison.OrdinalIgnoreCase)); + } + return CountTeamSlots(NPCSpawnSystem.Instance.ActiveNPCs, team, players); + } + + private void WriteMemberStatLines(Character c) + { + terminal.WriteLine(Loc.Get("team.examine_stats1", c.Strength, c.Dexterity, c.Agility, c.Constitution)); + terminal.WriteLine(Loc.Get("team.examine_stats2", c.Intelligence, c.Wisdom, c.Charisma, c.Defence)); + terminal.WriteLine(Loc.Get("team.examine_stats3", c.Stamina, c.WeapPow, c.ArmPow)); + } + + /// v1.1.12: a player member's card: the viewer from memory, anyone else from their save. + private async Task ExaminePlayerMember(TeamMemberEntry entry) + { + var summary = entry.Player!; + Character? c = entry.IsViewer ? currentPlayer : null; + if (c == null && SaveSystem.Instance.Backend is SqlSaveBackend backend) + { + try + { + string key = !string.IsNullOrEmpty(summary.Username) ? summary.Username : backend.ResolvePlayerUsername(summary.DisplayName) ?? summary.DisplayName; + var data = await backend.ReadGameData(key); + if (data?.Player != null) + { + c = PlayerCharacterLoader.CreateFromSaveData(data.Player, summary.DisplayName, story: data.StorySystems); + // v1.1.12: the combat loader starts at full HP and mana and has no age; show the saved ones, + // and the saved maxima. + if (data.Player.MaxHP > 0) c.MaxHP = data.Player.MaxHP; + if (data.Player.MaxMana > 0) c.MaxMana = data.Player.MaxMana; + c.HP = Math.Min(data.Player.HP, c.MaxHP); + c.Mana = Math.Min(data.Player.Mana, c.MaxMana); + c.Age = data.Player.Age; + } + } + catch (Exception ex) { DebugLogger.Instance.LogWarning("TEAM", $"Could not load the save of {summary.DisplayName}: {ex.Message}"); } + } + + terminal.ClearScreen(); + WriteSectionHeader(summary.DisplayName.ToUpper(), "bright_cyan"); + terminal.WriteLine(""); + terminal.SetColor("bright_white"); + terminal.WriteLine(Loc.Get("team.examine_section_identity")); + terminal.SetColor("white"); + terminal.WriteLine($" {Loc.Get("status.class")}: {GameConfig.GetLocalizedClassName(summary.ClassId)}"); + if (c != null) + { + terminal.WriteLine($" {Loc.Get("status.race")}: {GameConfig.GetLocalizedRaceName(c.Race)}"); + terminal.WriteLine($" {Loc.Get("team.examine_sex")}: {GameConfig.GetLocalizedSexName(c.Sex)}"); + terminal.WriteLine($" {Loc.Get("team.examine_age")}: {c.Age}"); + } + terminal.WriteLine($" {Loc.Get("ui.level")}: {c?.Level ?? summary.Level}"); + terminal.SetColor(summary.IsOnline ? "bright_green" : "gray"); + terminal.WriteLine($" {(summary.IsOnline ? Loc.Get("team.status_online") : Loc.Get("team.status_offline"))}"); + terminal.WriteLine(""); + if (c != null) + { + terminal.SetColor("bright_white"); + terminal.WriteLine(Loc.Get("team.examine_section_combat")); + terminal.SetColor("white"); + terminal.WriteLine($" {Loc.Get("combat.bar_hp")}: {c.HP}/{c.MaxHP}"); + terminal.WriteLine($" {Loc.Get("ui.mana_label")}: {c.Mana}/{c.MaxMana}"); + terminal.WriteLine(""); + WriteMemberStatLines(c); + terminal.WriteLine(""); + } + terminal.SetColor("darkgray"); + terminal.WriteLine(Loc.Get("ui.press_enter")); + await terminal.ReadKeyAsync(); + } + private async Task ShowTeamMembers(string teamName, bool detailed) { WriteSectionHeader(Loc.Get("team_corner.members"), "cyan"); @@ -749,8 +929,10 @@ private async Task ShowTeamMembers(string teamName, bool detailed) var backend = SaveSystem.Instance.Backend as SqlSaveBackend; if (backend != null) { - string myUsername = currentPlayer.DisplayName.ToLower(); - playerMembers = await backend.GetPlayerTeamMembers(teamName, myUsername); + // v1.1.12: the viewer is left out by save key; a teammate may share the viewer's display name + string myKey = GameEngine.InheritanceKey(currentPlayer); + playerMembers = (await backend.GetPlayerTeamMembers(teamName)) + .Where(m => !string.Equals(m.Username, myKey, StringComparison.OrdinalIgnoreCase)).ToList(); } } @@ -800,7 +982,7 @@ private async Task ShowTeamMembers(string teamName, bool detailed) foreach (var pm in playerMembers) { terminal.SetColor("bright_cyan"); - string className = pm.ClassId >= 0 ? ((CharacterClass)pm.ClassId).ToString() : "?"; + string className = GameConfig.GetLocalizedClassName(pm.ClassId); // v1.1.12: localized, was the enum name string onlineStatus = pm.IsOnline ? Loc.Get("team.status_online") : Loc.Get("team.status_offline"); terminal.WriteLine($"{pm.DisplayName,-20} {className,-12} {pm.Level,-5} {"?",-12} {"?",-15} {onlineStatus,-8}"); } @@ -809,7 +991,7 @@ private async Task ShowTeamMembers(string teamName, bool detailed) foreach (var member in teamMembers) { string hpDisplay = $"{member.HP}/{member.MaxHP}"; - string location = member.CurrentLocation ?? "Unknown"; + string location = member.CurrentLocation ?? Loc.Get("team.unknown"); if (location.Length > 14) location = location.Substring(0, 14); if (member.IsAlive) @@ -817,9 +999,9 @@ private async Task ShowTeamMembers(string teamName, bool detailed) else terminal.SetColor("red"); - string specTag = member.Specialization != ClassSpecialization.None ? $" [{member.Specialization}]" : ""; string status = member.IsAlive ? Loc.Get("team.status_alive") : (member.IsPermaDead ? Loc.Get("team.status_gone") : Loc.Get("team.status_dead_label")); - terminal.WriteLine($"{member.DisplayName,-20} {member.Class}{specTag,-12} {member.Level,-5} {hpDisplay,-12} {location,-15} {status,-8}"); + string classCol = GameConfig.GetLocalizedClassName(member.Class) + SpecTag(member); // v1.1.12: localized class + terminal.WriteLine($"{member.DisplayName,-20} {classCol,-12} {member.Level,-5} {hpDisplay,-12} {location,-15} {status,-8}"); } terminal.WriteLine(""); @@ -832,19 +1014,18 @@ private async Task ShowTeamMembers(string teamName, bool detailed) // Show player members foreach (var pm in playerMembers) { - string className = pm.ClassId >= 0 ? ((CharacterClass)pm.ClassId).ToString() : "?"; + string className = GameConfig.GetLocalizedClassName(pm.ClassId); string onlineTag = pm.IsOnline ? Loc.Get("team.online_tag") : ""; terminal.SetColor("bright_cyan"); - terminal.WriteLine($" {pm.DisplayName} - Level {pm.Level} {className}{onlineTag}"); + terminal.WriteLine($" {pm.DisplayName} - {Loc.Get("inn.npc_level_class", pm.Level, className)}{onlineTag}"); } // Show NPC members foreach (var member in teamMembers) { - string specTag = member.Specialization != ClassSpecialization.None ? $" [{member.Specialization}]" : ""; string status = member.IsAlive ? "" : Loc.Get("team.member_dead_tag"); terminal.SetColor("white"); - terminal.WriteLine($" {member.DisplayName} - Level {member.Level} {member.ClassName}{specTag}{status}"); + terminal.WriteLine($" {member.DisplayName} - {Loc.Get("inn.npc_level_class", member.Level, GameConfig.GetLocalizedClassName(member.Class))}{SpecTag(member)}{status}"); } } } @@ -908,10 +1089,10 @@ private async Task CreateTeam() terminal.WriteLine(Loc.Get("team.registration_fee", $"{creationCost:N0}")); terminal.WriteLine(""); - // Get team name + // Get team name. v1.1.12: trimmed; a blank name is refused terminal.SetColor("white"); terminal.Write(Loc.Get("team.enter_gang_name")); - string teamName = await terminal.ReadLineAsync(); + string teamName = ((await terminal.ReadLineAsync()) ?? "").Trim(); if (string.IsNullOrEmpty(teamName) || teamName.Length > 40) { @@ -921,9 +1102,9 @@ private async Task CreateTeam() return; } - // Check if team name already exists (NPC teams + player teams) - var allNPCs = NPCSpawnSystem.Instance.ActiveNPCs; - if (allNPCs.Any(n => n.Team == teamName)) + // Check if team name already exists (NPC teams + player teams). v1.1.12: ignoring case, as the + // protection list does (WorldSimulator._playerTeamNames) + if (IsNpcTeamName(teamName)) { terminal.SetColor("red"); terminal.WriteLine(Loc.Get("team.team_name_exists")); @@ -931,17 +1112,13 @@ private async Task CreateTeam() return; } - // Online mode: also check player_teams table - if (DoorMode.IsOnlineMode) + SqlSaveBackend? backend = DoorMode.IsOnlineMode ? SaveSystem.Instance.Backend as SqlSaveBackend : null; + if (backend != null && backend.IsTeamNameTaken(teamName)) { - var backend = SaveSystem.Instance.Backend as SqlSaveBackend; - if (backend != null && backend.IsTeamNameTaken(teamName)) - { - terminal.SetColor("red"); - terminal.WriteLine(Loc.Get("team.player_team_exists")); - await Task.Delay(2000); - return; - } + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.player_team_exists")); + await Task.Delay(2000); + return; } // Get password @@ -956,6 +1133,36 @@ private async Task CreateTeam() return; } + // v1.1.12: the gold can have gone while the prompts were up + if (currentPlayer.Gold < creationCost) + { + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.you_only_have", $"{currentPlayer.Gold:N0}")); + await Task.Delay(2000); + return; + } + + // Online: register in player_teams FIRST. v1.1.12: the fee was taken before this insert and its + // result ignored, so a name taken a moment earlier by another session (the check above is not a + // lock) cost the fee and left a member of a team with no row. The insert is guarded; only a + // created row is paid for. + if (backend != null) + { + string hashedPW = SqlSaveBackend.HashTeamPassword(password); + // v1.1.10: the team's leader key is the save key that queued bequests are delivered + // under (GameEngine.InheritanceKey), not the display name, which differs for an alt + // and changes with a marriage. created_by is read only to find where a dying NPC + // member's belongings go (WorldSimulator.BequeathItemsToTeamLeader). + string username = GameEngine.InheritanceKey(currentPlayer); + if (!await backend.CreatePlayerTeam(teamName, hashedPW, username)) + { + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.player_team_exists")); + await Task.Delay(2000); + return; + } + } + // Deduct the creation cost currentPlayer.Gold -= creationCost; @@ -970,22 +1177,6 @@ private async Task CreateTeam() // Register so WorldSimulator protects this team from NPC AI WorldSimulator.RegisterPlayerTeam(teamName); - // Online mode: register in player_teams table - if (DoorMode.IsOnlineMode) - { - var backend = SaveSystem.Instance.Backend as SqlSaveBackend; - if (backend != null) - { - string hashedPW = SqlSaveBackend.HashTeamPassword(password); - // v1.1.10: the team's leader key is the save key that queued bequests are delivered - // under (GameEngine.InheritanceKey), not the display name, which differs for an alt - // and changes with a marriage. created_by is read only to find where a dying NPC - // member's belongings go (WorldSimulator.BequeathItemsToTeamLeader). - string username = GameEngine.InheritanceKey(currentPlayer); - await backend.CreatePlayerTeam(teamName, hashedPW, username); - } - } - terminal.WriteLine(""); terminal.SetColor("bright_green"); terminal.WriteLine(Loc.Get("team.gang_created", teamName)); @@ -995,16 +1186,35 @@ private async Task CreateTeam() terminal.WriteLine(""); // Generate news - NewsSystem.Instance.Newsy(true, $"{currentPlayer.DisplayName} formed a new team: '{teamName}'!"); + NewsSystem.Instance.Newsy(true, Loc.Get("team.news_formed", currentPlayer.DisplayName, teamName)); if (DoorMode.IsOnlineMode) UsurperRemake.Systems.OnlineStateManager.Instance?.AddNews( - $"{currentPlayer.DisplayName} formed a new team: '{teamName}'!", "team"); + Loc.Get("team.news_formed", currentPlayer.DisplayName, teamName), "team"); terminal.SetColor("darkgray"); terminal.WriteLine(Loc.Get("ui.press_enter")); await terminal.ReadKeyAsync(); } + /// v1.1.12: an NPC team goes by this name, in any case. + private static bool IsNpcTeamName(string teamName) => + NPCSpawnSystem.Instance.ActiveNPCs.Any(n => !string.IsNullOrEmpty(n.Team) && string.Equals(n.Team, teamName, StringComparison.OrdinalIgnoreCase)); + + /// + /// v1.1.12: true (and says so) when the team has no free slot. Join checks before the password and + /// again after it, as another player can join while the prompt is up. + /// + private async Task RefuseJoinIfFull(string teamName) + { + if (await TeamSlotsUsed(teamName) < MaxTeamSize) return false; + terminal.WriteLine(""); + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.join_team_full", teamName, MaxTeamSize)); + terminal.WriteLine(""); + await Task.Delay(2000); + return true; + } + /// /// Join an existing team /// @@ -1031,14 +1241,24 @@ private async Task JoinTeam() return; } - terminal.WriteLine(""); - terminal.SetColor("cyan"); - terminal.Write(Loc.Get("team.which_gang_join")); - terminal.SetColor("white"); - string teamName = await terminal.ReadLineAsync(); - - if (string.IsNullOrEmpty(teamName)) + // v1.1.12: the teams are listed (the rankings' rows) instead of asking for an exact name + var teams = await LoadTeamRankings(); + if (teams.Count == 0) + { + terminal.WriteLine(""); + terminal.SetColor("yellow"); + terminal.WriteLine(Loc.Get("team.no_teams_yet")); + await Task.Delay(2000); return; + } + var picked = await PickFromList(teams, TeamPickRow, t => t.TeamName, "team.pick_team_join_title", + t => t.ControlsTurf ? "bright_yellow" : "white"); + if (picked == null) return; + string teamName = picked.TeamName; + + // v1.1.12: five members at most, players and NPCs together, the dead holding their slot. Checked + // before the password, whose check stamps the team's last join. + if (await RefuseJoinIfFull(teamName)) return; // Online mode: check player_teams table first if (DoorMode.IsOnlineMode) @@ -1056,6 +1276,7 @@ private async Task JoinTeam() var (exists, pwCorrect) = await backend.VerifyPlayerTeam(teamName, password); if (exists && pwCorrect) { + if (await RefuseJoinIfFull(teamName)) return; currentPlayer.Team = teamName; currentPlayer.TeamPW = password; currentPlayer.CTurf = false; @@ -1070,10 +1291,10 @@ private async Task JoinTeam() terminal.WriteLine(Loc.Get("team.joined_team", teamName)); terminal.WriteLine(""); - NewsSystem.Instance.Newsy(true, $"{currentPlayer.DisplayName} joined the team '{teamName}'!"); + NewsSystem.Instance.Newsy(true, Loc.Get("team.news_joined", currentPlayer.DisplayName, teamName)); if (DoorMode.IsOnlineMode) UsurperRemake.Systems.OnlineStateManager.Instance?.AddNews( - $"{currentPlayer.DisplayName} joined the team '{teamName}'!", "team"); + Loc.Get("team.news_joined", currentPlayer.DisplayName, teamName), "team"); terminal.SetColor("darkgray"); terminal.WriteLine(Loc.Get("ui.press_enter")); @@ -1112,6 +1333,7 @@ private async Task JoinTeam() if (npcPassword == teamMember.TeamPW) { + if (await RefuseJoinIfFull(teamName)) return; currentPlayer.Team = teamName; currentPlayer.TeamPW = npcPassword; currentPlayer.CTurf = teamMember.CTurf; @@ -1125,10 +1347,10 @@ private async Task JoinTeam() terminal.WriteLine(Loc.Get("team.joined_team", teamName)); terminal.WriteLine(""); - NewsSystem.Instance.Newsy(true, $"{currentPlayer.DisplayName} joined the team '{teamName}'!"); + NewsSystem.Instance.Newsy(true, Loc.Get("team.news_joined", currentPlayer.DisplayName, teamName)); if (DoorMode.IsOnlineMode) UsurperRemake.Systems.OnlineStateManager.Instance?.AddNews( - $"{currentPlayer.DisplayName} joined the team '{teamName}'!", "team"); + Loc.Get("team.news_joined", currentPlayer.DisplayName, teamName), "team"); terminal.SetColor("darkgray"); terminal.WriteLine(Loc.Get("ui.press_enter")); @@ -1171,38 +1393,42 @@ private async Task QuitTeam() currentPlayer.TeamPW = ""; currentPlayer.CTurf = false; currentPlayer.TeamRec = 0; + // Online, this save lands before the member count below, or the SQL count still finds the + // quitting player in the team. v1.1.12: the second, throttle-reset AutoSave that followed was + // the same write again and is gone. await PersistTeamMembershipChange(); - WorldSimulator.UnregisterPlayerTeam(oldTeam); - - // Online mode: save player data FIRST so the DB reflects the team change, - // THEN update member count. Without this, the SQL count still finds the - // quitting player in the team (stale player_data) and never reaches 0. - if (DoorMode.IsOnlineMode) + var backend = DoorMode.IsOnlineMode ? SaveSystem.Instance.Backend as SqlSaveBackend : null; + if (backend == null) { - SaveSystem.Instance.ResetAutoSaveThrottle(); - await SaveSystem.Instance.AutoSave(currentPlayer); - - var backend = SaveSystem.Instance.Backend as SqlSaveBackend; - if (backend != null) - { - await backend.UpdatePlayerTeamMemberCount(oldTeam); - - // v1.1.11: a leader who quits passes the team to the highest-level player left in it - string myKey = GameEngine.InheritanceKey(currentPlayer); - if (string.Equals(await backend.GetTeamLeaderUsername(oldTeam), myKey, StringComparison.Ordinal)) - backend.TryPassTeamLeadership(oldTeam, myKey, myKey, requireOldLeaderGone: true, out _); - - // If team is now empty (no players AND no NPCs), delete it - var remainingPlayers = await backend.GetPlayerTeamMembers(oldTeam); - var remainingNPCs = NPCSpawnSystem.Instance.ActiveNPCs - .Count(n => n.Team == oldTeam && !n.IsDead && !n.IsPermaDead); - if (remainingPlayers.Count == 0 && remainingNPCs == 0) - { - await backend.DeletePlayerTeam(oldTeam); - DebugLogger.Instance.LogInfo("TEAM", $"Team '{oldTeam}' dissolved — no members remaining"); - } - } + // single-player: no other player can be on the team + WorldSimulator.UnregisterPlayerTeam(oldTeam); + } + else + { + await backend.UpdatePlayerTeamMemberCount(oldTeam); + + // v1.1.11: a leader who quits passes the team to the highest-level player left in it + string myKey = GameEngine.InheritanceKey(currentPlayer); + if (string.Equals(await backend.GetTeamLeaderUsername(oldTeam), myKey, StringComparison.Ordinal)) + backend.TryPassTeamLeadership(oldTeam, myKey, myKey, requireOldLeaderGone: true, out _); + + var remainingPlayers = await backend.GetPlayerTeamMembers(oldTeam); + // v1.1.12: a dead NPC member respawns on the team, so it still counts (the 5-slot rule too) + var remainingNPCs = NPCSpawnSystem.Instance.ActiveNPCs + .Count(n => n.Team == oldTeam && !n.IsPermaDead && !n.IsAgedDeath); // as CountTeamSlots + + // v1.1.12: the protection is kept while a player is still on the team; it was dropped on + // every quit, which left the members still in it open to the NPC AI + if (remainingPlayers.Count == 0) + WorldSimulator.UnregisterPlayerTeam(oldTeam); + + // If team is now empty (no players AND no NPCs), delete it. v1.1.12: with its upgrades and + // vault (a later team of the same name inherited them), and not within the join grace; a + // team left then is the empty-team sweep's to remove + // v1.1.12: a roster being rebuilt can show no NPC members; the empty-team sweep removes it later + if (remainingPlayers.Count == 0 && remainingNPCs == 0 && !NPCSpawnSystem.Instance.IsRebuilding && backend.DeleteEmptyTeam(oldTeam)) + DebugLogger.Instance.LogInfo("TEAM", $"Team '{oldTeam}' dissolved, no members remaining"); } terminal.WriteLine(""); @@ -1210,10 +1436,10 @@ private async Task QuitTeam() terminal.WriteLine(Loc.Get("team.left_team")); terminal.WriteLine(""); - NewsSystem.Instance.Newsy(true, $"{currentPlayer.DisplayName} left the team '{oldTeam}'!"); + NewsSystem.Instance.Newsy(true, Loc.Get("team.news_left", currentPlayer.DisplayName, oldTeam)); if (DoorMode.IsOnlineMode) UsurperRemake.Systems.OnlineStateManager.Instance?.AddNews( - $"{currentPlayer.DisplayName} left the team '{oldTeam}'!", "team"); + Loc.Get("team.news_left", currentPlayer.DisplayName, oldTeam), "team"); terminal.SetColor("darkgray"); terminal.WriteLine(Loc.Get("ui.press_enter")); @@ -1254,9 +1480,10 @@ private async Task RecruitNPCToTeam() return; } - // Count current team size (include dead members — they still occupy a slot until dismissed or permadead) + // Count current team size. v1.1.12: the comment said the dead hold a slot but the code left them out, + // and player members were not counted at all; both count now (TeamSlotsUsed) var allNPCs = NPCSpawnSystem.Instance.ActiveNPCs; - var currentTeamSize = allNPCs.Count(n => n.Team == currentPlayer.Team && !n.IsDead) + 1; // +1 for player + var currentTeamSize = await TeamSlotsUsed(currentPlayer.Team); if (currentTeamSize >= MaxTeamSize) { @@ -1507,6 +1734,17 @@ private static string TruncateName(string name, int max) _ => role.ToString(), }; + /// v1.1.12: one specialization role, named (the raw enum name was shown). + private static string SpecRoleName(UsurperRemake.Data.SpecRole role) => role switch + { + UsurperRemake.Data.SpecRole.Tank => Loc.Get("team.spec_role_tank"), + UsurperRemake.Data.SpecRole.DPS => Loc.Get("team.spec_role_dps"), + UsurperRemake.Data.SpecRole.Healer => Loc.Get("team.spec_role_healer"), + UsurperRemake.Data.SpecRole.Utility => Loc.Get("team.spec_role_utility"), + UsurperRemake.Data.SpecRole.Debuff => Loc.Get("team.spec_role_debuff"), + _ => role.ToString(), + }; + /// /// v0.57.11: prompts the player to pick a role to filter by, or clear the /// filter. Returns the new filter value (null = "all roles"). @@ -1588,7 +1826,7 @@ private async Task SearchAndRecruitByName(List allNPCs) terminal.WriteLine(Loc.Get("team.recruit_multiple_matches_header")); terminal.SetColor("white"); for (int i = 0; i < disamb.Count; i++) - terminal.WriteLine($" {i + 1}. {disamb[i].DisplayName} ({disamb[i].ClassName}, Lv.{disamb[i].Level})"); + terminal.WriteLine($" {i + 1}. {disamb[i].DisplayName} ({Loc.Get("inn.npc_level_class", disamb[i].Level, disamb[i].ClassName)})"); terminal.WriteLine(""); terminal.SetColor("cyan"); terminal.Write(Loc.Get("team.recruit_pick_match")); @@ -1713,6 +1951,19 @@ private async Task ConfirmAndRecruit(NPC recruit, TeamSystem.RecruitmentBand ban // recruit list re-shows the NPC. Defense: re-resolve `recruit` to a // live ActiveNPCs reference by ID before any mutation. If the live // NPC is gone (rare — permadied or evicted by another path), refuse. + // v1.1.12: the team can have filled while the list was up; checked before the live lookup below, since + // the query yields and a roster reload in that wait would leave the looked-up NPC stale + if (await TeamSlotsUsed(currentPlayer.Team) >= MaxTeamSize) + { + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.team_full", MaxTeamSize)); + terminal.WriteLine(""); + terminal.SetColor("darkgray"); + terminal.WriteLine(Loc.Get("ui.press_enter")); + await terminal.ReadKeyAsync(); + return; + } + var liveRecruit = NPCSpawnSystem.Instance.ActiveNPCs .FirstOrDefault(n => !string.IsNullOrEmpty(n.ID) && n.ID == recruit.ID); if (liveRecruit == null) @@ -1809,7 +2060,7 @@ private async Task ConfirmAndRecruit(NPC recruit, TeamSystem.RecruitmentBand ban terminal.SetColor("bright_cyan"); terminal.WriteLine(Loc.Get("team.recruit_quote", recruit.DisplayName)); - NewsSystem.Instance.Newsy(true, $"{currentPlayer.DisplayName} recruited {recruit.DisplayName} into team '{currentPlayer.Team}'!"); + NewsSystem.Instance.Newsy(true, Loc.Get("team.news_recruited", currentPlayer.DisplayName, recruit.DisplayName, currentPlayer.Team)); if (DoorMode.IsOnlineMode && OnlineStateManager.Instance != null) { @@ -1838,35 +2089,17 @@ private async Task ExamineMember() return; } - terminal.WriteLine(""); - terminal.SetColor("cyan"); - terminal.WriteLine(Loc.Get("team.examine_prompt")); - terminal.Write(": "); - terminal.SetColor("white"); - string memberName = await terminal.ReadLineAsync(); - - if (memberName == "?") + // v1.1.12: every member is listed, players too (the name prompt found NPCs only, so a player member + // could not be examined) + var entries = await GetTeamMemberEntries(includeViewer: true); + var pickedEntry = await PickFromList(entries, MemberPickRow, e => e.Name, "team.pick_examine_title", MemberPickColor); + if (pickedEntry == null) return; + if (pickedEntry.Npc == null) { - await ShowTeamMembers(currentPlayer.Team, true); - terminal.SetColor("darkgray"); - terminal.WriteLine(Loc.Get("ui.press_enter")); - await terminal.ReadKeyAsync(); - return; - } - - // Find the member - var allNPCs = NPCSpawnSystem.Instance.ActiveNPCs; - var member = allNPCs.FirstOrDefault(n => - n.Team == currentPlayer.Team && - n.DisplayName.Equals(memberName, StringComparison.OrdinalIgnoreCase)); - - if (member == null) - { - terminal.SetColor("red"); - terminal.WriteLine(Loc.Get("team.member_not_found", memberName)); - await Task.Delay(2000); + await ExaminePlayerMember(pickedEntry); return; } + var member = pickedEntry.Npc; // Show detailed stats. Expanded v0.61.5: identity (age/sex/race/class), // alignment, attitude (NPC dominant emotion + impression of player), @@ -1914,11 +2147,11 @@ private async Task ExamineMember() { var specDef = UsurperRemake.Data.SpecializationData.GetSpec(member.Specialization); terminal.SetColor("cyan"); - terminal.WriteLine($" {Loc.Get("spec.label")}: {specDef?.Name ?? member.Specialization.ToString()} ({specDef?.Role})"); + terminal.WriteLine($" {Loc.Get("spec.label")}: {specDef?.Name ?? member.Specialization.ToString()} ({(specDef != null ? SpecRoleName(specDef.Role) : "")})"); terminal.SetColor("white"); } - terminal.WriteLine($" {Loc.Get("status.race")}: {member.Race}"); - terminal.WriteLine($" {Loc.Get("team.examine_sex")}: {member.Sex}"); + terminal.WriteLine($" {Loc.Get("status.race")}: {GameConfig.GetLocalizedRaceName(member.Race)}"); // v1.1.12: localized + terminal.WriteLine($" {Loc.Get("team.examine_sex")}: {GameConfig.GetLocalizedSexName(member.Sex)}"); terminal.WriteLine($" {Loc.Get("team.examine_age")}: {member.Age}"); terminal.WriteLine($" {Loc.Get("ui.level")}: {member.Level}"); @@ -1946,7 +2179,14 @@ private async Task ExamineMember() terminal.WriteLine($" {Loc.Get("team.examine_chivalry")}: {member.Chivalry} {Loc.Get("team.examine_darkness")}: {member.Darkness}"); if (member.NPCFaction.HasValue) { - terminal.WriteLine($" {Loc.Get("team.examine_faction")}: {member.NPCFaction.Value}"); + string facName = member.NPCFaction.Value switch // v1.1.12: localized, was the enum name + { + Faction.TheCrown => Loc.Get("faction.name_crown"), + Faction.TheShadows => Loc.Get("faction.name_shadows"), + Faction.TheFaith => Loc.Get("faction.name_faith"), + _ => member.NPCFaction.Value.ToString() + }; + terminal.WriteLine($" {Loc.Get("team.examine_faction")}: {facName}"); } if (!string.IsNullOrEmpty(member.WorshippedGod)) { @@ -2064,12 +2304,10 @@ private async Task ExamineMember() terminal.WriteLine($" {Loc.Get("ui.mana_label")}: {member.Mana}/{member.MaxMana}"); terminal.WriteLine($" {Loc.Get("ui.gold")}: {member.Gold:N0}"); terminal.WriteLine(""); - terminal.WriteLine($" STR: {member.Strength} DEX: {member.Dexterity} AGI: {member.Agility} CON: {member.Constitution}"); - terminal.WriteLine($" INT: {member.Intelligence} WIS: {member.Wisdom} CHA: {member.Charisma} DEF: {member.Defence}"); - terminal.WriteLine($" STA: {member.Stamina} WeapPow: {member.WeapPow} ArmPow: {member.ArmPow}"); + WriteMemberStatLines(member); terminal.WriteLine(""); - terminal.WriteLine($"{Loc.Get("ui.location")}: {member.CurrentLocation ?? "Unknown"}"); + terminal.WriteLine($"{Loc.Get("ui.location")}: {member.CurrentLocation ?? Loc.Get("team.unknown")}"); terminal.WriteLine(""); terminal.SetColor("darkgray"); @@ -2092,13 +2330,46 @@ private async Task ChangeTeamPassword() return; } + // v1.1.12: online, the password a join checks is player_teams.password_hash, and only the leader may + // change it; the change used to set only the in-memory copies, so the new password was refused and + // the old one kept working + var backend = DoorMode.IsOnlineMode ? SaveSystem.Instance.Backend as SqlSaveBackend : null; + string myKey = GameEngine.InheritanceKey(currentPlayer); + // v1.1.12: a team an NPC founded has no player_teams row; its password is the one its NPCs hold, which + // a join checks, so it is changed as before (the old password checked against that one) + bool? hasRow = backend?.HasPlayerTeamRow(currentPlayer.Team); + if (backend != null && hasRow == null) + { + terminal.WriteLine(""); + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.failed_generic")); + await Task.Delay(2000); + return; + } + bool npcTeam = hasRow == false; + if (npcTeam) backend = null; + if (backend != null && !string.Equals(await backend.GetTeamLeaderUsername(currentPlayer.Team), myKey, StringComparison.Ordinal)) + { + terminal.WriteLine(""); + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.password_leader_only")); + terminal.WriteLine(""); + await Task.Delay(2000); + return; + } + terminal.WriteLine(""); terminal.SetColor("cyan"); terminal.Write(Loc.Get("team.enter_current_password")); terminal.SetColor("white"); string currentPassword = await terminal.ReadLineAsync(); - if (currentPassword != currentPlayer.TeamPW) + // offline the in-memory copy is the password; an NPC team's is the one its NPCs hold (checked if set); + // a player team's stored hash is checked below + string heldPassword = npcTeam + ? NPCSpawnSystem.Instance.ActiveNPCs.FirstOrDefault(n => n.Team == currentPlayer.Team && n.IsAlive)?.TeamPW ?? currentPlayer.TeamPW ?? "" + : currentPlayer.TeamPW; + if (backend == null && (!npcTeam || !string.IsNullOrEmpty(heldPassword)) && currentPassword != heldPassword) { terminal.WriteLine(""); terminal.SetColor("red"); @@ -2115,7 +2386,16 @@ private async Task ChangeTeamPassword() if (!string.IsNullOrEmpty(newPassword) && newPassword.Length <= 20) { - string oldPassword = currentPlayer.TeamPW; + if (backend != null && !backend.ChangeTeamPassword(currentPlayer.Team, myKey, currentPassword, newPassword)) + { + terminal.WriteLine(""); + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.wrong_password_short")); + terminal.WriteLine(""); + await Task.Delay(2000); + return; + } + currentPlayer.TeamPW = newPassword; // Update all team members' passwords @@ -2179,7 +2459,8 @@ private async Task SendTeamMessage() // is the SQL one), under the mailbox's daily send cap. var backend = SaveSystem.Instance.Backend as SqlSaveBackend; var members = backend != null - ? await backend.GetPlayerTeamMembers(currentPlayer.Team, excludeDisplayName: currentPlayer.DisplayName) + ? (await backend.GetPlayerTeamMembers(currentPlayer.Team)) // v1.1.12: the sender left out by save key + .Where(m => !string.Equals(m.Username, GameEngine.InheritanceKey(currentPlayer), StringComparison.OrdinalIgnoreCase)).ToList() : new List(); terminal.WriteLine(""); @@ -2206,104 +2487,171 @@ private async Task SendTeamMessage() var memberUser = member.IsOnline ? backend.ResolvePlayerUsername(member.DisplayName) : null; if (memberUser != null) UsurperRemake.Server.MudServer.Instance?.SendToPlayer(memberUser, - $"\u001b[35m [Mail] {currentPlayer.DisplayName}: {body}\u001b[0m"); + $"\u001b[35m {Loc.Get("team.mail_push_tag")} {currentPlayer.DisplayName}: {body}\u001b[0m"); } terminal.SetColor("bright_green"); terminal.WriteLine(Loc.Get("team.message_mailed", members.Count)); terminal.SetColor("white"); terminal.WriteLine(Loc.Get("team.your_message", message)); } - terminal.WriteLine(""); + terminal.WriteLine(""); + } + + await Task.Delay(2000); + } + + /// + /// Sack a team member + /// + private async Task SackMember() + { + if (string.IsNullOrEmpty(currentPlayer.Team)) + { + terminal.WriteLine(""); + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.not_in_team")); + terminal.WriteLine(""); + await Task.Delay(2000); + return; + } + + // v1.1.12: every member is listed, players too. A player cannot be sacked: their membership is in + // their own save, which only their session writes (no cross-session save writes), so they leave + // with [L] themselves. + var entries = await GetTeamMemberEntries(includeViewer: false); + if (entries.Count == 0) + { + terminal.WriteLine(""); + terminal.SetColor("yellow"); + terminal.WriteLine(Loc.Get("team.only_member")); + await Task.Delay(2000); + return; + } + var picked = await PickFromList(entries, MemberPickRow, e => e.Name, "team.pick_sack_title", MemberPickColor); + if (picked == null) return; + if (picked.Npc == null) + { + terminal.WriteLine(""); + terminal.SetColor("yellow"); + terminal.WriteLine(Loc.Get("team.sack_player_refused", picked.Name)); + await Task.Delay(2500); + return; + } + var member = picked.Npc; + + terminal.WriteLine(""); + terminal.SetColor("yellow"); + terminal.Write(Loc.Get("team.confirm_sack", member.DisplayName)); + string response = await terminal.ReadLineAsync(); + if (!GameConfig.IsAffirmative(response)) return; + + // v1.1.12: the NPC keeps what they wear when they go; offer to take it first + bool tookGear = false; + var recovered = new List<(EquipmentSlot Slot, int Id, string Name)>(); + if (HasRemovableEquipment(member)) + { + terminal.SetColor("yellow"); + terminal.WriteLine(Loc.Get("team.sack_gear_warning", member.DisplayName)); + terminal.Write(Loc.Get("team.sack_take_gear_prompt")); + if (GameConfig.IsAffirmative(await terminal.ReadLineAsync())) + { + // v1.1.12: the live copy, checked still on this team before any gear comes off; a reload or + // a move while the prompts were up means nothing is taken + var liveForGear = LiveTeamNpc(member); + if (liveForGear == null || liveForGear.Team != currentPlayer.Team) + { + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.sack_gear_gone", member.DisplayName)); + await Task.Delay(2000); + return; + } + member = liveForGear; + // v1.1.12: saved straight after the move, nothing awaited between, so a roster reload in the + // pause below loads the NPC without the gear + var cursed = new List(); + recovered = MoveEquipmentToPlayer(member, cursed); + tookGear = recovered.Count > 0; + if (tookGear) await SaveRecoveredGear(); + await ReportEquipmentTaken(member, recovered.Count, cursed); + } } - await Task.Delay(2000); - } - - /// - /// Sack a team member - /// - private async Task SackMember() - { - if (string.IsNullOrEmpty(currentPlayer.Team)) + // v1.1.12: the NPC is looked up again by ID; a world_state reload while the prompts were up replaced + // the object picked from the list, and clearing its Team changed nothing + var live = LiveTeamNpc(member); + // v1.1.12: a reload read before the save can bring the NPC back wearing what was taken; it comes off + bool stripped = live != null && StripRecoveredGear(live, recovered) > 0; + if (live == null || live.Team != currentPlayer.Team) { - terminal.WriteLine(""); + if (stripped) await SaveRecoveredGear(); terminal.SetColor("red"); - terminal.WriteLine(Loc.Get("team.not_in_team")); - terminal.WriteLine(""); + terminal.WriteLine(Loc.Get("team.member_gone_now", member.DisplayName)); await Task.Delay(2000); return; } + live.Team = ""; + live.TeamPW = ""; + live.CTurf = false; terminal.WriteLine(""); - terminal.SetColor("cyan"); - terminal.WriteLine(Loc.Get("team.sack_prompt")); - terminal.Write(": "); - terminal.SetColor("white"); - string memberName = await terminal.ReadLineAsync(); + terminal.SetColor("bright_green"); + terminal.WriteLine(Loc.Get("team.member_sacked", live.DisplayName)); + terminal.WriteLine(""); - if (memberName == "?") + NewsSystem.Instance.Newsy(true, Loc.Get("team.news_sacked", live.DisplayName, currentPlayer.Team)); + + // Persist NPC team removal immediately. v0.57.11 (spudman + // report: "Sacking members that died didn't seem to have any + // effects other than history log"). Changed from fire-and-forget + // `_ = Task.Run(...)` to `await` because if another online + // session triggers a world_state reload between the `Team = ""` + // mutation and the Task completing, the cleared Team field + // gets overwritten from the stale snapshot and the sack is + // silently undone. Same class of bug as the v0.57.10 Coosh + // turf-reverts-on-relog issue. + if (DoorMode.IsOnlineMode && OnlineStateManager.Instance != null) { - await ShowTeamMembers(currentPlayer.Team, true); - terminal.SetColor("darkgray"); - terminal.WriteLine(Loc.Get("ui.press_enter")); - await terminal.ReadKeyAsync(); - return; + try { await OnlineStateManager.Instance.SaveAllSharedState(); } + catch (Exception ex) { DebugLogger.Instance.LogError("TEAM", $"SaveAllSharedState failed after sack: {ex.Message}"); } } - if (!string.IsNullOrEmpty(memberName)) - { - var allNPCs = NPCSpawnSystem.Instance.ActiveNPCs; - var member = allNPCs.FirstOrDefault(n => - n.Team == currentPlayer.Team && - n.DisplayName.Equals(memberName, StringComparison.OrdinalIgnoreCase)); - - if (member == null) - { - terminal.SetColor("red"); - terminal.WriteLine(Loc.Get("team.member_not_found", memberName)); - await Task.Delay(2000); - return; - } - - terminal.SetColor("yellow"); - terminal.Write(Loc.Get("team.confirm_sack", member.DisplayName)); - string response = await terminal.ReadLineAsync(); - - if (GameConfig.IsAffirmative(response)) - { - member.Team = ""; - member.TeamPW = ""; - member.CTurf = false; - - terminal.WriteLine(""); - terminal.SetColor("bright_green"); - terminal.WriteLine(Loc.Get("team.member_sacked", member.DisplayName)); - terminal.WriteLine(""); + terminal.SetColor("darkgray"); + terminal.WriteLine(Loc.Get("ui.press_enter")); + await terminal.ReadKeyAsync(); + } - NewsSystem.Instance.Newsy(true, $"{member.DisplayName} was kicked out of team '{currentPlayer.Team}'!"); - - // Persist NPC team removal immediately. v0.57.11 (spudman - // report: "Sacking members that died didn't seem to have any - // effects other than history log"). Changed from fire-and-forget - // `_ = Task.Run(...)` to `await` because if another online - // session triggers a world_state reload between the `Team = ""` - // mutation and the Task completing, the cleared Team field - // gets overwritten from the stale snapshot and the sack is - // silently undone. Same class of bug as the v0.57.10 Coosh - // turf-reverts-on-relog issue. - if (DoorMode.IsOnlineMode && OnlineStateManager.Instance != null) - { - try { await OnlineStateManager.Instance.SaveAllSharedState(); } - catch (Exception ex) { DebugLogger.Instance.LogError("TEAM", $"SaveAllSharedState failed after sack: {ex.Message}"); } - } + /// v1.1.12: the gear taken on a sack, saved at once: the NPC's side first, then the player's (a + /// crash between loses it rather than copying it). + private Task SaveRecoveredGear() => SaveGearTakenFromNpc(null); - terminal.SetColor("darkgray"); - terminal.WriteLine(Loc.Get("ui.press_enter")); - await terminal.ReadKeyAsync(); - } + /// v1.1.12: takes off the NPC whatever it still wears of the gear recovered from it, as a reloaded copy + /// of it can: the same slot and the same item, by ID or by name (a reload registers looted gear under a new + /// ID, GameEngine's NPC restore). The items are the player's now; they are not handed out again. Returns + /// the count taken off. + internal static int StripRecoveredGear(Character npc, IEnumerable<(EquipmentSlot Slot, int Id, string Name)> recovered) + { + int stripped = 0; + foreach (var (slot, id, name) in recovered) + { + if (!npc.EquippedItems.TryGetValue(slot, out var worn) || worn <= 0) continue; + if (worn != id && EquipmentDatabase.GetById(worn)?.Name != name) continue; + if (npc.UnequipSlot(slot) != null) stripped++; + } + if (stripped > 0) + { + npc.RecalculateStats(); + if (npc.IsCompanion) CompanionSystem.Instance?.SyncCompanionEquipment(npc); } + return stripped; } + /// v1.1.12: the character wears something that can be taken off (not cursed). + private static bool HasRemovableEquipment(Character c) => + Enum.GetValues(typeof(EquipmentSlot)).Cast() + .Where(slot => slot != EquipmentSlot.None) + .Select(c.GetEquipment) + .Any(item => item != null && !item.IsCursed); + /// /// Resurrect a dead teammate /// @@ -2341,7 +2689,7 @@ private async Task ResurrectTeammate() foreach (var pd in permadeadMembers) { string reason = pd.IsAgedDeath ? Loc.Get("team.permadead_reason_age") : Loc.Get("team.permadead_reason_slain"); - terminal.WriteLine($" {pd.DisplayName} — {reason}"); + terminal.WriteLine($" {pd.DisplayName} - {reason}"); } } else @@ -2354,47 +2702,76 @@ private async Task ResurrectTeammate() return; } + // v1.1.12: listed in the shared picker, and the cost confirmed before it is paid + var toResurrect = await PickFromList(deadMembers, + d => Loc.Get("team.pick_resurrect_row", d.DisplayName, Loc.Get("inn.npc_level_class", d.Level, GameConfig.GetLocalizedClassName(d.Class)), $"{ResurrectionCost(d):N0}"), + d => d.DisplayName, "team.dead_members_header", _ => "white"); + if (toResurrect == null) return; + + long cost = ResurrectionCost(toResurrect); terminal.WriteLine(""); - terminal.SetColor("cyan"); - terminal.WriteLine(Loc.Get("team.dead_members_header")); - for (int i = 0; i < deadMembers.Count; i++) + if (currentPlayer.Gold < cost) { - var dead = deadMembers[i]; - long cost = dead.Level * 1000; // Resurrection cost - terminal.SetColor("white"); - terminal.WriteLine($"{i + 1}. {dead.DisplayName} (Level {dead.Level}) - Cost: {cost:N0} gold"); + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.need_gold_resurrect", $"{cost:N0}", toResurrect.DisplayName)); + await Task.Delay(2000); + return; } + terminal.SetColor("yellow"); + terminal.Write(Loc.Get("team.confirm_resurrect", toResurrect.DisplayName, $"{cost:N0}")); + if (!GameConfig.IsAffirmative(await terminal.ReadLineAsync())) return; - terminal.WriteLine(""); - terminal.SetColor("cyan"); - terminal.Write(Loc.Get("team.enter_number_resurrect")); - terminal.SetColor("white"); - string input = await terminal.ReadLineAsync(); + // v1.1.12: a dead member holds a slot, so a revival adds no slot; it may bring the living up to + // MaxTeamSize and no further (a team from before the cap, with more, keeps its members). Checked before the + // live lookup below, since the query yields + int deadHere = NPCSpawnSystem.Instance.ActiveNPCs.Count(n => n.Team == currentPlayer.Team && (n.IsDead || !n.IsAlive) && !n.IsPermaDead && !n.IsAgedDeath); + if (await TeamSlotsUsed(currentPlayer.Team) - deadHere >= MaxTeamSize) + { + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.team_full", MaxTeamSize)); + await Task.Delay(2000); + return; + } - if (int.TryParse(input, out int choice) && choice >= 1 && choice <= deadMembers.Count) + // v1.1.12: the NPC is looked up again by ID; a world_state reload while the prompts were up replaced + // the object picked from the list, and the gold paid revived no one + var live = LiveTeamNpc(toResurrect); + if (live == null || live.Team != currentPlayer.Team || (live.IsAlive && !live.IsDead) || live.IsPermaDead || live.IsAgedDeath) { - var toResurrect = deadMembers[choice - 1]; - long cost = toResurrect.Level * 1000; + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.member_gone_now", toResurrect.DisplayName)); + await Task.Delay(2000); + return; + } - if (currentPlayer.Gold < cost) - { - terminal.SetColor("red"); - terminal.WriteLine(Loc.Get("team.need_gold_resurrect", $"{cost:N0}", toResurrect.DisplayName)); - } - else - { - currentPlayer.Gold -= cost; - toResurrect.HP = toResurrect.MaxHP / 2; // Resurrect at half HP - toResurrect.IsDead = false; // Clear permanent death flag - IsAlive is computed from HP > 0 + cost = ResurrectionCost(live); + if (currentPlayer.Gold < cost) + { + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.need_gold_resurrect", $"{cost:N0}", live.DisplayName)); + await Task.Delay(2000); + return; + } - terminal.WriteLine(""); - terminal.SetColor("bright_green"); - terminal.WriteLine(Loc.Get("team.member_resurrected", toResurrect.DisplayName)); - terminal.WriteLine(Loc.Get("team.resurrect_cost", $"{cost:N0}")); + currentPlayer.Gold -= cost; + live.HP = live.MaxHP / 2; // Resurrect at half HP + live.IsDead = false; // Clear permanent death flag - IsAlive is computed from HP > 0 - NewsSystem.Instance.Newsy(true, $"{toResurrect.DisplayName} was resurrected by their team '{currentPlayer.Team}'!"); - } + terminal.WriteLine(""); + terminal.SetColor("bright_green"); + terminal.WriteLine(Loc.Get("team.member_resurrected", live.DisplayName)); + terminal.WriteLine(Loc.Get("team.resurrect_cost", $"{cost:N0}")); + + NewsSystem.Instance.Newsy(true, Loc.Get("team.news_resurrected", live.DisplayName, currentPlayer.Team)); + + // v1.1.12: saved at once, as Sack and Specialize do (the revival was lost to the next reload), and + // the gold with it + if (DoorMode.IsOnlineMode && OnlineStateManager.Instance != null) + { + try { await OnlineStateManager.Instance.SaveAllSharedState(); } + catch (Exception ex) { DebugLogger.Instance.LogError("TEAM", $"SaveAllSharedState failed after resurrect: {ex.Message}"); } } + await ForcePlayerSave(); terminal.WriteLine(""); terminal.SetColor("darkgray"); @@ -2402,6 +2779,8 @@ private async Task ResurrectTeammate() await terminal.ReadKeyAsync(); } + private static long ResurrectionCost(NPC npc) => npc.Level * 1000L; + /// /// Recruit a player's echo as a dungeon ally (online mode only). /// Their character will be loaded from the database and fight as AI. @@ -2422,8 +2801,11 @@ private async Task RecruitPlayerAlly() var backend = SaveSystem.Instance.Backend as SqlSaveBackend; if (backend == null) return; - string myUsername = currentPlayer.DisplayName.ToLower(); - var teammates = await backend.GetPlayerTeamMembers(currentPlayer.Team, myUsername); + // v1.1.12: the viewer is left out by save key; a teammate may share the viewer's display name + string myKey = GameEngine.InheritanceKey(currentPlayer); + var members = await backend.GetPlayerTeamMembers(currentPlayer.Team); + var teammates = members + .Where(m => !string.Equals(m.Username, myKey, StringComparison.OrdinalIgnoreCase)).ToList(); if (teammates.Count == 0) { @@ -2450,10 +2832,14 @@ private async Task RecruitPlayerAlly() // landed (echo never appeared in dungeon) and re-tried, was told // "already in party" with no way to verify or recover. var currentRecruits = GameEngine.Instance?.DungeonPartyPlayerNames ?? new List(); + // v1.1.12: an entry is a save key (an older one a display name); shown by the teammate's display name + string RecruitLabel(string entry) => + teammates.FirstOrDefault(t => t.Username.Equals(entry, StringComparison.OrdinalIgnoreCase))?.DisplayName ?? entry; + bool IsRecruited(PlayerSummary tm) => IsEchoRecruited(currentRecruits, members.Select(m => m.Username).Append(myKey), tm); if (currentRecruits.Count > 0) { terminal.SetColor("bright_cyan"); - terminal.WriteLine(Loc.Get("team.echo_currently_recruited", string.Join(", ", currentRecruits))); + terminal.WriteLine(Loc.Get("team.echo_currently_recruited", string.Join(", ", currentRecruits.Select(RecruitLabel)))); terminal.SetColor("gray"); terminal.WriteLine(Loc.Get("team.echo_unrecruit_hint")); terminal.WriteLine(""); @@ -2473,11 +2859,11 @@ private async Task RecruitPlayerAlly() for (int i = 0; i < teammates.Count; i++) { var tm = teammates[i]; - string className = tm.ClassId >= 0 ? ((CharacterClass)tm.ClassId).ToString() : "Unknown"; + string className = GameConfig.GetLocalizedClassName(tm.ClassId); // v1.1.12: localized string status = tm.IsOnline ? Loc.Get("team.status_online") : Loc.Get("team.status_offline"); // Mark teammates already recruited with a [recruited] tag so the // player can see at a glance which slots are filled. - bool alreadyRecruited = currentRecruits.Contains(tm.DisplayName, StringComparer.OrdinalIgnoreCase); + bool alreadyRecruited = IsRecruited(tm); string tag = alreadyRecruited ? " " + Loc.Get("team.echo_recruited_tag") : ""; terminal.WriteLine($"{i + 1,-3} {tm.DisplayName,-18} {className,-12} {tm.Level,-6} {status,-10}{tag}"); } @@ -2497,7 +2883,7 @@ private async Task RecruitPlayerAlly() // path for the v0.61.5 "echo recruited but never materialized" bug. if (!string.IsNullOrEmpty(input) && input.Trim().Equals("U", StringComparison.OrdinalIgnoreCase)) { - await UnrecruitPlayerAlly(currentRecruits); + await UnrecruitPlayerAlly(currentRecruits, RecruitLabel); return; } @@ -2507,7 +2893,7 @@ private async Task RecruitPlayerAlly() // Check if already recruited var partyNames = GameEngine.Instance?.DungeonPartyPlayerNames ?? new List(); - if (partyNames.Contains(selected.DisplayName, StringComparer.OrdinalIgnoreCase)) + if (IsRecruited(selected)) { terminal.SetColor("yellow"); terminal.WriteLine(Loc.Get("team.echo_already_in_party", selected.DisplayName)); @@ -2517,8 +2903,9 @@ private async Task RecruitPlayerAlly() return; } - // Add to dungeon party - var names = new List(partyNames) { selected.DisplayName }; + // Add to dungeon party. v1.1.12: by save key; the display name can be another account's username, + // whose save (even the viewer's own) the dungeon would otherwise load + var names = new List(partyNames) { selected.Username }; GameEngine.Instance?.SetDungeonPartyPlayers(names); terminal.WriteLine(""); @@ -2554,6 +2941,16 @@ private async Task RecruitPlayerAlly() await terminal.ReadKeyAsync(); } + /// v1.1.12: whether a teammate is in the echo recruit list. An entry that is a team member's save key + /// matches that member only; an older entry (a display name, no member's key) matches by display name. + internal static bool IsEchoRecruited(IEnumerable recruits, IEnumerable memberKeys, PlayerSummary tm) + { + var keys = new HashSet(memberKeys.Where(k => !string.IsNullOrEmpty(k)), StringComparer.OrdinalIgnoreCase); + return recruits.Any(e => keys.Contains(e) + ? e.Equals(tm.Username, StringComparison.OrdinalIgnoreCase) + : e.Equals(tm.DisplayName, StringComparison.OrdinalIgnoreCase)); + } + /// /// Manual un-recruit flow for player echoes. Lets the player dismiss an /// echo from `DungeonPartyPlayerNames` without entering the dungeon. @@ -2561,7 +2958,7 @@ private async Task RecruitPlayerAlly() /// materialize (save deleted, off-team, load error) -- and also lets /// players who change their mind un-recruit cleanly. /// - private async Task UnrecruitPlayerAlly(List currentRecruits) + private async Task UnrecruitPlayerAlly(List currentRecruits, Func label) { if (currentRecruits.Count == 0) { @@ -2577,7 +2974,7 @@ private async Task UnrecruitPlayerAlly(List currentRecruits) terminal.SetColor("white"); for (int i = 0; i < currentRecruits.Count; i++) { - terminal.WriteLine($" [{i + 1}] {currentRecruits[i]}"); + terminal.WriteLine($" [{i + 1}] {label(currentRecruits[i])}"); } terminal.WriteLine(""); terminal.SetColor("cyan"); @@ -2591,7 +2988,7 @@ private async Task UnrecruitPlayerAlly(List currentRecruits) var cleaned = currentRecruits.Where(n => !n.Equals(toRemove, StringComparison.OrdinalIgnoreCase)).ToList(); GameEngine.Instance?.SetDungeonPartyPlayers(cleaned); terminal.SetColor("bright_yellow"); - terminal.WriteLine(Loc.Get("team.echo_unrecruit_done", toRemove)); + terminal.WriteLine(Loc.Get("team.echo_unrecruit_done", label(toRemove))); await Task.Delay(2000); } } @@ -2630,55 +3027,17 @@ private async Task EquipMember() return; } - terminal.ClearScreen(); - WriteBoxHeader(Loc.Get("team_corner.equip_header"), "bright_cyan"); - terminal.WriteLine(""); - - // List team members - terminal.SetColor("white"); - terminal.WriteLine(Loc.Get("team.team_members_label")); - terminal.WriteLine(""); - - for (int i = 0; i < teamMembers.Count; i++) - { - var member = teamMembers[i]; - terminal.SetColor("bright_yellow"); - terminal.Write($" {i + 1}. "); - terminal.SetColor("white"); - terminal.Write($"{member.DisplayName} "); - terminal.SetColor("gray"); - terminal.WriteLine($"(Lv {member.Level} {member.ClassName})"); - } - - terminal.WriteLine(""); - terminal.SetColor("cyan"); - terminal.Write(Loc.Get("team.select_member_equip")); - terminal.SetColor("white"); - - var input = await terminal.ReadLineAsync(); - if (!int.TryParse(input, out int memberIdx) || memberIdx < 1 || memberIdx > teamMembers.Count) - { - terminal.SetColor("gray"); - terminal.WriteLine(Loc.Get("ui.cancelled")); - await Task.Delay(1000); - return; - } + // v1.1.12: the shared picker (a number, or a name or the start of one) + var selectedMember = await PickFromList(teamMembers, + m => $"{m.DisplayName} ({Loc.Get("inn.npc_level_class", m.Level, GameConfig.GetLocalizedClassName(m.Class))})", + m => m.DisplayName, "team_corner.equip_header"); + if (selectedMember == null) return; - var selectedMember = teamMembers[memberIdx - 1]; await ManageCharacterEquipment(selectedMember); // Sync equipment changes to canonical NPC in ActiveNPCs (handles orphaned references) CombatEngine.SyncNPCTeammateToActiveNPCs(selectedMember); - - // Auto-save after equipment changes to persist NPC equipment state - await SaveSystem.Instance.AutoSave(currentPlayer); - - // Force NPC world_state save so equipment survives world-sim reload cycles - if (DoorMode.IsOnlineMode && OnlineStateManager.Instance != null) - { - try { await OnlineStateManager.Instance.SaveAllSharedState(); } - catch (Exception ex) { DebugLogger.Instance.LogError("TEAM", $"SaveAllSharedState failed after equipment change: {ex.Message}"); } - } + // v1.1.12: each move was saved when it was made, in the order for its direction (BaseLocation gear saves) } /// @@ -2695,7 +3054,7 @@ private async Task ManageCharacterEquipment(Character target) // Show target's stats terminal.SetColor("white"); - terminal.WriteLine(Loc.Get("team.examine_level", target.Level, GameConfig.GetLocalizedClassName(target.Class), target.Race)); + terminal.WriteLine(Loc.Get("team.examine_level", target.Level, GameConfig.GetLocalizedClassName(target.Class), GameConfig.GetLocalizedRaceName(target.Race))); terminal.WriteLine(Loc.Get("team.examine_hp", target.HP, target.MaxHP, target.Mana, target.MaxMana)); terminal.WriteLine(Loc.Get("team.examine_stats1", target.Strength, target.Dexterity, target.Agility, target.Constitution)); terminal.WriteLine(Loc.Get("team.examine_stats2", target.Intelligence, target.Wisdom, target.Charisma, target.Defence)); @@ -2797,7 +3156,7 @@ private async Task EquipItemToCharacter(Character target) { terminal.WriteLine(""); terminal.SetColor("yellow"); - terminal.WriteLine(" No items available for this slot."); + terminal.WriteLine(Loc.Get("team.equip_no_items_for_slot")); await Task.Delay(2000); continue; } @@ -2806,18 +3165,18 @@ private async Task EquipItemToCharacter(Character target) terminal.WriteLine(""); var currentItem = target.GetEquipment(selectedSlot.Value); terminal.SetColor("white"); - terminal.Write($" Current: "); + terminal.Write($" {Loc.Get("weapon_shop.current_prefix")}"); if (currentItem != null) { terminal.SetColor(currentItem.IsIdentified ? currentItem.GetRarityColor() : "magenta"); - terminal.Write(currentItem.IsIdentified ? currentItem.Name : "Unidentified"); + terminal.Write(currentItem.IsIdentified ? currentItem.Name : Loc.Get("ui.unidentified")); if (currentItem.IsIdentified) WriteEquipmentStatSummary(currentItem); terminal.WriteLine(""); } else { terminal.SetColor("darkgray"); - terminal.WriteLine("Empty"); + terminal.WriteLine(Loc.Get("ui.empty")); } terminal.WriteLine(""); @@ -2847,7 +3206,7 @@ private async Task EquipItemToCharacter(Character target) if (!selectedItem.IsIdentified) { terminal.SetColor("yellow"); - terminal.WriteLine(" Must identify the item first."); + terminal.WriteLine(Loc.Get("team.equip_identify_first")); await Task.Delay(2000); continue; } @@ -2864,23 +3223,14 @@ private async Task EquipItemToCharacter(Character target) // Use the slot the player already picked (no need to ask which hand) EquipmentSlot? targetSlot = selectedSlot.Value; - // Remove from player - if (wasEquipped && sourceSlot.HasValue) - { - currentPlayer.UnequipSlot(sourceSlot.Value); - currentPlayer.RecalculateStats(); - } - else + // Remove from player. v1.1.12: if nothing came off the player, nothing is equipped (the item + // was equipped anyway, a copy) + if (!TakeFromPlayerForEquip(selectedItem, wasEquipped, sourceSlot)) { - // Remove from inventory (find by name) - // Two-pass match: first try Name+Attack+ArmorClass for precision, then fallback to name-only - var invItem = currentPlayer.Inventory.FirstOrDefault(i => - i.Name == selectedItem.Name && i.Attack == selectedItem.WeaponPower && i.Armor == selectedItem.ArmorClass) - ?? currentPlayer.Inventory.FirstOrDefault(i => i.Name == selectedItem.Name); - if (invItem != null) - { - currentPlayer.Inventory.Remove(invItem); - } + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.equip_item_gone", selectedItem.Name)); + await Task.Delay(2000); + continue; } // Track items in target's inventory BEFORE equipping, so we can move displaced items to player @@ -2892,7 +3242,9 @@ private async Task EquipItemToCharacter(Character target) if (result) { - // Move any items that were added to target's inventory (displaced equipment) to player's inventory + // v1.1.12: the give is saved first (player, then NPC) with the displaced items still in the target's + // bag; then they come back to the player, saved NPC side first. No crash point copies an item. + await SaveGearGivenToNpc(target); if (target.Inventory.Count > targetInventoryBefore) { var displacedItems = target.Inventory.Skip(targetInventoryBefore).ToList(); @@ -2901,6 +3253,7 @@ private async Task EquipItemToCharacter(Character target) target.Inventory.Remove(displaced); currentPlayer.Inventory.Add(displaced); } + await SaveGearTakenFromNpc(target); } // v0.57.7 (Hesperos report): `target` is a WRAPPER Character built fresh by @@ -2933,6 +3286,25 @@ private async Task EquipItemToCharacter(Character target) } } + /// + /// v1.1.12: takes the item being given from the player: off the slot it is worn in, or out of the pack + /// (matched on name and power first, then on name). False when nothing was removed, and then nothing + /// may be equipped. + /// + internal bool TakeFromPlayerForEquip(Equipment selectedItem, bool wasEquipped, EquipmentSlot? sourceSlot) + { + if (wasEquipped && sourceSlot.HasValue) + { + if (currentPlayer.UnequipSlot(sourceSlot.Value) == null) return false; + currentPlayer.RecalculateStats(); + return true; + } + var invItem = currentPlayer.Inventory.FirstOrDefault(i => + i.Name == selectedItem.Name && i.Attack == selectedItem.WeaponPower && i.Armor == selectedItem.ArmorClass) + ?? currentPlayer.Inventory.FirstOrDefault(i => i.Name == selectedItem.Name); + return invItem != null && currentPlayer.Inventory.Remove(invItem); + } + /// /// Unequip an item from a character and add to player's inventory /// @@ -2978,7 +3350,7 @@ private async Task UnequipItemFromCharacter(Character target) if (item.IsCursed) { terminal.SetColor("red"); - terminal.Write(" (CURSED)"); + terminal.Write(Loc.Get("inn.cursed_label")); } terminal.WriteLine(""); } @@ -3018,6 +3390,7 @@ private async Task UnequipItemFromCharacter(Character target) CompanionSystem.Instance?.SyncCompanionEquipment(target); var legacyItem = ConvertEquipmentToItem(unequipped); currentPlayer.Inventory.Add(legacyItem); + await SaveGearTakenFromNpc(target); // v1.1.12: NPC side first, then the player terminal.WriteLine(""); terminal.SetColor("bright_green"); @@ -3045,8 +3418,8 @@ private async Task TakeAllEquipment(Character target) terminal.Write(Loc.Get("team.take_all_warning")); terminal.SetColor("white"); - var confirm = await terminal.ReadLineAsync(); - if (!GameConfig.IsAffirmative(confirm)) + var answer = await terminal.ReadLineAsync(); + if (!GameConfig.IsAffirmative(answer)) { terminal.SetColor("gray"); terminal.WriteLine(Loc.Get("ui.cancelled")); @@ -3054,8 +3427,18 @@ private async Task TakeAllEquipment(Character target) return; } - int itemsTaken = 0; var cursedItems = new List(); + int itemsTaken = MoveEquipmentToPlayer(target, cursedItems).Count; + if (itemsTaken > 0) await SaveGearTakenFromNpc(target); // v1.1.12: NPC side first, then the player + await ReportEquipmentTaken(target, itemsTaken, cursedItems); + } + + /// v1.1.12: moves everything the character wears but the cursed items into the player's pack, with + /// nothing awaited; returns the slot, item ID and name of each piece moved (Sack saves at once, then strips a + /// reloaded copy of the NPC by them). + internal List<(EquipmentSlot Slot, int Id, string Name)> MoveEquipmentToPlayer(Character target, List cursedItems) + { + var taken = new List<(EquipmentSlot Slot, int Id, string Name)>(); foreach (EquipmentSlot slot in Enum.GetValues(typeof(EquipmentSlot))) { @@ -3069,21 +3452,26 @@ private async Task TakeAllEquipment(Character target) continue; } + int id = target.EquippedItems[slot]; var unequipped = target.UnequipSlot(slot); if (unequipped != null) { var legacyItem = ConvertEquipmentToItem(unequipped); currentPlayer.Inventory.Add(legacyItem); - itemsTaken++; + taken.Add((slot, id, unequipped.Name)); } } } target.RecalculateStats(); // v0.57.7 — sync wrapper take-all back to Companion (Hesperos report) - if (itemsTaken > 0 && target.IsCompanion) + if (taken.Count > 0 && target.IsCompanion) CompanionSystem.Instance?.SyncCompanionEquipment(target); + return taken; + } + private async Task ReportEquipmentTaken(Character target, int itemsTaken, List cursedItems) + { terminal.WriteLine(""); if (itemsTaken > 0) { @@ -3206,7 +3594,7 @@ private async Task ChallengeTeamWar(SqlSaveBackend backend) // Show available teams to challenge var allTeams = await backend.GetPlayerTeams(); - var opponents = allTeams.Where(t => t.TeamName != myTeam && t.MemberCount > 0).ToList(); + var opponents = allTeams.Where(t => !string.Equals(t.TeamName, myTeam, StringComparison.OrdinalIgnoreCase) && t.MemberCount > 0).ToList(); if (opponents.Count == 0) { terminal.SetColor("gray"); @@ -3247,7 +3635,7 @@ private async Task ChallengeTeamWar(SqlSaveBackend backend) var recentHistory = await backend.GetTeamWarHistory(myTeam, limit: 20); var cooldownCutoff = DateTime.UtcNow.AddHours(-GameConfig.TeamWarOpponentCooldownHours); var recentVsThisOpponent = recentHistory.FirstOrDefault(w => - w.StartedAt > cooldownCutoff && + w.StartedAt > cooldownCutoff && w.Status != "abandoned" && // v1.1.12: a war that never ran does not count ((w.ChallengerTeam == myTeam && w.DefenderTeam == enemyTeam.TeamName) || (w.DefenderTeam == myTeam && w.ChallengerTeam == enemyTeam.TeamName))); if (recentVsThisOpponent != null) @@ -3278,8 +3666,6 @@ private async Task ChallengeTeamWar(SqlSaveBackend backend) return; } - currentPlayer.Gold -= wager; - // Load both teams' members for combat var myMembers = await backend.GetPlayerTeamMembers(myTeam); var enemyMembers = await backend.GetPlayerTeamMembers(enemyTeam.TeamName); @@ -3288,15 +3674,31 @@ private async Task ChallengeTeamWar(SqlSaveBackend backend) { terminal.SetColor("red"); terminal.WriteLine(Loc.Get("team.no_members_war")); - currentPlayer.Gold += wager; // refund await Task.Delay(1500); return; } - int warId = await backend.CreateTeamWar(myTeam, enemyTeam.TeamName, wager); + // v1.1.12: the wager is taken and SAVED before the war row exists. A war a lost session leaves + // active is refunded from the row (SqlSaveBackend.ExpireStaleTeamWars), so the payment must be on + // disk first, or the refund would pay a wager that was never taken. + currentPlayer.Gold -= wager; + if (!await ForcePlayerSave()) + { + currentPlayer.Gold += wager; + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.failed_generic")); + await Task.Delay(1500); + return; + } + + int warId = await backend.CreateTeamWar(myTeam, enemyTeam.TeamName, wager, GameEngine.GoldTransferKey(currentPlayer)); if (warId < 0) { currentPlayer.Gold += wager; // refund + await ForcePlayerSave(); + terminal.SetColor("red"); + terminal.WriteLine($" {Loc.Get("team.active_war_exists")}"); + await Task.Delay(2000); return; } @@ -3320,8 +3722,8 @@ private async Task ChallengeTeamWar(SqlSaveBackend backend) var enemyData = await backend.ReadGameData((string.IsNullOrEmpty(enemySummary.Username) ? backend.ResolvePlayerUsername(enemySummary.DisplayName) : enemySummary.Username) ?? enemySummary.DisplayName); if (myData?.Player == null || enemyData?.Player == null) continue; - var myFighter = PlayerCharacterLoader.CreateFromSaveData(myData.Player, mySummary.DisplayName); - var enemyFighter = PlayerCharacterLoader.CreateFromSaveData(enemyData.Player, enemySummary.DisplayName); + var myFighter = PlayerCharacterLoader.CreateFromSaveData(myData.Player, mySummary.DisplayName, story: myData.StorySystems); + var enemyFighter = PlayerCharacterLoader.CreateFromSaveData(enemyData.Player, enemySummary.DisplayName, story: enemyData.StorySystems); // Quick auto-resolved combat (no UI, just determine winner by stats) long myPower = myFighter.Level * 10 + myFighter.Strength + myFighter.WeapPow + myFighter.Dexterity; @@ -3336,7 +3738,7 @@ private async Task ChallengeTeamWar(SqlSaveBackend backend) terminal.SetColor(myWin ? "bright_green" : "bright_red"); terminal.Write(Loc.Get("team.round_label", i + 1)); terminal.SetColor("white"); - terminal.Write($"{mySummary.DisplayName} (Lv{mySummary.Level}) vs {enemySummary.DisplayName} (Lv{enemySummary.Level}) "); + terminal.Write(Loc.Get("team.war_round_line", mySummary.DisplayName, mySummary.Level, enemySummary.DisplayName, enemySummary.Level)); terminal.SetColor(myWin ? "bright_green" : "bright_red"); terminal.WriteLine(myWin ? Loc.Get("team.fighter_wins", mySummary.DisplayName) : Loc.Get("team.fighter_wins", enemySummary.DisplayName)); @@ -3345,9 +3747,44 @@ private async Task ChallengeTeamWar(SqlSaveBackend backend) } terminal.WriteLine(""); + + // v1.1.12: no round could be fought (no save loaded); this counted as a defender win, the wager lost + // on a war that never ran. Now it is abandoned, the wager returned, and no daily war used. + if (myWins + enemyWins == 0) + { + // v1.1.12: refunded here only if this guarded flip landed; otherwise the war is still active + // and the stale cleanup refunds it once (ExpireStaleTeamWars), so it is never paid twice + if (await backend.CompleteTeamWar(warId, "abandoned")) + { + currentPlayer.Gold += wager; + await ForcePlayerSave(); + terminal.SetColor("yellow"); + terminal.WriteLine(Loc.Get("team.war_no_rounds", $"{wager:N0}")); + } + else + { + terminal.SetColor("yellow"); + terminal.WriteLine(Loc.Get("team.war_no_rounds_pending", $"{wager:N0}")); + } + await terminal.PressAnyKey(); + return; + } + bool weWon = myWins > enemyWins; string result = weWon ? "challenger_won" : "defender_won"; - await backend.CompleteTeamWar(warId, result); + // v1.1.12: paid or charged only if this guarded flip landed, as in the no-round path. Otherwise nothing + // changes hands here: the stale cleanup (ExpireStaleTeamWars) closes the war once, refunding the wager + // only if no round was recorded, so a won war can never pay twice. + if (!await backend.CompleteTeamWar(warId, result)) + { + string? status = await backend.GetTeamWarStatus(warId); + bool closed = status != null && status != "active"; + terminal.SetColor("yellow"); + terminal.WriteLine(Loc.Get("team.war_score", myWins, enemyWins)); + terminal.WriteLine(closed ? Loc.Get("team.war_already_closed") : Loc.Get("team.war_result_pending", $"{wager:N0}")); + await terminal.PressAnyKey(); + return; + } // v0.57.17 — increment daily counter regardless of outcome (ran a war = burned a slot) currentPlayer.TeamWarsToday++; @@ -3371,6 +3808,9 @@ private async Task ChallengeTeamWar(SqlSaveBackend backend) terminal.WriteLine(Loc.Get("team.war_lost_gold", $"{wager:N0}")); } + // v1.1.12: the daily count and the spoils are saved now, not at the next throttled autosave + await ForcePlayerSave(); + if (UsurperRemake.Systems.OnlineStateManager.IsActive) { string winner = weWon ? myTeam : enemyTeam.TeamName; @@ -3384,37 +3824,42 @@ private async Task ChallengeTeamWar(SqlSaveBackend backend) private async Task ShowWarHistory(SqlSaveBackend backend) { string myTeam = currentPlayer.Team; - var wars = await backend.GetTeamWarHistory(myTeam); + // v1.1.12: more than the last ten, a page at a time; a war still running is left out, and one that + // was abandoned says so instead of counting as a loss + var wars = (await backend.GetTeamWarHistory(myTeam, limit: 100)).Where(w => w.Status != "active").ToList(); - terminal.ClearScreen(); - terminal.WriteLine(""); - WriteSectionHeader(Loc.Get("team_corner.war_history_header"), "bright_red"); + void Header() + { + terminal.ClearScreen(); + terminal.WriteLine(""); + WriteSectionHeader(Loc.Get("team_corner.war_history_header"), "bright_red"); + } if (wars.Count == 0) { + Header(); terminal.SetColor("gray"); terminal.WriteLine(Loc.Get("team.no_wars_yet")); + await terminal.PressAnyKey(); + return; } - else - { - foreach (var war in wars) - { - bool weChallenger = war.ChallengerTeam == myTeam; - string opponent = weChallenger ? war.DefenderTeam : war.ChallengerTeam; - int ourWins = weChallenger ? war.ChallengerWins : war.DefenderWins; - int theirWins = weChallenger ? war.DefenderWins : war.ChallengerWins; - bool weWon = ourWins > theirWins; - terminal.SetColor(weWon ? "bright_green" : "bright_red"); - terminal.Write($" {(weWon ? Loc.Get("team.war_win") : Loc.Get("team.war_loss"))} "); - terminal.SetColor("white"); - terminal.Write(Loc.Get("team.war_vs", opponent)); - terminal.SetColor("gray"); - terminal.WriteLine(Loc.Get("team.war_record", ourWins, theirWins, $"{war.GoldWagered:N0}")); - } - } + await ShowPaged(wars, Header, (war, _) => + { + bool weChallenger = string.Equals(war.ChallengerTeam, myTeam, StringComparison.OrdinalIgnoreCase); + string opponent = weChallenger ? war.DefenderTeam : war.ChallengerTeam; + int ourWins = weChallenger ? war.ChallengerWins : war.DefenderWins; + int theirWins = weChallenger ? war.DefenderWins : war.ChallengerWins; + bool abandoned = war.Status == "abandoned"; + bool weWon = ourWins > theirWins; - await terminal.PressAnyKey(); + terminal.SetColor(abandoned ? "gray" : weWon ? "bright_green" : "bright_red"); + terminal.Write($" {(abandoned ? Loc.Get("team.war_abandoned") : weWon ? Loc.Get("team.war_win") : Loc.Get("team.war_loss"))} "); + terminal.SetColor("white"); + terminal.Write(Loc.Get("team.war_vs", opponent)); + terminal.SetColor("gray"); + terminal.WriteLine(Loc.Get("team.war_record", ourWins, theirWins, $"{war.GoldWagered:N0}")); + }); } // ═══════════════════════════════════════════════════════════════════════════ @@ -3462,7 +3907,7 @@ private async Task TeamHeadquartersMenu() var upgrades = await backend.GetTeamUpgrades(teamName); long vaultGold = await backend.GetTeamVaultGold(teamName); int vaultLevel = backend.GetTeamUpgradeLevel(teamName, "vault"); - long vaultCapacity = 50000 + (vaultLevel * 50000); + long vaultCapacity = SqlSaveBackend.TeamVaultCapacity(vaultLevel); WriteSectionHeader(Loc.Get("team_corner.facilities"), "bright_yellow"); terminal.WriteLine(""); @@ -3525,7 +3970,7 @@ private async Task UpgradeFacility(SqlSaveBackend backend, string teamName) var def = UpgradeDefinitions[key]; int currentLevel = backend.GetTeamUpgradeLevel(teamName, key); - if (currentLevel >= 10) + if (currentLevel >= GameConfig.MaxTeamFacilityLevel) { terminal.SetColor("yellow"); terminal.WriteLine(Loc.Get("team.max_level_reached")); @@ -3552,6 +3997,11 @@ private async Task UpgradeFacility(SqlSaveBackend backend, string teamName) terminal.Write(Loc.Get("team.pay_personal")); string payChoice = (await terminal.ReadLineAsync())?.Trim().ToUpper() ?? ""; + // v1.1.12: the level rises only if it is still the one priced (and below the cap), so two members + // upgrading at once cannot both pay and both raise it. Paid from the vault, the payment and the + // level change are one transaction. Paid personally, the gold is taken and saved first and given + // back if the upgrade does not land (the bool was ignored, the gold kept). + bool landed; if (payChoice == "V") { if (vaultGold < cost) @@ -3561,8 +4011,7 @@ private async Task UpgradeFacility(SqlSaveBackend backend, string teamName) await Task.Delay(1500); return; } - bool withdrawn = await backend.WithdrawFromTeamVault(teamName, cost); - if (!withdrawn) { terminal.SetColor("red"); terminal.WriteLine(Loc.Get("team.failed_generic")); await Task.Delay(1500); return; } + landed = backend.TryUpgradeTeamFacility(teamName, key, currentLevel, cost, payFromVault: true); } else if (payChoice == "P") { @@ -3574,10 +4023,31 @@ private async Task UpgradeFacility(SqlSaveBackend backend, string teamName) return; } currentPlayer.Gold -= cost; + if (!await ForcePlayerSave()) + { + currentPlayer.Gold += cost; + landed = false; + } + else + { + landed = backend.TryUpgradeTeamFacility(teamName, key, currentLevel, cost, payFromVault: false); + if (!landed) + { + currentPlayer.Gold += cost; + await ForcePlayerSave(); + } + } } else return; - await backend.UpgradeTeamFacility(teamName, key, cost); + if (!landed) + { + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.upgrade_not_landed")); + await Task.Delay(2000); + return; + } + terminal.SetColor("bright_green"); terminal.WriteLine(Loc.Get("team.facility_upgraded", Loc.Get(def.NameKey), currentLevel + 1)); @@ -3590,7 +4060,7 @@ private async Task UpgradeFacility(SqlSaveBackend backend, string teamName) private async Task DepositToVault(SqlSaveBackend backend, string teamName) { int vaultLevel = backend.GetTeamUpgradeLevel(teamName, "vault"); - long vaultCapacity = 50000 + (vaultLevel * 50000); + long vaultCapacity = SqlSaveBackend.TeamVaultCapacity(vaultLevel); long currentVault = await backend.GetTeamVaultGold(teamName); long space = vaultCapacity - currentVault; @@ -3610,8 +4080,20 @@ private async Task DepositToVault(SqlSaveBackend backend, string teamName) amount = Math.Min(amount, Math.Min(space, currentPlayer.Gold)); if (amount <= 0) return; + // v1.1.12: the vault row was credited at once but the gold left the player only in memory, so a + // crash before the next autosave kept both. Now the gold is taken and saved first, then the vault + // is credited (capacity checked in the SQL); if the credit fails the gold comes back. currentPlayer.Gold -= amount; - await backend.DepositToTeamVault(teamName, amount); + bool deposited = await ForcePlayerSave() && await backend.DepositToTeamVault(teamName, amount); + if (!deposited) + { + currentPlayer.Gold += amount; + await ForcePlayerSave(); + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.vault_full")); + await Task.Delay(1500); + return; + } terminal.SetColor("bright_green"); terminal.WriteLine(Loc.Get("team.deposited", $"{amount:N0}")); await Task.Delay(1500); @@ -3634,10 +4116,19 @@ private async Task WithdrawFromVault(SqlSaveBackend backend, string teamName) if (!long.TryParse(input, out long amount) || amount <= 0) return; amount = Math.Min(amount, currentVault); + + // v1.1.12: asked first, the vault is the whole team's + terminal.SetColor("yellow"); + terminal.Write(Loc.Get("team.confirm_withdraw", $"{amount:N0}")); + if (!GameConfig.IsAffirmative(await terminal.ReadLineAsync())) return; + bool success = await backend.WithdrawFromTeamVault(teamName, amount); if (success) { + // v1.1.12: saved at once; the vault was debited at once while the gold waited for the next + // throttled autosave, and a crash between lost it currentPlayer.Gold += amount; + await ForcePlayerSave(); terminal.SetColor("bright_green"); terminal.WriteLine(Loc.Get("team.withdrew", $"{amount:N0}")); } @@ -3710,43 +4201,12 @@ private async Task SpecializeMember() return; } - // List team members with current spec - terminal.ClearScreen(); - terminal.SetColor("bright_cyan"); - terminal.WriteLine(Loc.Get("spec.title")); - terminal.WriteLine(""); - - for (int i = 0; i < teamMembers.Count; i++) - { - var member = teamMembers[i]; - string specLabel = member.Specialization != ClassSpecialization.None - ? $"[{member.Specialization}]" - : Loc.Get("spec.none_label"); - - terminal.SetColor("bright_yellow"); - terminal.Write($" {i + 1}. "); - terminal.SetColor("white"); - terminal.Write($"{member.DisplayName} "); - terminal.SetColor("gray"); - terminal.Write($"(Lv {member.Level} {member.ClassName}) "); - terminal.SetColor("cyan"); - terminal.WriteLine(specLabel); - } - - terminal.WriteLine(""); - terminal.SetColor("yellow"); - string input = await terminal.GetInput(Loc.Get("spec.choose_member")); - if (string.IsNullOrWhiteSpace(input)) return; - - if (!int.TryParse(input, out int memberIndex) || memberIndex < 1 || memberIndex > teamMembers.Count) - { - terminal.SetColor("red"); - terminal.WriteLine(Loc.Get("spec.invalid_choice")); - await Task.Delay(1500); - return; - } - - var selectedNPC = teamMembers[memberIndex - 1]; + // v1.1.12: the shared picker + var selectedNPC = await PickFromList(teamMembers, + m => $"{m.DisplayName} ({Loc.Get("inn.npc_level_class", m.Level, GameConfig.GetLocalizedClassName(m.Class))}) " + + (m.Specialization != ClassSpecialization.None ? SpecTag(m).Trim() : Loc.Get("spec.none_label")), + m => m.DisplayName, "spec.title"); + if (selectedNPC == null) return; await ShowSpecOptions(selectedNPC); } @@ -3771,7 +4231,7 @@ private async Task ShowSpecOptions(NPC npc) // Show current spec string currentSpecName = npc.Specialization != ClassSpecialization.None - ? npc.Specialization.ToString() + ? (UsurperRemake.Data.SpecializationData.GetSpec(npc.Specialization)?.Name ?? npc.Specialization.ToString()) : Loc.Get("spec.unspecialized"); terminal.SetColor("white"); terminal.WriteLine(Loc.Get("spec.current_spec", currentSpecName)); @@ -3795,7 +4255,7 @@ private async Task ShowSpecOptions(NPC npc) terminal.SetColor(isCurrentSpec ? "bright_green" : "white"); terminal.Write($"{spec.Name} "); terminal.SetColor("cyan"); - terminal.Write($"({spec.Role}) "); + terminal.Write($"({SpecRoleName(spec.Role)}) "); if (isCurrentSpec) { terminal.SetColor("bright_green"); @@ -3809,17 +4269,17 @@ private async Task ShowSpecOptions(NPC npc) // Stat growth bonuses var bonuses = new List(); - if (spec.BonusStrength > 0) bonuses.Add($"+{spec.BonusStrength} STR"); - if (spec.BonusConstitution > 0) bonuses.Add($"+{spec.BonusConstitution} CON"); - if (spec.BonusMaxHP > 0) bonuses.Add($"+{spec.BonusMaxHP} HP"); - if (spec.BonusDefence > 0) bonuses.Add($"+{spec.BonusDefence} DEF"); - if (spec.BonusIntelligence > 0) bonuses.Add($"+{spec.BonusIntelligence} INT"); - if (spec.BonusWisdom > 0) bonuses.Add($"+{spec.BonusWisdom} WIS"); - if (spec.BonusCharisma > 0) bonuses.Add($"+{spec.BonusCharisma} CHA"); - if (spec.BonusMaxMana > 0) bonuses.Add($"+{spec.BonusMaxMana} Mana"); - if (spec.BonusDexterity > 0) bonuses.Add($"+{spec.BonusDexterity} DEX"); - if (spec.BonusAgility > 0) bonuses.Add($"+{spec.BonusAgility} AGI"); - if (spec.BonusStamina > 0) bonuses.Add($"+{spec.BonusStamina} STA"); + if (spec.BonusStrength > 0) bonuses.Add($"+{spec.BonusStrength} {Loc.Get("stats.str")}"); + if (spec.BonusConstitution > 0) bonuses.Add($"+{spec.BonusConstitution} {Loc.Get("stats.con")}"); + if (spec.BonusMaxHP > 0) bonuses.Add($"+{spec.BonusMaxHP} {Loc.Get("ui.stat_hp")}"); + if (spec.BonusDefence > 0) bonuses.Add($"+{spec.BonusDefence} {Loc.Get("stats.def")}"); + if (spec.BonusIntelligence > 0) bonuses.Add($"+{spec.BonusIntelligence} {Loc.Get("stats.int")}"); + if (spec.BonusWisdom > 0) bonuses.Add($"+{spec.BonusWisdom} {Loc.Get("stats.wis")}"); + if (spec.BonusCharisma > 0) bonuses.Add($"+{spec.BonusCharisma} {Loc.Get("stats.cha")}"); + if (spec.BonusMaxMana > 0) bonuses.Add($"+{spec.BonusMaxMana} {Loc.Get("ui.stat_mana")}"); + if (spec.BonusDexterity > 0) bonuses.Add($"+{spec.BonusDexterity} {Loc.Get("stats.dex")}"); + if (spec.BonusAgility > 0) bonuses.Add($"+{spec.BonusAgility} {Loc.Get("stats.agi")}"); + if (spec.BonusStamina > 0) bonuses.Add($"+{spec.BonusStamina} {Loc.Get("stats.sta")}"); if (bonuses.Count > 0) { @@ -3866,8 +4326,17 @@ private async Task ShowSpecOptions(NPC npc) return; } - // Apply specialization - var oldSpec = npc.Specialization; + // Apply specialization. v1.1.12: to the live NPC, looked up again by ID; a world_state reload while + // the menu was up replaced the object listed, and the change was lost + var live = LiveTeamNpc(npc); + if (live == null || live.Team != currentPlayer.Team) + { + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("team.member_gone_now", npc.DisplayName)); + await Task.Delay(2000); + return; + } + npc = live; npc.Specialization = newSpec; terminal.WriteLine(""); @@ -3880,7 +4349,7 @@ private async Task ShowSpecOptions(NPC npc) { var specDef = UsurperRemake.Data.SpecializationData.GetSpec(newSpec); terminal.SetColor("bright_green"); - terminal.WriteLine(Loc.Get("spec.set", npc.DisplayName, specDef?.Name ?? newSpec.ToString(), specDef?.Role.ToString() ?? "")); + terminal.WriteLine(Loc.Get("spec.set", npc.DisplayName, specDef?.Name ?? newSpec.ToString(), specDef != null ? SpecRoleName(specDef.Role) : "")); } terminal.SetColor("gray"); diff --git a/Scripts/Systems/AmnesiaSystem.cs b/Scripts/Systems/AmnesiaSystem.cs index 63f5c984..c30da404 100644 --- a/Scripts/Systems/AmnesiaSystem.cs +++ b/Scripts/Systems/AmnesiaSystem.cs @@ -15,6 +15,11 @@ namespace UsurperRemake.Systems /// public class AmnesiaSystem { + // v1.1.12: MemoriesRecovered needs 4. Only the floor 10/25/50/75 memories and the full truth + // can be recovered (no call passes the rest-, god-, death- or seal-triggers), and the full + // truth already grants TrueIdentityRevealed, so the four floor memories are the threshold. + public const int MemoriesRecoveredThreshold = 4; + private static AmnesiaSystem? _fallbackInstance; public static AmnesiaSystem Instance { @@ -332,7 +337,7 @@ public void RecoverMemory(MemoryFragment fragment) // GD.Print($"[Amnesia] Recovered memory: {data.Title}"); // Notify the Ocean Philosophy system - if (RecoveredMemories.Count >= MemoryData.Count - 2) + if (RecoveredMemories.Count >= MemoriesRecoveredThreshold) { OceanPhilosophySystem.Instance.ExperienceMoment(AwakeningMoment.MemoriesRecovered); } diff --git a/Scripts/Systems/AwakeningBonus.cs b/Scripts/Systems/AwakeningBonus.cs new file mode 100644 index 00000000..1124f857 --- /dev/null +++ b/Scripts/Systems/AwakeningBonus.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace UsurperRemake.Systems +{ + /// + /// v1.1.12: the boons of the awakening stages, cumulative, computed from the stage at run time and + /// never stored on the character. + /// Stage 1 +3 Wisdom + /// Stage 2 +5% max mana + /// Stage 3 +5% combat XP + /// Stage 4 +5% max HP + /// Stage 5 +3% damage dealt + /// Stage 6 3% less damage taken + /// Stage 7 +5 more to each percentage above, and the title "the Awakened" + /// Only a player has an awakening, stamped on their Character by their own session (AwakeningStage), or + /// by PlayerCharacterLoader from their own save (a duel defender fights with its own): companions, NPCs + /// and echoes get nothing from it. Damage, defence and XP go through TeamHQBonus.Apply*, so they follow its + /// ordering rule (after every other modifier, before any floor, cap or minimum). + /// + public static class AwakeningBonus + { + public const int Wisdom1 = 3; + public const double Mana2 = 0.05; + public const double XP3 = 0.05; + public const double HP4 = 0.05; + public const double Damage5 = 0.03; + public const double Defense6 = 0.03; + public const double Stage7Extra = 0.05; + + /// The awakening stage that applies to this character: its own stamped stage, else the + /// session player's, else 0. + public static int StageOf(Character? c) + { + if (c == null || c is NPC) return 0; + // v1.1.12: the stamp travels with a grouped follower into the leader's session + if (c.AwakeningStage >= 0) return c.AwakeningStage; + var player = GameEngine.Instance?.CurrentPlayer; + if (!ReferenceEquals(c, player)) return 0; + return OceanPhilosophySystem.Instance?.AwakeningLevel ?? 0; + } + + private static double Pct(int stage, int from, double pct) => + stage < from ? 0.0 : pct + (stage >= OceanPhilosophySystem.MaxStage ? Stage7Extra : 0.0); + + public static int WisdomAt(int stage) => stage >= 1 ? Wisdom1 : 0; + public static double ManaAt(int stage) => Pct(stage, 2, Mana2); + public static double XPAt(int stage) => Pct(stage, 3, XP3); + public static double HPAt(int stage) => Pct(stage, 4, HP4); + public static double DamageAt(int stage) => Pct(stage, 5, Damage5); + public static double DefenseAt(int stage) => Pct(stage, 6, Defense6); + public static bool IsAwakened(int stage) => stage >= OceanPhilosophySystem.MaxStage; + + public static int Wisdom(Character c) => WisdomAt(StageOf(c)); + public static double DamageMultiplier(Character c) => 1.0 + DamageAt(StageOf(c)); + public static double DefenseFactor(Character c) => 1.0 - DefenseAt(StageOf(c)); + public static double XPMultiplier(Character c) => 1.0 + XPAt(StageOf(c)); + + /// + /// v1.1.12: the player was recalculated before the story systems were restored, so without the + /// boons; recalculate with them and give back the saved HP and mana the earlier pass clamped. + /// + public static void RecalculateAfterRestore(Character? player, long savedHP, long savedMana) + { + if (player == null) return; + Stamp(player); + if (StageOf(player) == 0) return; + player.RecalculateStats(); + if (savedHP > player.HP) player.HP = Math.Min(savedHP, player.MaxHP); + if (savedMana > player.Mana) player.Mana = Math.Min(savedMana, player.MaxMana); + } + + /// v1.1.12: give a player the session's stage as their own. + public static void Stamp(Character? player, int? stage = null) + { + if (player == null || player is NPC) return; + player.AwakeningStage = stage ?? OceanPhilosophySystem.Instance?.AwakeningLevel ?? 0; + } + + /// v1.1.12: the stage a player's own saved story data reaches, as their session restores it + /// (OceanPhilosophySystem.RestoreFromSave): the points function over the distinct fragments, moments and + /// insights, at least the saved level. Never reads the current session's Ocean. 0 without the data. + public static int StageFromSave(StorySystemsData? story) + { + if (story == null) return 0; + var moments = new HashSet((story.ExperiencedMoments ?? new()).Select(m => (AwakeningMoment)m)); + int fragments = (story.CollectedFragments ?? new()).Distinct().Count(); + int insights = (story.OceanInsightIds ?? new()).Where(id => !string.IsNullOrEmpty(id)).Distinct().Count(); + int stage = OceanPhilosophySystem.StageFor(moments, fragments, insights); + return Math.Max(stage, Math.Clamp(story.AwakeningLevel, 0, OceanPhilosophySystem.MaxStage)); + } + + private static int P(double pct) => (int)Math.Round(pct * 100); + + /// The boon one stage adds, as a line of text. + public static string GainedAt(int stage) => stage switch + { + 1 => Loc.Get("ocean.boost.1", Wisdom1), + 2 => Loc.Get("ocean.boost.2", P(Mana2)), + 3 => Loc.Get("ocean.boost.3", P(XP3)), + 4 => Loc.Get("ocean.boost.4", P(HP4)), + 5 => Loc.Get("ocean.boost.5", P(Damage5)), + 6 => Loc.Get("ocean.boost.6", P(Defense6)), + 7 => Loc.Get("ocean.boost.7", P(Stage7Extra)), + _ => "" + }; + + /// Every boon active at a stage, one line each, with the stage 7 extra folded in. + public static List ActiveAt(int stage) + { + var lines = new List(); + if (stage >= 1) lines.Add(Loc.Get("ocean.boost.1", WisdomAt(stage))); + if (stage >= 2) lines.Add(Loc.Get("ocean.boost.2", P(ManaAt(stage)))); + if (stage >= 3) lines.Add(Loc.Get("ocean.boost.3", P(XPAt(stage)))); + if (stage >= 4) lines.Add(Loc.Get("ocean.boost.4", P(HPAt(stage)))); + if (stage >= 5) lines.Add(Loc.Get("ocean.boost.5", P(DamageAt(stage)))); + if (stage >= 6) lines.Add(Loc.Get("ocean.boost.6", P(DefenseAt(stage)))); + if (IsAwakened(stage)) lines.Add(Loc.Get("ocean.title_awakened_line")); + return lines; + } + } +} diff --git a/Scripts/Systems/AwakeningScreens.cs b/Scripts/Systems/AwakeningScreens.cs new file mode 100644 index 00000000..82b0614b --- /dev/null +++ b/Scripts/Systems/AwakeningScreens.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using UsurperRemake.UI; + +namespace UsurperRemake.Systems +{ + /// + /// v1.1.12: the AWAKENING screen shown when the stage rises. The rise is queued on the session's + /// OceanPhilosophySystem and shown at the next safe point, the top of a location's loop, so never + /// mid-fight or mid-dialogue; a fight returns to that loop when it ends. + /// + public static class AwakeningScreens + { + /// The stage name, the same labels as the status screen. + public static string StageName(int stage) => stage switch + { + 1 => Loc.Get("base.awakening_stirring"), + 2 => Loc.Get("base.awakening_aware"), + 3 => Loc.Get("base.awakening_seeking"), + 4 => Loc.Get("base.awakening_illuminated"), + 5 => Loc.Get("base.awakening_transcendent"), + 6 => Loc.Get("base.awakening_enlightened"), + 7 => Loc.Get("base.awakening_awakened"), + _ => Loc.Get("base.awakening_dormant") + }; + + public const int LoreLinesPerStage = 4; + + public static string FragmentTitle(WaveFragment f) => Loc.Get($"ocean.fragment.{f}.title"); + public static string FragmentText(WaveFragment f) => Loc.Get($"ocean.fragment.{f}.text"); + public static string MomentLabel(AwakeningMoment m) => Loc.Get($"ocean.moment.{m}"); + + /// + /// v1.1.12: the Ocean Journal section of [P] Progress: the stage and its name, the points toward + /// the next stage, the fragments found, the moments lived and the active boons. + /// + public static void WriteJournalSummary(TerminalEmulator term) + { + var ocean = OceanPhilosophySystem.Instance; + int stage = ocean.AwakeningLevel; + term.SetColor("bright_cyan"); + term.WriteLine($" {Loc.Get("ocean.journal_stage", stage, OceanPhilosophySystem.MaxStage, StageName(stage))}"); + term.SetColor("white"); + if (stage >= OceanPhilosophySystem.MaxStage) + term.WriteLine($" {Loc.Get("ocean.journal_awake")}"); + else if (stage == OceanPhilosophySystem.MaxStage - 1 && ocean.Points >= ocean.PointsForNextStage) + term.WriteLine($" {Loc.Get("ocean.journal_points_last", ocean.Points)}"); + else + term.WriteLine($" {Loc.Get("ocean.journal_points", ocean.Points, ocean.PointsForNextStage)}"); + term.SetColor("gray"); + term.WriteLine($" {Loc.Get("ocean.journal_fragments", ocean.CollectedFragments.Count, OceanPhilosophySystem.FragmentData.Count)}"); + term.WriteLine($" {Loc.Get("ocean.journal_moments", ocean.ExperiencedMoments.Count)}"); + + var boons = AwakeningBonus.ActiveAt(stage); + if (boons.Count > 0) + { + term.SetColor("bright_green"); + term.WriteLine($" {Loc.Get("ocean.journal_boons", string.Join(", ", boons))}"); + } + term.SetColor("yellow"); + term.WriteLine($" {Loc.Get("ocean.journal_open_key")}"); + term.SetColor("white"); + } + + /// v1.1.12: the Journal's pages: each fragment found with its lore, then the moments lived. + public static async Task ShowJournal(TerminalEmulator term) + { + var ocean = OceanPhilosophySystem.Instance; + term.ClearScreen(); + term.WriteLine(""); + UIHelper.WriteBoxHeader(term, Loc.Get("ocean.journal_header"), "bright_cyan", 64); + term.WriteLine(""); + + term.WriteLine($" {Loc.Get("ocean.journal_fragments", ocean.CollectedFragments.Count, OceanPhilosophySystem.FragmentData.Count)}", "bright_cyan"); + term.WriteLine(""); + foreach (var f in ocean.CollectedFragments.OrderBy(f => OceanPhilosophySystem.FragmentData[f].RequiredAwakening).ThenBy(f => (int)f)) + { + term.WriteLine($" [{FragmentTitle(f)}]", "bright_white"); + foreach (var line in Wrap(FragmentText(f), 72)) + term.WriteLine($" {line}", "cyan"); + term.WriteLine(""); + } + int hidden = OceanPhilosophySystem.FragmentData.Count - ocean.CollectedFragments.Count; + if (hidden > 0) + { + term.WriteLine($" {Loc.Get("ocean.journal_hidden", hidden)}", "dark_gray"); + term.WriteLine(""); + } + + term.WriteLine($" {Loc.Get("ocean.journal_moments", ocean.ExperiencedMoments.Count)}", "bright_magenta"); + foreach (var m in ocean.ExperiencedMoments.OrderBy(m => (int)m)) + term.WriteLine($" - {MomentLabel(m)}", "magenta"); + if (ocean.ExperiencedMoments.Count == 0) + term.WriteLine($" {Loc.Get("ocean.journal_no_moments")}", "dark_gray"); + term.WriteLine(""); + + await term.PressAnyKey(); + } + + private static List Wrap(string text, int width) + { + var lines = new List(); + string current = ""; + foreach (var word in text.Split(' ')) + { + if (current.Length > 0 && current.Length + 1 + word.Length > width) + { + lines.Add(current); + current = word; + } + else current = current.Length == 0 ? word : current + " " + word; + } + if (current.Length > 0) lines.Add(current); + return lines; + } + + /// Show the pending announcement, once. Returns false when nothing was pending. + public static async Task ShowPending(TerminalEmulator term, Character? player) + { + var pending = OceanPhilosophySystem.Instance?.TakePendingAnnouncement(); + if (pending == null || term == null) return false; + var (from, to) = pending.Value; + player?.RecalculateStats(); // v1.1.12: the new Wisdom, max HP and max mana, at a safe point + + term.ClearScreen(); + term.WriteLine(""); + UIHelper.WriteBoxHeader(term, Loc.Get("ocean.awakening_header"), "bright_cyan", 64); + term.WriteLine(""); + term.WriteLine($" {Loc.Get("ocean.awakening_stage", to, OceanPhilosophySystem.MaxStage, StageName(to))}", "bright_white"); + term.WriteLine(""); + + for (int i = 1; i <= LoreLinesPerStage; i++) + term.WriteLine($" {Loc.Get($"ocean.stage.{to}.lore.{i}")}", "cyan"); + term.WriteLine(""); + + for (int stage = Math.Max(1, from + 1); stage <= to; stage++) + { + term.WriteLine($" {Loc.Get("ocean.awakening_gained", AwakeningBonus.GainedAt(stage))}", "bright_green"); + term.WriteLine($" {Loc.Get("ocean.awakening_opens", Loc.Get($"ocean.stage.{stage}.opens"))}", "yellow"); + } + term.WriteLine(""); + + await term.PressAnyKey(); + + if (player != null) + HintSystem.Instance.TryShowHint(HintSystem.HINT_AWAKENING, term, player.HintsShown); + return true; + } + } +} diff --git a/Scripts/Systems/BetrayalSystem.cs b/Scripts/Systems/BetrayalSystem.cs index 7cbe558d..2eb4b823 100644 --- a/Scripts/Systems/BetrayalSystem.cs +++ b/Scripts/Systems/BetrayalSystem.cs @@ -338,7 +338,7 @@ public async Task ExecuteBetrayal(string npcId, Character player terminal.WriteLine(""); terminal.WriteLine(Loc.Get("betrayal.ocean_understanding"), "dark_cyan"); terminal.WriteLine(Loc.Get("betrayal.ocean_wave_crashes"), "cyan"); - OceanPhilosophySystem.Instance.GainInsight(5); + OceanPhilosophySystem.Instance.GainInsight("betrayal:" + npcId); // v1.1.12 } return new BetrayalResult @@ -640,7 +640,6 @@ public async Task TakeRevenge(string npcId, Character player, TerminalEmulator t // Ocean philosophy - revenge is grasping terminal.WriteLine(""); terminal.WriteLine(Loc.Get("betrayal.violence_begets"), "dark_cyan"); - OceanPhilosophySystem.Instance.GainInsight(-5); // Negative insight terminal.WriteLine(""); await terminal.GetInputAsync($" {Loc.Get("ui.press_enter")}"); diff --git a/Scripts/Systems/CombatEngine.cs b/Scripts/Systems/CombatEngine.cs index 263deeea..89a95ed6 100644 --- a/Scripts/Systems/CombatEngine.cs +++ b/Scripts/Systems/CombatEngine.cs @@ -6782,6 +6782,7 @@ private async Task TryManweBossAbility(Monster monster, Character player, terminal.WriteLine($" {Loc.Get("combat.manwe_smiles")}", "bright_yellow"); terminal.WriteLine($" {Loc.Get("combat.manwe_thank_you")}", "bright_white"); if (BossContext != null) BossContext.BossSaved = true; + OceanPhilosophySystem.Instance.ExperienceMoment(AwakeningMoment.LetGoOfPower); // v1.1.12: the Creator spared, not taken monster.HP = 0; // End combat peacefully result.CombatLog.Add("Player accepts The Offer — Manwe spared"); } @@ -7093,6 +7094,9 @@ private async Task HandleVictory(CombatResult result) // Apply world event modifiers long expReward = WorldEventSystem.Instance.GetAdjustedXP(baseExpReward); long goldReward = WorldEventSystem.Instance.GetAdjustedGold(baseGoldReward); + // v1.1.12: the event's share alone; later multipliers are not the event's. + long worldEventXP = WorldEventSystem.Instance.GetWorldEventXPBonus(baseExpReward); + long worldEventGold = WorldEventSystem.Instance.GetWorldEventGoldBonus(baseGoldReward); // Blood Moon multipliers (v0.52.0) if (result.Player.IsBloodMoon) @@ -7314,14 +7318,7 @@ private async Task HandleVictory(CombatResult result) terminal.WriteLine(Loc.Get("combat.gold_gained", goldReward)); // Show bonus from world events if any - if (expReward > baseExpReward + spouseBonus || goldReward > baseGoldReward) - { - terminal.SetColor("bright_cyan"); - if (expReward > baseExpReward + spouseBonus) - terminal.WriteLine($" {Loc.Get("combat.world_event_xp", (expReward - baseExpReward - spouseBonus).ToString())}"); - if (goldReward > baseGoldReward) - terminal.WriteLine($" {Loc.Get("combat.world_event_gold", (goldReward - baseGoldReward).ToString())}"); - } + ShowWorldEventBonus(terminal, worldEventXP, worldEventGold); // Show spouse bonus if applicable if (spouseBonus > 0) @@ -9842,7 +9839,7 @@ private async Task RenderEquipment(Item lootItem, Monster monster, Character cha lootBroadcastSb.AppendLine("\u001b[31m WARNING: CURSED!\u001b[0m"); } - await ShowEquipmentComparison(lootItem, character, lootBroadcastSb); + ShowEquipmentComparison(terminal, lootItem, character, lootBroadcastSb); } else { @@ -11162,10 +11159,35 @@ private int CalculateItemUpgradeValue(Item lootItem, Character character) return equipment; } + /// + /// v1.1.12: non-casters always see ST; a caster sees it only with a stamina ability on the quickbar. + /// + internal static bool ShowsStaminaBar(Character player) + { + if (!ClassAbilitySystem.IsSpellcaster(player.Class)) return true; + if (player.CurrentCombatStamina < player.MaxCombatStamina) return true; // v1.1.12: spent on Power Attack or the like + return player.Quickbar?.Any(id => !string.IsNullOrEmpty(id) && !id.StartsWith("spell:") + && (ClassAbilitySystem.GetAbility(id)?.StaminaCost ?? 0) > 0) ?? false; + } + + /// + /// v1.1.12: prints the world event's own share of a reward, only when it is positive. + /// + internal static void ShowWorldEventBonus(TerminalEmulator terminal, long worldEventXP, long worldEventGold) + { + if (worldEventXP <= 0 && worldEventGold <= 0) return; + terminal.SetColor("bright_cyan"); + if (worldEventXP > 0) + terminal.WriteLine($" {Loc.Get("combat.world_event_xp", worldEventXP.ToString())}"); + if (worldEventGold > 0) + terminal.WriteLine($" {Loc.Get("combat.world_event_gold", worldEventGold.ToString())}"); + } + /// /// Show comparison between dropped item and currently equipped item /// - private async Task ShowEquipmentComparison(Item lootItem, Character character, System.Text.StringBuilder lootBroadcastSb) + // v1.1.12: static and shared, so dungeon finds and the dungeon merchant show the same comparison. + internal static void ShowEquipmentComparison(TerminalEmulator terminal, Item lootItem, Character character, System.Text.StringBuilder? lootBroadcastSb = null) { terminal.WriteLine(""); terminal.SetColor("gray"); @@ -11213,7 +11235,7 @@ private async Task ShowEquipmentComparison(Item lootItem, Character character, S terminal.WriteLine(Loc.Get("combat.loot_spell_req_warning", GameConfig.GetLocalizedClassName(character.Class), spellReq)); terminal.WriteLine(Loc.Get("combat.loot_spell_req_block", inferredType)); terminal.WriteLine(""); - lootBroadcastSb.AppendLine($"\u001b[31m NOTE: Requires {spellReq} for spells\u001b[0m"); + lootBroadcastSb?.AppendLine($"\u001b[31m NOTE: Requires {spellReq} for spells\u001b[0m"); } // Check class ability requirements (Ranger→Bow, Assassin→Dagger) @@ -11457,8 +11479,6 @@ private async Task ShowEquipmentComparison(Item lootItem, Character character, S terminal.SetColor("gray"); if (!GameConfig.ScreenReaderMode) terminal.WriteLine(" ─────────────────────────────────────"); - - await Task.CompletedTask; // Keep async signature for consistency } /// @@ -11984,12 +12004,17 @@ private void DisplayCombatStatusBBS(List monsters, Character player, in terminal.WriteLine(""); // Player line 2: ST + ATK + DEF + status effects + // v1.1.12: ST only where the quickbar can spend it (casters' bars hold spells). + if (ShowsStaminaBar(player)) + { + terminal.SetColor("gray"); + terminal.Write($" {Loc.Get("combat.bar_st")}:"); + terminal.SetColor("yellow"); + terminal.Write($"{player.CurrentCombatStamina}/{player.MaxCombatStamina}"); + terminal.Write(" "); + } terminal.SetColor("gray"); - terminal.Write($" {Loc.Get("combat.bar_st")}:"); - terminal.SetColor("yellow"); - terminal.Write($"{player.CurrentCombatStamina}/{player.MaxCombatStamina}"); - terminal.SetColor("gray"); - terminal.Write($" {Loc.Get("combat.bar_atk")}:"); + terminal.Write($" {Loc.Get("combat.bar_atk")}:"); terminal.SetColor("bright_yellow"); terminal.Write($"{player.Strength + player.WeapPow}"); terminal.SetColor("gray"); @@ -20540,6 +20565,9 @@ private async Task HandleVictoryMultiMonster(CombatResult result, bool offerMonk // Apply world event modifiers long adjustedExp = WorldEventSystem.Instance.GetAdjustedXP(totalExp); long adjustedGold = WorldEventSystem.Instance.GetAdjustedGold(totalGold); + // v1.1.12: the event's share alone; later multipliers are not the event's. + long worldEventXP = WorldEventSystem.Instance.GetWorldEventXPBonus(totalExp); + long worldEventGold = WorldEventSystem.Instance.GetWorldEventGoldBonus(totalGold); // Blood Moon multipliers (v0.52.0) if (result.Player.IsBloodMoon) @@ -20781,14 +20809,7 @@ private async Task HandleVictoryMultiMonster(CombatResult result, bool offerMonk PrintPartyFightSummary(result); // v1.1.3 (council ruling 5) // Show bonus from world events if any - if (adjustedExp > totalExp || adjustedGold > totalGold) - { - terminal.SetColor("bright_cyan"); - if (adjustedExp > totalExp) - terminal.WriteLine($" {Loc.Get("combat.world_event_xp", (adjustedExp - totalExp).ToString())}"); - if (adjustedGold > totalGold) - terminal.WriteLine($" {Loc.Get("combat.world_event_gold", (adjustedGold - totalGold).ToString())}"); - } + ShowWorldEventBonus(terminal, worldEventXP, worldEventGold); // First kill bonus for brand new players if (isFirstKillMulti) @@ -21174,6 +21195,9 @@ private async Task HandlePartialVictory(CombatResult result, bool offerMonkEncou // Apply world event modifiers long adjustedExp = WorldEventSystem.Instance.GetAdjustedXP(totalExp); long adjustedGold = WorldEventSystem.Instance.GetAdjustedGold(totalGold); + // v1.1.12: the event's share alone; later multipliers are not the event's. + long worldEventXP = WorldEventSystem.Instance.GetWorldEventXPBonus(totalExp); + long worldEventGold = WorldEventSystem.Instance.GetWorldEventGoldBonus(totalGold); // Blood Moon multipliers (v0.52.0) if (result.Player.IsBloodMoon) @@ -21312,14 +21336,7 @@ private async Task HandlePartialVictory(CombatResult result, bool offerMonkEncou terminal.WriteLine(Loc.Get("combat.gold_gained", $"{adjustedGold:N0}")); // Show bonus from world events if any - if (adjustedExp > totalExp || adjustedGold > totalGold) - { - terminal.SetColor("bright_cyan"); - if (adjustedExp > totalExp) - terminal.WriteLine($" {Loc.Get("combat.world_event_xp", (adjustedExp - totalExp).ToString())}"); - if (adjustedGold > totalGold) - terminal.WriteLine($" {Loc.Get("combat.world_event_gold", (adjustedGold - totalGold).ToString())}"); - } + ShowWorldEventBonus(terminal, worldEventXP, worldEventGold); terminal.WriteLine(""); await Task.Delay(GetCombatDelay(2000)); @@ -26101,6 +26118,7 @@ private async Task OfferNPCSurrenderAsync(Character attacker, Character de if (spared) { result.Outcome = CombatOutcome.OpponentSpared; + OceanPhilosophySystem.Instance.ExperienceMoment(AwakeningMoment.SparedAnEnemy); // v1.1.12 // v0.64.1 audit fix: scrub combat statuses + persistent poison so // a leaked DoT can't kill the spared NPC out-of-combat one tick diff --git a/Scripts/Systems/DialogueSystem.cs b/Scripts/Systems/DialogueSystem.cs index a303fb94..7502341b 100644 --- a/Scripts/Systems/DialogueSystem.cs +++ b/Scripts/Systems/DialogueSystem.cs @@ -45,7 +45,7 @@ public async Task StartDialogue(Character player, string treeId, return new DialogueResult { Completed = false, EndNode = null }; } - // GD.Print($"[Dialogue] Starting dialogue tree: {treeId}"); + RecordDialogueMoments(treeId); // v1.1.12 currentNode = tree.RootNode; var result = await ProcessDialogueTree(tree); @@ -56,6 +56,13 @@ public async Task StartDialogue(Character player, string treeId, return result; } + /// v1.1.12: meeting the Creator is an awakening moment. + internal static void RecordDialogueMoments(string treeId) + { + if (treeId == "manwe_encounter") + OceanPhilosophySystem.Instance.ExperienceMoment(AwakeningMoment.MetManwe); + } + /// /// Process the dialogue tree until completion /// @@ -424,7 +431,7 @@ private bool EvaluateCondition(DialogueCondition? condition) return false; case ConditionType.HasOceanInsight: - return OceanPhilosophySystem.Instance.Insights.Count >= condition.IntValue; + return OceanPhilosophySystem.Instance.InsightIds.Count >= condition.IntValue; // v1.1.12: distinct insights case ConditionType.ExperiencedMoment: if (Enum.TryParse(condition.StringValue, out var moment)) @@ -469,7 +476,7 @@ private void ApplyNodeEffects(DialogueNode node) foreach (var effect in node.Effects) { - ApplyEffect(effect); + ApplyEffect(effect, node.Id); } } @@ -482,14 +489,14 @@ private void ApplyChoiceEffects(DialogueChoice choice) foreach (var effect in choice.Effects) { - ApplyEffect(effect); + ApplyEffect(effect, currentNode?.Id ?? ""); } } /// /// Apply a single dialogue effect /// - private void ApplyEffect(DialogueEffect effect) + private void ApplyEffect(DialogueEffect effect, string sourceNodeId) { if (currentPlayer == null) return; var story = StoryProgressionSystem.Instance; @@ -618,7 +625,7 @@ private void ApplyEffect(DialogueEffect effect) // Ocean Philosophy effects case EffectType.GainOceanInsight: - OceanPhilosophySystem.Instance.GainInsight(effect.IntValue); + OceanPhilosophySystem.Instance.GainInsight("dialogue:" + sourceNodeId); // v1.1.12: one insight per node terminal?.WriteLine("(A deeper understanding settles within you)", "bright_cyan"); break; @@ -2757,7 +2764,7 @@ private void RegisterManweDialogue() { new() { Type = EffectType.SetStoryFlag, StringValue = "manwe_ally" }, new() { Type = EffectType.GainOceanInsight, IntValue = 30 }, - new() { Type = EffectType.CollectWaveFragment, StringValue = "CreatorsRest" } + new() { Type = EffectType.CollectWaveFragment, StringValue = "TheChoice" } // v1.1.12: was "CreatorsRest", not a fragment } }; tree.AllNodes[alliance.Id] = alliance; diff --git a/Scripts/Systems/DiscoverySystem.cs b/Scripts/Systems/DiscoverySystem.cs index b0508561..366c3820 100644 --- a/Scripts/Systems/DiscoverySystem.cs +++ b/Scripts/Systems/DiscoverySystem.cs @@ -80,7 +80,7 @@ public async Task RunDiscovery(DiscoveryDefinition def, Characte switch (o.Kind) { case DiscoveryKind.Narrative: - await ApplyEffects(o.Effects, player, floor, terminal, outcome, teammates); + await ApplyEffects(def.Id, o.Effects, player, floor, terminal, outcome, teammates); break; case DiscoveryKind.Choice: await RunChoice(def, player, floor, terminal, outcome, teammates); @@ -130,7 +130,7 @@ private async Task RunChoice(DiscoveryDefinition def, Character player, int floo foreach (var line in ResolveArray(def.Id, $"choice{idx}.result", chosen.ResultLines)) terminal.WriteLine(" " + line); if (!chosen.IsWalkAway) - await ApplyEffects(chosen.Effects, player, floor, terminal, outcome, teammates); + await ApplyEffects(def.Id, chosen.Effects, player, floor, terminal, outcome, teammates); } private async Task RunSkillTest(DiscoveryDefinition def, Character player, int floor, @@ -165,14 +165,14 @@ private async Task RunSkillTest(DiscoveryDefinition def, Character player, int f terminal.SetColor("bright_green"); foreach (var line in ResolveArray(def.Id, "test.success", o.SuccessLines)) terminal.WriteLine(" " + line); - await ApplyEffects(o.SuccessEffects, player, floor, terminal, outcome, teammates); + await ApplyEffects(def.Id, o.SuccessEffects, player, floor, terminal, outcome, teammates); } else { terminal.SetColor("red"); foreach (var line in ResolveArray(def.Id, "test.fail", o.FailLines)) terminal.WriteLine(" " + line); - await ApplyEffects(o.FailEffects, player, floor, terminal, outcome, teammates); + await ApplyEffects(def.Id, o.FailEffects, player, floor, terminal, outcome, teammates); outcome.Success = false; } } @@ -204,14 +204,14 @@ private async Task RunRisk(DiscoveryDefinition def, Character player, int floor, terminal.SetColor("bright_green"); foreach (var line in ResolveArray(def.Id, "risk.success", o.SuccessLines)) terminal.WriteLine(" " + line); - await ApplyEffects(o.SuccessEffects, player, floor, terminal, outcome, teammates); + await ApplyEffects(def.Id, o.SuccessEffects, player, floor, terminal, outcome, teammates); } else { terminal.SetColor("red"); foreach (var line in ResolveArray(def.Id, "risk.fail", o.FailLines)) terminal.WriteLine(" " + line); - await ApplyEffects(o.FailEffects, player, floor, terminal, outcome, teammates); + await ApplyEffects(def.Id, o.FailEffects, player, floor, terminal, outcome, teammates); outcome.Success = false; } } @@ -221,25 +221,25 @@ private async Task RunTrap(DiscoveryDefinition def, Character player, int floor, { terminal.SetColor("red"); // Intro already shown; apply the trap's effects. - await ApplyEffects(def.Root.Effects, player, floor, terminal, outcome, teammates); + await ApplyEffects(def.Id, def.Root.Effects, player, floor, terminal, outcome, teammates); outcome.Success = false; } // ---------- effect application ---------- - private async Task ApplyEffects(List effects, Character player, int floor, + private async Task ApplyEffects(string discoveryId, List effects, Character player, int floor, TerminalEmulator terminal, FeatureOutcome outcome, List teammates) { if (effects == null) return; foreach (var e in effects) { - try { ApplyEffect(e, player, floor, terminal, outcome, teammates); } + try { ApplyEffect(discoveryId, e, player, floor, terminal, outcome, teammates); } catch (Exception ex) { DebugLogger.Instance?.LogError("DISCOVERY", $"effect {e.Type} failed: {ex.Message}"); } await Task.Delay(250); } } - private void ApplyEffect(DiscEffect e, Character player, int floor, + private void ApplyEffect(string discoveryId, DiscEffect e, Character player, int floor, TerminalEmulator terminal, FeatureOutcome outcome, List teammates) { switch (e.Type) @@ -357,12 +357,12 @@ private void ApplyEffect(DiscEffect e, Character player, int floor, Msg(terminal, "bright_cyan", Loc.Get("discovery.effect.lore")); break; case DiscEffectType.Ocean: - OceanPhilosophySystem.Instance.GainInsight(e.A); + OceanPhilosophySystem.Instance.GainInsight("discovery:" + discoveryId); // v1.1.12: one insight per discovery outcome.OceanInsightGained = true; Msg(terminal, "bright_blue", Loc.Get("discovery.effect.ocean")); break; case DiscEffectType.Awakening: - OceanPhilosophySystem.Instance.GainInsight(Math.Max(5, e.A * 5)); + OceanPhilosophySystem.Instance.GainInsight("discovery:" + discoveryId + ":awaken"); // v1.1.12: the deeper one counts apart outcome.OceanInsightGained = true; Msg(terminal, "bright_magenta", Loc.Get("discovery.effect.awakening")); break; @@ -389,7 +389,10 @@ private void GrantLoot(Character player, int floor, TerminalEmulator terminal, F if (item != null) { player.Inventory.Add(item); - Msg(terminal, "bright_green", Loc.Get("discovery.effect.loot", item.Name)); + // v1.1.12: an unidentified find keeps its disguise, as combat loot does. + string shownName = item.IsIdentified ? item.Name : LootGenerator.GetUnidentifiedName(item); + Msg(terminal, "bright_green", Loc.Get("discovery.effect.loot", shownName)); + ShowFoundGear(terminal, player, item); return; } } @@ -401,6 +404,32 @@ private void GrantLoot(Character player, int floor, TerminalEmulator terminal, F Msg(terminal, "bright_yellow", Loc.Get("discovery.effect.loot_gold", gold)); } + /// + /// v1.1.12: a found weapon or armour gets the combat loot comparison and curse warning. + /// Unidentified items show nothing, as in combat. + /// + internal static void ShowFoundGear(TerminalEmulator terminal, Character player, Item item) + { + if (!item.IsIdentified) return; + switch (item.Type) + { + case ObjType.Weapon: case ObjType.Shield: case ObjType.Body: case ObjType.Head: + case ObjType.Arms: case ObjType.Hands: case ObjType.Legs: case ObjType.Feet: + case ObjType.Waist: case ObjType.Neck: case ObjType.Face: case ObjType.Fingers: + case ObjType.Abody: + break; + default: + return; + } + if (item.IsCursed) + { + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("combat.loot_cursed_warning")); + terminal.WriteLine(Loc.Get("combat.loot_cursed_hint")); + } + CombatEngine.ShowEquipmentComparison(terminal, item, player); + } + // v0.62.0 fix: discovery combat boons must SURVIVE into the next fight. TempAttackBonus / // TempDefenseBonus are reset to 0 at combat start (CombatEngine combat-init), so a boon // applied while examining a feature was wiped before it could matter. Route it through the diff --git a/Scripts/Systems/DreamSystem.cs b/Scripts/Systems/DreamSystem.cs index dbede530..90251d70 100644 --- a/Scripts/Systems/DreamSystem.cs +++ b/Scripts/Systems/DreamSystem.cs @@ -1040,7 +1040,7 @@ public void ExperienceDream(string dreamId) // Apply awakening gain if (dream.AwakeningGain > 0) { - OceanPhilosophySystem.Instance?.GainInsight(dream.AwakeningGain * 10); + OceanPhilosophySystem.Instance?.GainInsight("dream:" + dream.Id); // v1.1.12: one insight per dream } // Grant wave fragment diff --git a/Scripts/Systems/DungeonGenerator.cs b/Scripts/Systems/DungeonGenerator.cs index 0dfa9476..6f2f6fe1 100644 --- a/Scripts/Systems/DungeonGenerator.cs +++ b/Scripts/Systems/DungeonGenerator.cs @@ -1320,6 +1320,7 @@ public class DungeonFloorState public bool IsPermanentlyClear { get; set; } = false; // Boss/seal floors public bool BossDefeated { get; set; } = false; // True if boss room boss was actually defeated public bool CompletionBonusAwarded { get; set; } = false; // Completion XP/gold bonus already paid out + public bool RestedOnThisFloor { get; set; } = false; // v1.1.12: the one rest per floor is spent public string CurrentRoomId { get; set; } = ""; // Room-level state diff --git a/Scripts/Systems/FeatureInteractionSystem.cs b/Scripts/Systems/FeatureInteractionSystem.cs index 1b25359e..3c25022f 100644 --- a/Scripts/Systems/FeatureInteractionSystem.cs +++ b/Scripts/Systems/FeatureInteractionSystem.cs @@ -1426,7 +1426,7 @@ private async Task HandleOceanInsight(RoomFeature feature, Character player, int // Trigger ocean philosophy awakening with insight points try { - OceanPhilosophySystem.Instance?.GainInsight(5); // 5 insight points from feature discovery + OceanPhilosophySystem.Instance?.GainInsight("feature:" + insight.InsightType); // v1.1.12: one insight per kind } catch { /* System may not be initialized */ } diff --git a/Scripts/Systems/GriefSystem.cs b/Scripts/Systems/GriefSystem.cs index 88d3b172..a28237a7 100644 --- a/Scripts/Systems/GriefSystem.cs +++ b/Scripts/Systems/GriefSystem.cs @@ -595,6 +595,7 @@ private void AdvanceGriefStage(GriefState grief, int currentDay) // Grant wisdom // GD.Print($"[Grief] Grief complete for {grief.CompanionName}. Player gains +5 Wisdom."); + OceanPhilosophySystem.Instance.ExperienceMoment(AwakeningMoment.AcceptedGrief); // v1.1.12 OnGriefComplete?.Invoke(grief.CompanionId); } @@ -629,6 +630,7 @@ private void AdvanceNpcGriefStage(GriefState grief, string npcId, int currentDay // Grant wisdom // GD.Print($"[Grief] NPC grief complete for {grief.CompanionName}. Player gains +5 Wisdom."); + OceanPhilosophySystem.Instance.ExperienceMoment(AwakeningMoment.AcceptedGrief); // v1.1.12 OnNpcGriefComplete?.Invoke(npcId); } diff --git a/Scripts/Systems/HintSystem.cs b/Scripts/Systems/HintSystem.cs index ab26b18e..8c2ce87b 100644 --- a/Scripts/Systems/HintSystem.cs +++ b/Scripts/Systems/HintSystem.cs @@ -34,6 +34,8 @@ public class HintSystem public const string HINT_COMPANION_VEX_TEASER = "companion_vex_teaser"; public const string HINT_COMPANION_LYRIS_TEASER = "companion_lyris_teaser"; public const string HINT_COMPANION_MIRA_TEASER = "companion_mira_teaser"; + public const string HINT_LIVES = "lives"; // v1.1.12 + public const string HINT_AWAKENING = "awakening"; // v1.1.12 // Hint definitions. Title and message text are resolved at display time // from loc keys derived from the hint ID (`hint..title` / @@ -56,7 +58,9 @@ public class HintSystem [HINT_MANA_SPELLS] = new HintDefinition("bright_cyan"), [HINT_QUEST_SYSTEM] = new HintDefinition("bright_green"), [HINT_GETTING_STARTED] = new HintDefinition("bright_cyan"), - [HINT_FIRST_COMBAT_CLASS] = new HintDefinition("bright_green") + [HINT_FIRST_COMBAT_CLASS] = new HintDefinition("bright_green"), + [HINT_LIVES] = new HintDefinition("bright_yellow"), + [HINT_AWAKENING] = new HintDefinition("bright_cyan") }; /// @@ -86,7 +90,7 @@ public static string GetClassCombatTip(CharacterClass playerClass) /// Try to show a hint if the player hasn't seen it before. /// Returns true if hint was shown, false if already seen. /// - public bool TryShowHint(string hintId, TerminalEmulator terminal, HashSet? shownHints) + public bool TryShowHint(string hintId, TerminalEmulator terminal, HashSet? shownHints, params object[] msgArgs) { if (shownHints == null) return false; @@ -101,10 +105,22 @@ public bool TryShowHint(string hintId, TerminalEmulator terminal, HashSet + /// v1.1.12: the lives hint, once per character. Lives exist only in online permadeath mode + /// (PermadeathHelper, Character.RaiseLevel and the Rite of Return share this gate). + /// + public bool TryShowLivesHint(Character player, TerminalEmulator terminal) + { + if (!UsurperRemake.BBS.DoorMode.IsOnlineMode || !GameConfig.OnlinePermadeathEnabled) + return false; + return TryShowHint(HINT_LIVES, terminal, player.HintsShown, + Math.Max(0, player.Resurrections), Math.Max(1, player.MaxResurrections)); + } + /// /// Check if a hint has been shown to the player /// @@ -116,11 +132,13 @@ public bool HasSeenHint(string hintId, HashSet? shownHints) /// /// Display a hint in a nice box format /// - private void ShowHintBox(string hintId, HintDefinition hint, TerminalEmulator terminal) + private void ShowHintBox(string hintId, HintDefinition hint, TerminalEmulator terminal, object[]? msgArgs = null) { // Resolve localized title/message from keys derived from the hint ID. string title = Loc.Get($"hint.{hintId}.title"); - string message = Loc.Get($"hint.{hintId}.msg"); + string message = msgArgs != null && msgArgs.Length > 0 + ? Loc.Get($"hint.{hintId}.msg", msgArgs) + : Loc.Get($"hint.{hintId}.msg"); string tipLabel = Loc.Get("hint.tip_label"); terminal.WriteLine(""); diff --git a/Scripts/Systems/IOnlineSaveBackend.cs b/Scripts/Systems/IOnlineSaveBackend.cs index d0289875..07e2c230 100644 --- a/Scripts/Systems/IOnlineSaveBackend.cs +++ b/Scripts/Systems/IOnlineSaveBackend.cs @@ -225,6 +225,12 @@ public class PlayerTeamInfo public int MemberCount { get; set; } public bool ControlsTurf { get; set; } public DateTime CreatedAt { get; set; } + /// v1.1.12: sum of the player members' levels, from the same pass as MemberCount. + public long LevelSum { get; set; } + /// v1.1.12: sum of level + strength + defence over the player members (the rankings' power). + public long PowerSum { get; set; } + /// v1.1.12: false for a team named only in player saves (an NPC team players joined). + public bool HasTeamRow { get; set; } = true; } /// diff --git a/Scripts/Systems/InventorySystem.cs b/Scripts/Systems/InventorySystem.cs index a123790d..146fb2b4 100644 --- a/Scripts/Systems/InventorySystem.cs +++ b/Scripts/Systems/InventorySystem.cs @@ -568,6 +568,12 @@ private void DisplayBackpack(EquipmentSlot? slot = null) }; terminal.SetColor(itemColor); terminal.Write(item.Name); + if (item.IsCursed) + { + // v1.1.12: cursed items are tagged in the backpack list. + terminal.SetColor("red"); + terminal.Write(Loc.Get("shop.cursed_tag")); + } terminal.SetColor("gray"); terminal.Write($" - {item.Value:N0}g"); @@ -913,7 +919,14 @@ private async Task ManageBackpackItem(int index, EquipmentSlot? slot = null) if (item.IsIdentified) { terminal.SetColor("yellow"); - terminal.WriteLine($" {item.Name}"); + terminal.Write($" {item.Name}"); + if (item.IsCursed) + { + // v1.1.12: and in the item view. + terminal.SetColor("red"); + terminal.Write(Loc.Get("shop.cursed_tag")); + } + terminal.WriteLine(""); terminal.SetColor("gray"); terminal.WriteLine($" {Loc.Get("inventory.value")}: {item.Value:N0} {Loc.Get("ui.gold_word")}"); terminal.WriteLine($" {Loc.Get("inventory.type")}: {item.Type}"); @@ -1130,6 +1143,13 @@ private async Task EquipFromBackpack(int itemIndex, EquipmentSlot? slot = null) } } + // v1.1.12: a cursed item cannot be unequipped (UnequipSlot refuses it); say so first. + if (item.IsCursed) + { + terminal.SetColor("red"); + terminal.WriteLine(Loc.Get("inventory.cursed_equip_warning")); + } + // Show comparison with currently equipped item EquipmentSlot compareSlot = finalSlot ?? targetSlot; var currentEquip = player.GetEquipment(compareSlot); @@ -1250,6 +1270,20 @@ private async Task EquipFromBackpack(int itemIndex, EquipmentSlot? slot = null) return; } } + else if (item.IsCursed) + { + // v1.1.12: a cursed item is confirmed into an empty slot too; the prompt above only runs when + // something is already equipped there + terminal.SetColor("white"); + terminal.Write(Loc.Get("inventory.equip_confirm")); + if (!GameConfig.IsAffirmative(await terminal.GetInput(""))) + { + terminal.SetColor("gray"); + terminal.WriteLine(Loc.Get("ui.cancelled")); + await Task.Delay(1000); + return; + } + } // Equip the new item (EquipItem handles old equipment management) if (player.EquipItem(equipment, finalSlot, out string message)) diff --git a/Scripts/Systems/MoralParadoxSystem.cs b/Scripts/Systems/MoralParadoxSystem.cs index 8da3d431..f6f8c88c 100644 --- a/Scripts/Systems/MoralParadoxSystem.cs +++ b/Scripts/Systems/MoralParadoxSystem.cs @@ -812,7 +812,7 @@ private async Task DisplayOutcome(ParadoxOption choice, TerminalEmulator termina /// /// Apply the effects of the choice /// - private void ApplyChoiceEffects(ParadoxOption choice, Character player) + internal void ApplyChoiceEffects(ParadoxOption choice, Character player) { // Alignment changes if (choice.ChivalryChange != 0) @@ -857,9 +857,14 @@ private void ApplyChoiceEffects(ParadoxOption choice, Character player) StoryProgressionSystem.Instance.UpdateGodState(choice.GodAwakened.Value, GodStatus.Awakened); } + // v1.1.12: the awakening moments these choices are + var moment = MomentForChoice(choice.Id); + if (moment.HasValue) + OceanPhilosophySystem.Instance.ExperienceMoment(moment.Value); + if (choice.OceanPhilosophyBonus) { - OceanPhilosophySystem.Instance.GainInsight(20); + OceanPhilosophySystem.Instance.GainInsight("paradox:" + choice.Id); // v1.1.12 } if (choice.RevealsPlayerSecret) @@ -876,6 +881,15 @@ private void ApplyChoiceEffects(ParadoxOption choice, Character player) // GD.Print($"[MoralParadox] Applied effects for choice: {choice.Id}"); } + /// v1.1.12: the awakening moment a paradox choice records, if any. + internal static AwakeningMoment? MomentForChoice(string optionId) => optionId switch + { + "refuse_paradise" => AwakeningMoment.RejectedParadise, + "take_darkness" => AwakeningMoment.AbsorbedDarkness, + "refuse_power" => AwakeningMoment.LetGoOfPower, // final_choice is never presented; Manwe's Offer also records it + _ => null + }; + /// /// Display Ocean Philosophy reflection /// diff --git a/Scripts/Systems/OceanPhilosophySystem.cs b/Scripts/Systems/OceanPhilosophySystem.cs index d1b30001..4947920e 100644 --- a/Scripts/Systems/OceanPhilosophySystem.cs +++ b/Scripts/Systems/OceanPhilosophySystem.cs @@ -30,6 +30,47 @@ public static OceanPhilosophySystem Instance /// public int AwakeningLevel { get; private set; } = 0; + // v1.1.12: the points model. A moment is worth 3, a Wave Fragment 2, each distinct insight 1. + // Stage N needs StageThresholds[N] points. Expected pacing: + // 1 (4): levels 1-10. The first companion (3) plus one early insight (the wave_self room + // feature, the mirror dream, a catacomb discovery), or sparing a beaten foe. + // 2 (12): about level 20-30. A few more moments (a spared foe, a lore song, a companion's + // death), the first fragments and a handful of dreams, discoveries and visions. + // dream_ocean_first and the Magic Shop's sixth tier open here. + // 3 (22): the mid game, the first Old Gods and their fragments. + // 4 (34), 5 (48), 6 (62): the later Old Gods, the seals, the deep visions and dreams. + // 7: the stage 6 points plus TrueIdentityRevealed or AllSealsCollected. + // TrueIdentityRevealed alone still grants 7 at once. + // The level never falls: a save restores at least the level it was saved with. + public const int PointsPerMoment = 3; + public const int PointsPerFragment = 2; + public const int PointsPerInsight = 1; + public static readonly int[] StageThresholds = { 0, 4, 12, 22, 34, 48, 62 }; + public const int MaxStage = 7; + + /// v1.1.12: the distinct insights gained, by source id. Saved. + public HashSet InsightIds { get; private set; } = new(); + + /// v1.1.12: the highest stage risen to and not yet announced (0 = none). + public int PendingAnnouncementStage { get; private set; } + /// v1.1.12: the stage the pending rise started from. + public int PendingAnnouncementFromStage { get; private set; } + + // v1.1.12: set while a save is replayed, so a restore never queues an announcement + private bool _restoring; + + /// v1.1.12: the awakening points from moments, fragments and insights. + public int Points => + ExperiencedMoments.Count * PointsPerMoment + + CollectedFragments.Count * PointsPerFragment + + InsightIds.Count * PointsPerInsight; + + /// v1.1.12: the points the next stage needs, or -1 at the last stage. + public int PointsForNextStage => + AwakeningLevel + 1 < StageThresholds.Length ? StageThresholds[AwakeningLevel + 1] + : AwakeningLevel < MaxStage ? StageThresholds[StageThresholds.Length - 1] + : -1; + /// /// Wave Fragments: Cryptic lore pieces found throughout the game /// @@ -210,39 +251,20 @@ public void ExperienceMoment(AwakeningMoment moment) } /// - /// Gain or lose awakening insight points directly - /// Used for choices that affect philosophical understanding + /// v1.1.12: gain an insight. Each distinct source id counts once toward the awakening. + /// Used for dreams, visions, discoveries, room features, riddles, songs and choices. /// - public void GainInsight(int points) + public void GainInsight(string insightId) { - // Blood Price blocks positive insight — murderers cannot awaken - if (points > 0) - { - var player = GameEngine.Instance?.CurrentPlayer; - if (player != null && player.MurderWeight > GameConfig.MurderWeightAwakeningBlock) - return; // The weight of death clouds your mind - } + if (string.IsNullOrEmpty(insightId)) return; - // Track insight points in a simple way - affects awakening level calculation - // Positive points move toward awakening, negative move away - if (points > 0) - { - // Create a minor insight - var insight = new OceanInsight( - "Moment of Clarity", - "A deeper understanding settles into your consciousness.", - points / 10 // Contributes to awakening - ); - Insights.Add(insight); - // GD.Print($"[Ocean] Gained {points} insight points"); - } - else if (points < 0) - { - // Grasping moves away from awakening - // GD.Print($"[Ocean] Lost {-points} insight points (grasping)"); - } + // Blood Price blocks insight: murderers cannot awaken + var player = GameEngine.Instance?.CurrentPlayer; + if (player != null && player.MurderWeight > GameConfig.MurderWeightAwakeningBlock) + return; // The weight of death clouds your mind - CheckAwakeningProgress(); + if (InsightIds.Add(insightId)) + CheckAwakeningProgress(); } /// @@ -255,39 +277,80 @@ private void CheckAwakeningProgress() { int oldLevel = AwakeningLevel; AwakeningLevel = newLevel; - // GD.Print($"[Ocean] Awakening increased: {oldLevel} -> {newLevel}"); + StampPlayer(); + OnStageRose(oldLevel, newLevel); } } + // v1.1.12: the session's player carries their stage, so it counts in another session's party too + private void StampPlayer() => AwakeningBonus.Stamp(GameEngine.Instance?.CurrentPlayer, AwakeningLevel); + /// - /// Calculate awakening level based on fragments, moments, and insights + /// v1.1.12: a stage rise queues its announcement for the next safe point, which also + /// recalculates the player's stats (never mid-fight). A restore queues nothing: the stages + /// were announced when they happened. /// - private int CalculateAwakeningLevel() + private void OnStageRose(int oldLevel, int newLevel) { + if (_restoring) return; + if (PendingAnnouncementStage == 0) PendingAnnouncementFromStage = oldLevel; + PendingAnnouncementStage = Math.Max(PendingAnnouncementStage, newLevel); + } + + /// + /// v1.1.12: take the pending announcement, once. Returns (from, to) or null. + /// + public (int From, int To)? TakePendingAnnouncement() + { + if (PendingAnnouncementStage == 0) return null; + var result = (PendingAnnouncementFromStage, PendingAnnouncementStage); + PendingAnnouncementStage = 0; + PendingAnnouncementFromStage = 0; + return result; + } + + /// + /// v1.1.12: the stage the points and moments reach. Stages 1-6 by points; stage 7 needs the + /// stage 6 points and AllSealsCollected, or TrueIdentityRevealed alone. + /// + private int CalculateAwakeningLevel() => StageFor(ExperiencedMoments, CollectedFragments.Count, InsightIds.Count); + + /// v1.1.12: the points function, without the session's state: the stage the distinct moments, + /// the fragment count and the distinct insight count reach (also a loaded save, AwakeningBonus.StageFromSave). + public static int StageFor(ICollection moments, int fragments, int insights) + { + if (moments.Contains(AwakeningMoment.TrueIdentityRevealed)) return MaxStage; + + int points = moments.Count * PointsPerMoment + fragments * PointsPerFragment + insights * PointsPerInsight; int level = 0; + for (int stage = 1; stage < StageThresholds.Length; stage++) + if (points >= StageThresholds[stage]) level = stage; - // Fragments contribute - int fragmentCount = CollectedFragments.Count; - level += fragmentCount / 2; // Every 2 fragments = +1 level - - // Key moments contribute - if (ExperiencedMoments.Contains(AwakeningMoment.FirstCompanionDeath)) level++; - if (ExperiencedMoments.Contains(AwakeningMoment.SacrificedForAnother)) level++; - if (ExperiencedMoments.Contains(AwakeningMoment.SparedAnEnemy)) level++; - if (ExperiencedMoments.Contains(AwakeningMoment.MetManwe)) level++; - if (ExperiencedMoments.Contains(AwakeningMoment.AllSealsCollected)) level++; - if (ExperiencedMoments.Contains(AwakeningMoment.MemoriesRecovered)) level++; - if (ExperiencedMoments.Contains(AwakeningMoment.TrueIdentityRevealed)) level = 7; // Auto-max - if (ExperiencedMoments.Contains(AwakeningMoment.LetGoOfPower)) level++; - if (ExperiencedMoments.Contains(AwakeningMoment.AcceptedDeath)) level++; - if (ExperiencedMoments.Contains(AwakeningMoment.CompanionSacrifice)) level++; - if (ExperiencedMoments.Contains(AwakeningMoment.ForgaveBetrayerMercy)) level++; - if (ExperiencedMoments.Contains(AwakeningMoment.AcceptedGrief)) level++; - if (ExperiencedMoments.Contains(AwakeningMoment.RejectedParadise)) level++; - if (ExperiencedMoments.Contains(AwakeningMoment.AbsorbedDarkness)) level++; - if (ExperiencedMoments.Contains(AwakeningMoment.HeardOldGodLoreSong)) level++; - - return Math.Min(7, level); + if (level == StageThresholds.Length - 1 && moments.Contains(AwakeningMoment.AllSealsCollected)) + level = MaxStage; + + return level; + } + + /// + /// v1.1.12: replay a save. Nothing is announced, and the level is at least the saved level. + /// + public void RestoreFromSave(IEnumerable fragments, IEnumerable moments, + IEnumerable? insightIds, int savedLevel) + { + _restoring = true; + try + { + foreach (var f in fragments) CollectFragment(f); + foreach (var m in moments) ExperienceMoment(m); + if (insightIds != null) + foreach (var id in insightIds) + if (!string.IsNullOrEmpty(id) && InsightIds.Add(id)) CheckAwakeningProgress(); + int floor = Math.Clamp(savedLevel, 0, MaxStage); + if (floor > AwakeningLevel) AwakeningLevel = floor; + StampPlayer(); + } + finally { _restoring = false; } } /// @@ -304,10 +367,11 @@ private int CalculateAwakeningLevel() "Many times. An echo of ancient sorrow...", 2 ), + // v1.1.12: recorded when a companion joins you; the enum name stays for old saves AwakeningMoment.SacrificedForAnother => new OceanInsight( - "Water Flows Downhill", - "In giving of yourself, the boundaries softened. " + - "For a moment, there was no 'you' and 'them' - only love in motion. " + + "Water Joins Water", + "Another chose to walk your road. " + + "For a moment, there was no 'you' and 'them' - two currents in one stream. " + "Is this what the Ocean feels?", 3 ), @@ -451,31 +515,6 @@ public bool IsReadyForTrueEnding() CollectedFragments.Count >= 8; } - /// - /// Get all fragments as a formatted string for display - /// - public string GetFragmentLore() - { - var lines = new List(); - lines.Add("=== The Fragments of Truth ===\n"); - - foreach (var fragment in CollectedFragments.OrderBy(f => FragmentData[f].RequiredAwakening)) - { - var data = FragmentData[fragment]; - lines.Add($"[{data.Title}]"); - lines.Add(data.Text); - lines.Add(""); - } - - if (CollectedFragments.Count < FragmentData.Count) - { - int missing = FragmentData.Count - CollectedFragments.Count; - lines.Add($"({missing} fragments remain hidden...)"); - } - - return string.Join("\n", lines); - } - /// /// Serialize state for saving /// @@ -501,6 +540,7 @@ public void Deserialize(OceanPhilosophyData data) CollectedFragments = new HashSet(data.CollectedFragments); ExperiencedMoments = new HashSet(data.ExperiencedMoments); Insights = data.Insights?.ToList() ?? new List(); + StampPlayer(); } /// @@ -512,6 +552,10 @@ public void Reset() CollectedFragments = new HashSet(); ExperiencedMoments = new HashSet(); Insights = new List(); + InsightIds = new HashSet(); + PendingAnnouncementStage = 0; + PendingAnnouncementFromStage = 0; + StampPlayer(); } } @@ -540,7 +584,7 @@ public enum WaveFragment public enum AwakeningMoment { FirstCompanionDeath, // Losing someone you cared about - SacrificedForAnother, // Giving something precious for another + SacrificedForAnother, // v1.1.12: a companion joined you (name kept for old saves) SparedAnEnemy, // Showing mercy when you could destroy MetManwe, // Encountering the Creator AllSealsCollected, // Understanding the full history diff --git a/Scripts/Systems/OnlineAdminConsole.cs b/Scripts/Systems/OnlineAdminConsole.cs index e1bfbda7..70fb9c43 100644 --- a/Scripts/Systems/OnlineAdminConsole.cs +++ b/Scripts/Systems/OnlineAdminConsole.cs @@ -649,7 +649,8 @@ internal async Task DeletePlayer() // v1.1.11: the same purge as permadeath, once the delete has succeeded (a failed delete must not // leave a living character purged, review). display_name carries a married surname, so the // character's Name2 is read from the save first. - var deletedSave = await backend.ReadGameData(target.Username); + // v1.1.12: read from the row itself, so a banned character's Name2 is found too + string? deletedName2 = backend.GetStoredName2(target.Username); if (!backend.DeleteGameData(target.Username)) { terminal.SetColor("red"); @@ -658,7 +659,7 @@ internal async Task DeletePlayer() return; } await PermadeathHelper.PurgeDeletedCharacterAsync(backend, target.Username, - deletedSave?.Player?.Name2 ?? target.DisplayName); + deletedName2 ?? target.DisplayName); terminal.SetColor("green"); terminal.WriteLine($"Player '{target.DisplayName}' has been permanently deleted."); DebugLogger.Instance.LogWarning("ADMIN", $"Player '{target.DisplayName}' deleted by {DoorMode.OnlineUsername}"); diff --git a/Scripts/Systems/OpeningStorySystem.cs b/Scripts/Systems/OpeningStorySystem.cs index b7e5d3b6..5498f137 100644 --- a/Scripts/Systems/OpeningStorySystem.cs +++ b/Scripts/Systems/OpeningStorySystem.cs @@ -139,6 +139,8 @@ private async Task PlayAwakening(Character player, TerminalEmulator terminal) ("", ""), (Loc.Get("opening_story.dream_remember_answer"), "bright_yellow"), ("", ""), + (Loc.Get("opening_story.dream_asleep"), "bright_cyan"), // v1.1.12: the awakening, named without spoiling it + ("", ""), (Loc.Get("opening_story.dream_fades"), "gray"), (Loc.Get("opening_story.dream_and_then"), "gray") }; diff --git a/Scripts/Systems/PlayerCharacterLoader.cs b/Scripts/Systems/PlayerCharacterLoader.cs index d4178fe4..d90b6439 100644 --- a/Scripts/Systems/PlayerCharacterLoader.cs +++ b/Scripts/Systems/PlayerCharacterLoader.cs @@ -10,7 +10,9 @@ public static class PlayerCharacterLoader /// Create a combat-ready Character from saved PlayerData. /// The character starts at full HP and is AI-controlled. /// - public static Character CreateFromSaveData(PlayerData playerData, string displayName, bool isEcho = false) + /// v1.1.12: story is the save's own StorySystems; the character is stamped with the awakening + /// stage it reaches, before the one recalculation below, so the boons apply once. An echo gets 0. + public static Character CreateFromSaveData(PlayerData playerData, string displayName, bool isEcho = false, StorySystemsData? story = null) { var character = new Character { @@ -54,6 +56,8 @@ public static Character CreateFromSaveData(PlayerData playerData, string display AI = CharacterAI.Computer, IsEcho = isEcho, IsLoadedPlayer = !isEcho, // v1.1.11: a player's own save, so a bounty on them can be paid + // v1.1.12: its own awakening, as with its own team's HQ levels; an echo is only a copy and keeps none + AwakeningStage = isEcho ? 0 : AwakeningBonus.StageFromSave(story), // Restore base stats for RecalculateStats BaseStrength = playerData.BaseStrength > 0 ? playerData.BaseStrength : playerData.Strength, BaseDexterity = playerData.BaseDexterity > 0 ? playerData.BaseDexterity : playerData.Dexterity, diff --git a/Scripts/Systems/SaveDataStructures.cs b/Scripts/Systems/SaveDataStructures.cs index 894ce776..70bc8b89 100644 --- a/Scripts/Systems/SaveDataStructures.cs +++ b/Scripts/Systems/SaveDataStructures.cs @@ -33,6 +33,7 @@ public class StorySystemsData public int AwakeningLevel { get; set; } public List CollectedFragments { get; set; } = new(); public List ExperiencedMoments { get; set; } = new(); + public List OceanInsightIds { get; set; } = new(); // v1.1.12: distinct insights, by source id // Seven Seals public List CollectedSeals { get; set; } = new(); @@ -1795,6 +1796,7 @@ public class DungeonFloorStateData public bool IsPermanentlyClear { get; set; } = false; // Boss/seal floors stay cleared forever public bool BossDefeated { get; set; } = false; // True if the actual boss room boss was defeated public bool CompletionBonusAwarded { get; set; } = false; // Completion XP/gold bonus already paid out + public bool RestedOnThisFloor { get; set; } = false; // v1.1.12: the one rest per floor is spent public string CurrentRoomId { get; set; } = ""; // Where player left off public List Rooms { get; set; } = new(); } diff --git a/Scripts/Systems/SaveSystem.cs b/Scripts/Systems/SaveSystem.cs index 058f0cac..89b8649a 100644 --- a/Scripts/Systems/SaveSystem.cs +++ b/Scripts/Systems/SaveSystem.cs @@ -1098,6 +1098,7 @@ private Dictionary SerializeDungeonFloorStates(Chara IsPermanentlyClear = state.IsPermanentlyClear, BossDefeated = state.BossDefeated, CompletionBonusAwarded = state.CompletionBonusAwarded, + RestedOnThisFloor = state.RestedOnThisFloor, CurrentRoomId = state.CurrentRoomId, Rooms = new List() }; @@ -1878,6 +1879,7 @@ private StorySystemsData SerializeStorySystems() data.AwakeningLevel = ocean.AwakeningLevel; data.CollectedFragments = ocean.CollectedFragments.Select(f => (int)f).ToList(); data.ExperiencedMoments = ocean.ExperiencedMoments.Select(m => (int)m).ToList(); + data.OceanInsightIds = ocean.InsightIds.ToList(); // v1.1.12 } catch (Exception ex) { DebugLogger.Instance.Log(DebugLogger.LogLevel.Debug, "SAVE", $"System not initialized: {ex.Message}"); } @@ -2403,23 +2405,14 @@ public void RestoreStorySystems(StorySystemsData? data, string? onlyRestoreGodFo // Ocean Philosophy - restore fragments one by one try { + // v1.1.12: fragments, moments and insights replay without announcing, and the level is + // at least the saved level, so a change to the points model never lowers a player var ocean = OceanPhilosophySystem.Instance; - foreach (var fragmentInt in data.CollectedFragments) - { - var fragment = (WaveFragment)fragmentInt; - if (!ocean.CollectedFragments.Contains(fragment)) - { - ocean.CollectFragment(fragment); - } - } - foreach (var momentInt in data.ExperiencedMoments) - { - var moment = (AwakeningMoment)momentInt; - if (!ocean.ExperiencedMoments.Contains(moment)) - { - ocean.ExperienceMoment(moment); - } - } + ocean.RestoreFromSave( + data.CollectedFragments.Select(f => (WaveFragment)f), + data.ExperiencedMoments.Select(m => (AwakeningMoment)m), + data.OceanInsightIds, + data.AwakeningLevel); } catch (Exception ex) { DebugLogger.Instance.Log(DebugLogger.LogLevel.Debug, "LOAD", $"System not available: {ex.Message}"); } diff --git a/Scripts/Systems/SqlSaveBackend.cs b/Scripts/Systems/SqlSaveBackend.cs index 32a042a8..c0c82172 100644 --- a/Scripts/Systems/SqlSaveBackend.cs +++ b/Scripts/Systems/SqlSaveBackend.cs @@ -904,6 +904,15 @@ claimed_at TEXT DEFAULT (datetime('now')) } catch { /* Column already exists - expected */ } + // v1.1.12: who paid a team war's wager, so a war left active by a lost session can be refunded + try + { + using var migCmd = connection.CreateCommand(); + migCmd.CommandText = "ALTER TABLE team_wars ADD COLUMN challenger_key TEXT;"; + migCmd.ExecuteNonQuery(); + } + catch { /* Column already exists - expected */ } + // v0.60.5: add created_ip column for per-IP registration rate limiting try { @@ -969,6 +978,8 @@ private SqliteConnection OpenConnection() { var connection = new SqliteConnection(connectionString); connection.Open(); + // v1.1.12: SQLite LOWER() folds ASCII only; ulower folds case as C# does (the team name guards) + connection.CreateFunction("ulower", (string? s) => s?.ToLowerInvariant()); return connection; } @@ -1060,6 +1071,28 @@ UPDATE players SET } } + /// + /// v1.1.12: the save's Name2 for one key, banned accounts included (ReadGameData skips them), for the + /// admin deletes: a married display name is not the name children and quests record. Null if none. + /// + public string? GetStoredName2(string username) + { + try + { + using var connection = OpenConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT CASE WHEN json_valid(player_data) THEN json_extract(player_data, '$.player.name2') END FROM players " + + "WHERE LOWER(username) = LOWER(@u) ORDER BY LENGTH(player_data) DESC LIMIT 1;"; + cmd.Parameters.AddWithValue("@u", username); + return cmd.ExecuteScalar() is string s && !string.IsNullOrWhiteSpace(s) ? s : null; + } + catch (Exception ex) + { + DebugLogger.Instance.LogWarning("SQL", $"GetStoredName2('{username}') failed: {ex.Message}"); + return null; + } + } + public async Task ReadGameData(string playerName) { try @@ -1119,7 +1152,9 @@ public string GetPlayerTeamName(string username) using var connection = OpenConnection(); using var cmd = connection.CreateCommand(); cmd.CommandText = @" - SELECT json_extract(player_data, '$.Player.Team') + -- v1.1.12: the save's keys are camelCase; '$.Player.Team' never matched, so the sleeper's + -- team was always empty and their NPC teammates could attack them + SELECT CASE WHEN json_valid(player_data) THEN json_extract(player_data, '$.player.team') END FROM players WHERE LOWER(username) = LOWER(@username) AND is_banned = 0 ORDER BY (username = LOWER(@username)) DESC, LENGTH(player_data) DESC LIMIT 1;"; @@ -1198,7 +1233,11 @@ public void PurgePlayerWorldState(string username, string? displayName = null) // Multi-column tables: the username can appear as sender/recipient, // attacker/defender, etc. Clear all of them. - ExecPurge(connection, tx, "messages", "LOWER(from_player) = LOWER(@u) OR LOWER(to_player) = LOWER(@u)", username); + // v1.1.12: mail to the key is kept when another character goes by that name (account "bob" playing + // "Alice" beside a character "Bob"), as the alias clause below does + ExecPurge(connection, tx, "messages", "LOWER(from_player) = LOWER(@u) OR (LOWER(to_player) = LOWER(@u) " + + "AND NOT EXISTS (SELECT 1 FROM players p WHERE LOWER(p.username) != LOWER(@u) AND (LOWER(p.display_name) = LOWER(messages.to_player) " + + "OR LOWER(CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.name2') END) = LOWER(messages.to_player))))", username); ExecPurge(connection, tx, "trade_offers", "LOWER(from_player) = LOWER(@u) OR LOWER(to_player) = LOWER(@u)", username); ExecPurge(connection, tx, "bounties", "LOWER(target_player) = LOWER(@u) OR LOWER(placed_by) = LOWER(@u) OR LOWER(claimed_by) = LOWER(@u)", username); // v1.1.11: only the character's own listings, under each name it may have listed as (the key, @@ -1238,6 +1277,14 @@ public void PurgePlayerWorldState(string username, string? displayName = null) ExecPurge(connection, tx, "pending_inheritance", "LOWER(player_username) = LOWER(@u)", username); ExecPurge(connection, tx, "pending_gold_transfers", "LOWER(recipient_username) = LOWER(@u)", username); ExecPurge(connection, tx, "world_boss_rewards", "LOWER(player_name) = LOWER(@u) AND COALESCE(delivered, 0) = 0", username); + // v1.1.12: an unfinished war's refund would otherwise be queued later under this reused key + using (var wars = connection.CreateCommand()) + { + wars.Transaction = tx; + wars.CommandText = "UPDATE team_wars SET challenger_key = NULL WHERE LOWER(challenger_key) = LOWER(@u) AND status = 'active';"; + wars.Parameters.AddWithValue("@u", username); + wars.ExecuteNonQuery(); + } // v1.1.11: mail and auctions also key on the display name (mail to Name2, auction sellers // are DisplayName.ToLower(), the married surname form comes from players.display_name). @@ -1247,7 +1294,10 @@ public void PurgePlayerWorldState(string username, string? displayName = null) const string ownNames = "(SELECT LOWER(display_name) FROM players WHERE LOWER(username) = LOWER(@u) AND display_name IS NOT NULL)"; ExecPurge(connection, tx, "messages", $"to_player != '*' AND (LOWER(to_player) = LOWER(@d) OR LOWER(to_player) IN {ownNames}) " + - "AND NOT EXISTS (SELECT 1 FROM players p WHERE LOWER(p.username) = LOWER(messages.to_player) AND LOWER(p.username) != LOWER(@u))", + "AND NOT EXISTS (SELECT 1 FROM players p WHERE LOWER(p.username) = LOWER(messages.to_player) AND LOWER(p.username) != LOWER(@u)) " + + // v1.1.12: nor a name another character goes by (a married "Bob Smith" beside a player "Bob Smith") + "AND NOT EXISTS (SELECT 1 FROM players p WHERE LOWER(p.username) != LOWER(@u) AND (LOWER(p.display_name) = LOWER(messages.to_player) " + + "OR LOWER(CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.name2') END) = LOWER(messages.to_player)))", username, displayName); } @@ -5160,23 +5210,64 @@ AND pvp.created_at > @since // ========== Player Teams ========== + /// + /// v1.1.12: one guarded INSERT, so of two sessions creating the same name only one gets the row; a name + /// that differs only in case counts as taken (the protection list ignores case). The join stamp gives + /// the founder's save time to land before the empty-team cleanup looks at it. False when not created. + /// public async Task CreatePlayerTeam(string teamName, string passwordHash, string createdBy) + { + try + { + return await Task.Run(() => + { + using var connection = OpenConnection(); + using var tx = connection.BeginTransaction(); + using var cmd = connection.CreateCommand(); + cmd.Transaction = tx; + cmd.CommandText = @"INSERT INTO player_teams (team_name, password_hash, created_by, last_join_at) + SELECT @name, @hash, @creator, datetime('now') + WHERE NOT EXISTS (SELECT 1 FROM player_teams WHERE ulower(team_name) = ulower(@name));"; + cmd.Parameters.AddWithValue("@name", teamName); + cmd.Parameters.AddWithValue("@hash", passwordHash); + cmd.Parameters.AddWithValue("@creator", createdBy.ToLower()); + if (cmd.ExecuteNonQuery() != 1) return false; + // v1.1.12: a new team starts bare; before this release a team dissolved by its last member + // left its upgrades and vault under the name for the next team of that name + foreach (var table in new[] { "team_upgrades", "team_vault" }) + { + using var clear = connection.CreateCommand(); + clear.Transaction = tx; + clear.CommandText = $"DELETE FROM {table} WHERE ulower(team_name) = ulower(@name);"; + clear.Parameters.AddWithValue("@name", teamName); + clear.ExecuteNonQuery(); + } + tx.Commit(); + return true; + }); + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("SQL", $"Failed to create player team '{teamName}': {ex.Message}"); + return false; + } + } + + /// v1.1.12: whether a team has a player_teams row (an NPC-founded team has none); null on a DB error. + public bool? HasPlayerTeamRow(string teamName) { try { using var connection = OpenConnection(); using var cmd = connection.CreateCommand(); - cmd.CommandText = "INSERT INTO player_teams (team_name, password_hash, created_by) VALUES (@name, @hash, @creator);"; + cmd.CommandText = "SELECT COUNT(*) FROM player_teams WHERE team_name = @name;"; cmd.Parameters.AddWithValue("@name", teamName); - cmd.Parameters.AddWithValue("@hash", passwordHash); - cmd.Parameters.AddWithValue("@creator", createdBy.ToLower()); - await Task.Run(() => cmd.ExecuteNonQuery()); - return true; + return Convert.ToInt64(cmd.ExecuteScalar()) > 0; } catch (Exception ex) { - DebugLogger.Instance.LogError("SQL", $"Failed to create player team '{teamName}': {ex.Message}"); - return false; + DebugLogger.Instance.LogError("SQL", $"Failed to look up team '{teamName}': {ex.Message}"); + return null; } } @@ -5702,43 +5793,116 @@ public void ClearGoldTransfers(IEnumerable ids) } } + /// + /// One row per player_teams row, with the player member count and sums from one pass over the saves. + /// public async Task> GetPlayerTeams() { - var teams = new List(); try { - using var connection = OpenConnection(); - using var cmd = connection.CreateCommand(); - // v1.1.11: the member count is counted here; the stored column was refreshed only when someone - // opened that team's roster, so the rankings showed teams with players in them as empty - cmd.CommandText = @" - SELECT t.team_name, t.created_by, - (SELECT COUNT(*) FROM players p - WHERE (CASE WHEN json_valid(p.player_data) THEN json_extract(p.player_data, '$.player.team') END) = t.team_name - AND p.player_data != '{}' AND LENGTH(p.player_data) > 2 - AND p.is_banned = 0 AND p.username NOT LIKE 'emergency_%') AS members, - t.controls_turf, t.created_at - FROM player_teams t ORDER BY members DESC;"; - using var reader = await Task.Run(() => cmd.ExecuteReader()); - while (reader.Read()) + return await Task.Run(() => { - teams.Add(new PlayerTeamInfo - { - TeamName = reader.GetString(0), - CreatedBy = reader.GetString(1), - MemberCount = reader.GetInt32(2), - ControlsTurf = reader.GetInt32(3) != 0, - CreatedAt = DateTime.TryParse(reader.GetString(4), out var dt) ? dt : DateTime.Now - }); - } + using var connection = OpenConnection(); + var stats = ReadTeamPlayerStats(connection, null); + return ReadTeamRows(connection, stats).OrderByDescending(t => t.MemberCount).ToList(); + }); } catch (Exception ex) { DebugLogger.Instance.LogError("SQL", $"Failed to get player teams: {ex.Message}"); + return new List(); + } + } + + /// + /// v1.1.12: the team rankings' player side: every player_teams row plus every team named only in player + /// saves (HasTeamRow false), with the viewer's own save left out (excludeSaveKey, the players.username key) + /// so the caller adds the in-memory character once, with its current level and team. + /// + public async Task> GetTeamRankingStats(string? excludeSaveKey) + { + try + { + return await Task.Run(() => + { + using var connection = OpenConnection(); + var stats = ReadTeamPlayerStats(connection, excludeSaveKey); + var teams = ReadTeamRows(connection, stats); + var named = teams.Select(t => t.TeamName).ToHashSet(StringComparer.Ordinal); + foreach (var (team, st) in stats) + if (!named.Contains(team)) + teams.Add(new PlayerTeamInfo { TeamName = team, MemberCount = st.Members, LevelSum = st.LevelSum, PowerSum = st.PowerSum, HasTeamRow = false }); + return teams; + }); + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("SQL", $"Failed to get team ranking stats: {ex.Message}"); + return new List(); + } + } + + // v1.1.12: TOTAL, not SUM, since SUM throws on overflow and one absurd stat would blank the list + private static long ClampToLong(double v) => v >= long.MaxValue ? long.MaxValue : v <= long.MinValue ? long.MinValue : (long)v; + + private static List ReadTeamRows(SqliteConnection connection, Dictionary stats) + { + var teams = new List(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT team_name, created_by, controls_turf, created_at FROM player_teams;"; + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var name = reader.GetString(0); + stats.TryGetValue(name, out var st); + teams.Add(new PlayerTeamInfo + { + TeamName = name, + CreatedBy = reader.GetString(1), + MemberCount = st.Members, + LevelSum = st.LevelSum, + PowerSum = st.PowerSum, + ControlsTurf = reader.GetInt32(2) != 0, + CreatedAt = DateTime.TryParse(reader.GetString(3), out var dt) ? dt : DateTime.Now + }); } return teams; } + /// + /// v1.1.12: player members per team in ONE pass over players. The v1.1.11 query counted each team with + /// its own scan, so 66 teams parsed about 2.4 GB of save JSON (6.1 s on a 38 MB test set, now 0.1 s). + /// The multi-path json_extract parses each blob once; MATERIALIZED stops SQLite from inlining it into + /// each '$[n]' read below (inlined was twice as slow). json_valid keeps one malformed save from failing + /// every team, and a team value that is not text is ignored. + /// + private static Dictionary ReadTeamPlayerStats(SqliteConnection connection, string? excludeSaveKey) + { + var stats = new Dictionary(StringComparer.Ordinal); + using var cmd = connection.CreateCommand(); + cmd.CommandText = @" + WITH s AS MATERIALIZED ( + SELECT CASE WHEN json_valid(player_data) + THEN json_extract(player_data, '$.player.team', '$.player.level', '$.player.strength', '$.player.defence') END AS v + FROM players + WHERE player_data != '{}' AND LENGTH(player_data) > 2 + AND is_banned = 0 AND username NOT LIKE 'emergency_%' + AND (@me IS NULL OR LOWER(username) != @me)) + SELECT json_extract(v, '$[0]') AS team, COUNT(*), + TOTAL(COALESCE(CAST(json_extract(v, '$[1]') AS INTEGER), 0)), + TOTAL(COALESCE(CAST(json_extract(v, '$[1]') AS INTEGER), 0) + + COALESCE(CAST(json_extract(v, '$[2]') AS INTEGER), 0) + + COALESCE(CAST(json_extract(v, '$[3]') AS INTEGER), 0)) + FROM s + WHERE json_type(v, '$[0]') = 'text' AND json_extract(v, '$[0]') != '' + GROUP BY team;"; + cmd.Parameters.AddWithValue("@me", string.IsNullOrEmpty(excludeSaveKey) ? DBNull.Value : excludeSaveKey.ToLowerInvariant()); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + stats[reader.GetString(0)] = (reader.GetInt32(1), ClampToLong(reader.GetDouble(2)), ClampToLong(reader.GetDouble(3))); + return stats; + } + /// /// Roster of PLAYER members of a team. Membership is read from each player's own /// save blob (player_data.player.team), so a member is only visible here once @@ -5885,22 +6049,6 @@ AND NOT EXISTS (SELECT 1 FROM deleted_characters d WHERE d.expires_at > datetime } } - public async Task DeletePlayerTeam(string teamName) - { - try - { - using var connection = OpenConnection(); - using var cmd = connection.CreateCommand(); - cmd.CommandText = "DELETE FROM player_teams WHERE team_name = @name;"; - cmd.Parameters.AddWithValue("@name", teamName); - await Task.Run(() => cmd.ExecuteNonQuery()); - } - catch (Exception ex) - { - DebugLogger.Instance.LogError("SQL", $"Failed to delete player team '{teamName}': {ex.Message}"); - } - } - public async Task UpdatePlayerTeamMemberCount(string teamName) { try @@ -5930,7 +6078,8 @@ public bool IsTeamNameTaken(string teamName) { using var connection = OpenConnection(); using var cmd = connection.CreateCommand(); - cmd.CommandText = "SELECT COUNT(*) FROM player_teams WHERE team_name = @name;"; + // v1.1.12: ignores case, as the protection list and the create guard do + cmd.CommandText = "SELECT COUNT(*) FROM player_teams WHERE ulower(team_name) = ulower(@name);"; cmd.Parameters.AddWithValue("@name", teamName); var count = Convert.ToInt32(cmd.ExecuteScalar()); return count > 0; @@ -5947,6 +6096,42 @@ public static string HashTeamPassword(string password) return HashPassword(password); } + /// + /// v1.1.12: the team password lives in password_hash, which a join checks; the change used to set only + /// the in-memory copies, so the new password was refused and the old one still worked. Only the leader + /// (created_by) may change it, and only while the old password still matches (a compare-and-swap on + /// the stored hash). True when the row changed. + /// + public bool ChangeTeamPassword(string teamName, string leaderKey, string oldPassword, string newPassword) + { + try + { + using var connection = OpenConnection(); + string? stored; + using (var read = connection.CreateCommand()) + { + read.CommandText = "SELECT password_hash FROM player_teams WHERE team_name = @name AND created_by = @leader;"; + read.Parameters.AddWithValue("@name", teamName); + read.Parameters.AddWithValue("@leader", leaderKey.ToLowerInvariant()); + stored = read.ExecuteScalar()?.ToString(); + } + if (stored == null || !VerifyPassword(oldPassword, stored)) return false; + using var cmd = connection.CreateCommand(); + cmd.CommandText = @"UPDATE player_teams SET password_hash = @hash + WHERE team_name = @name AND created_by = @leader AND password_hash = @old;"; + cmd.Parameters.AddWithValue("@hash", HashTeamPassword(newPassword)); + cmd.Parameters.AddWithValue("@name", teamName); + cmd.Parameters.AddWithValue("@leader", leaderKey.ToLowerInvariant()); + cmd.Parameters.AddWithValue("@old", stored); + return cmd.ExecuteNonQuery() == 1; + } + catch (Exception ex) + { + DebugLogger.Instance.LogError("SQL", $"Failed to change the password of team '{teamName}': {ex.Message}"); + return false; + } + } + // ========== Offline Mail ========== public bool PlayerExists(string nameOrDisplay) @@ -6790,23 +6975,82 @@ public async Task CollectAuctionGold(int listingId, string seller) // Team Wars // ═══════════════════════════════════════════════════════════════════════════ - public async Task CreateTeamWar(string challengerTeam, string defenderTeam, long goldWagered) + /// + /// v1.1.12: guarded, so two challenges at once cannot both start a war for the same team; the payer's + /// key (the pending_gold_transfers recipient) is kept for a refund if the war is abandoned. -1 when + /// not created. + /// + public async Task CreateTeamWar(string challengerTeam, string defenderTeam, long goldWagered, string challengerKey = "") { try { + ExpireStaleTeamWars(); using var connection = OpenConnection(); using var cmd = connection.CreateCommand(); - cmd.CommandText = @"INSERT INTO team_wars (challenger_team, defender_team, status, gold_wagered) - VALUES (@challenger, @defender, 'active', @gold) RETURNING id;"; + cmd.CommandText = @"INSERT INTO team_wars (challenger_team, defender_team, status, gold_wagered, challenger_key) + SELECT @challenger, @defender, 'active', @gold, @key + WHERE NOT EXISTS (SELECT 1 FROM team_wars WHERE status = 'active' + AND (challenger_team IN (@challenger, @defender) OR defender_team IN (@challenger, @defender))) + RETURNING id;"; cmd.Parameters.AddWithValue("@challenger", challengerTeam); cmd.Parameters.AddWithValue("@defender", defenderTeam); cmd.Parameters.AddWithValue("@gold", goldWagered); + cmd.Parameters.AddWithValue("@key", (challengerKey ?? "").ToLowerInvariant()); var result = await cmd.ExecuteScalarAsync(); - return Convert.ToInt32(result); + return result == null || result is DBNull ? -1 : Convert.ToInt32(result); } catch (Exception ex) { DebugLogger.Instance.LogError("SQL", $"Failed to create team war: {ex.Message}"); return -1; } } + /// + /// v1.1.12: a war still 'active' after GameConfig.TeamWarStaleMinutes was left by a lost session and + /// would block both teams for ever. It is marked 'abandoned'; if no round was recorded, the wager goes + /// back to its payer by a queued transfer, in the same transaction and only by the process that flipped + /// the row. A war with rounds recorded is not refunded, so leaving a losing war does not pay. It is not + /// settled by score either, since a challenger could leave while ahead; this holds too for a fought war + /// whose own completion failed (TeamCornerLocation pays nothing then). Returns the number expired. + /// + public int ExpireStaleTeamWars(int? staleMinutes = null) + { + int expired = 0; + try + { + using var connection = OpenConnection(); + using var tx = connection.BeginTransaction(); + var stale = new List<(int Id, long Wager, int Wins, string Key, string Team)>(); + using (var q = connection.CreateCommand()) + { + q.Transaction = tx; + q.CommandText = @"SELECT id, gold_wagered, challenger_wins + defender_wins, COALESCE(challenger_key, ''), challenger_team + FROM team_wars WHERE status = 'active' AND started_at < datetime('now', '-' || @mins || ' minutes');"; + q.Parameters.AddWithValue("@mins", staleMinutes ?? GameConfig.TeamWarStaleMinutes); + using var r = q.ExecuteReader(); + while (r.Read()) stale.Add((r.GetInt32(0), r.GetInt64(1), r.GetInt32(2), r.GetString(3), r.GetString(4))); + } + foreach (var war in stale) + { + using var flip = connection.CreateCommand(); + flip.Transaction = tx; + flip.CommandText = "UPDATE team_wars SET status = 'abandoned', finished_at = datetime('now') WHERE id = @id AND status = 'active';"; + flip.Parameters.AddWithValue("@id", war.Id); + if (flip.ExecuteNonQuery() != 1) continue; + expired++; + if (war.Wins != 0 || war.Wager <= 0 || string.IsNullOrEmpty(war.Key)) continue; + using var refund = connection.CreateCommand(); + refund.Transaction = tx; + refund.CommandText = @"INSERT INTO pending_gold_transfers (recipient_username, sender_display, amount, note) + VALUES (@user, @sender, @amount, 'Team war refund');"; + refund.Parameters.AddWithValue("@user", war.Key); + refund.Parameters.AddWithValue("@sender", war.Team); + refund.Parameters.AddWithValue("@amount", war.Wager); + refund.ExecuteNonQuery(); + } + tx.Commit(); + } + catch (Exception ex) { DebugLogger.Instance.LogError("SQL", $"Failed to expire stale team wars: {ex.Message}"); } + return expired; + } + public async Task UpdateTeamWarScore(int warId, bool challengerWon) { try @@ -6822,18 +7066,36 @@ public async Task UpdateTeamWarScore(int warId, bool challengerWon) catch (Exception ex) { DebugLogger.Instance.LogError("SQL", $"Failed to update war score: {ex.Message}"); } } - public async Task CompleteTeamWar(int warId, string result) + /// + /// v1.1.12: guarded on the war still being 'active' (the same flip ExpireStaleTeamWars makes), so a war + /// is settled once. True only when this call settled it; a caller refunds only then. + /// + public async Task CompleteTeamWar(int warId, string result) { try { using var connection = OpenConnection(); using var cmd = connection.CreateCommand(); - cmd.CommandText = @"UPDATE team_wars SET status = @result, finished_at = datetime('now') WHERE id = @id;"; + cmd.CommandText = @"UPDATE team_wars SET status = @result, finished_at = datetime('now') WHERE id = @id AND status = 'active';"; cmd.Parameters.AddWithValue("@id", warId); cmd.Parameters.AddWithValue("@result", result); - await cmd.ExecuteNonQueryAsync(); + return await cmd.ExecuteNonQueryAsync() == 1; } - catch (Exception ex) { DebugLogger.Instance.LogError("SQL", $"Failed to complete team war: {ex.Message}"); } + catch (Exception ex) { DebugLogger.Instance.LogError("SQL", $"Failed to complete team war: {ex.Message}"); return false; } + } + + /// v1.1.12: a war's status ('active', a result, or 'abandoned'); null if it cannot be read. + public async Task GetTeamWarStatus(int warId) + { + try + { + using var connection = OpenConnection(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = "SELECT status FROM team_wars WHERE id = @id;"; + cmd.Parameters.AddWithValue("@id", warId); + return (await cmd.ExecuteScalarAsync())?.ToString(); + } + catch (Exception ex) { DebugLogger.Instance.LogError("SQL", $"Failed to read team war status: {ex.Message}"); return null; } } public async Task> GetTeamWarHistory(string teamName, int limit = 10) @@ -6875,6 +7137,7 @@ public bool HasActiveTeamWar(string teamName) { try { + ExpireStaleTeamWars(); // v1.1.12: a war a lost session left active no longer blocks both teams using var connection = OpenConnection(); using var cmd = connection.CreateCommand(); cmd.CommandText = @"SELECT COUNT(*) FROM team_wars @@ -7140,26 +7403,51 @@ public async Task> GetTeamUpgrades(string teamName) return upgrades; } - public async Task UpgradeTeamFacility(string teamName, string upgradeType, long cost) + /// + /// v1.1.12: raises a facility one level only if it is still at expectedLevel and below the cap, so two + /// members upgrading at once cannot both land (or pass the cap). When payFromVault, the cost comes out + /// of the vault in the same transaction, and nothing changes unless both land. True when it landed. + /// + public bool TryUpgradeTeamFacility(string teamName, string upgradeType, int expectedLevel, long cost, bool payFromVault) { try { using var connection = OpenConnection(); + using var tx = connection.BeginTransaction(); + if (payFromVault) + { + using var pay = connection.CreateCommand(); + pay.Transaction = tx; + pay.CommandText = "UPDATE team_vault SET gold = gold - @cost WHERE team_name = @team AND gold >= @cost;"; + pay.Parameters.AddWithValue("@team", teamName); + pay.Parameters.AddWithValue("@cost", cost); + if (pay.ExecuteNonQuery() != 1) return false; + } using var cmd = connection.CreateCommand(); - cmd.CommandText = @"INSERT INTO team_upgrades (team_name, upgrade_type, level, invested_gold) - VALUES (@team, @type, 1, @cost) - ON CONFLICT(team_name, upgrade_type) DO UPDATE SET - level = level + 1, - invested_gold = invested_gold + @cost;"; + cmd.Transaction = tx; + cmd.CommandText = expectedLevel == 0 + ? @"INSERT INTO team_upgrades (team_name, upgrade_type, level, invested_gold) + VALUES (@team, @type, 1, @cost) + ON CONFLICT(team_name, upgrade_type) DO UPDATE SET + level = level + 1, invested_gold = invested_gold + @cost + WHERE team_upgrades.level = 0;" + : @"UPDATE team_upgrades SET level = level + 1, invested_gold = invested_gold + @cost + WHERE team_name = @team AND upgrade_type = @type AND level = @expected AND level < @cap;"; cmd.Parameters.AddWithValue("@team", teamName); cmd.Parameters.AddWithValue("@type", upgradeType); cmd.Parameters.AddWithValue("@cost", cost); - await cmd.ExecuteNonQueryAsync(); + cmd.Parameters.AddWithValue("@expected", expectedLevel); + cmd.Parameters.AddWithValue("@cap", GameConfig.MaxTeamFacilityLevel); + if (cmd.ExecuteNonQuery() != 1) return false; + tx.Commit(); return true; } catch (Exception ex) { DebugLogger.Instance.LogError("SQL", $"Failed to upgrade facility: {ex.Message}"); return false; } } + /// v1.1.12: the vault's size at a vault level. + public static long TeamVaultCapacity(int vaultLevel) => GameConfig.TeamVaultBaseCapacity + vaultLevel * GameConfig.TeamVaultCapacityPerLevel; + public async Task GetTeamVaultGold(string teamName) { try @@ -7180,12 +7468,19 @@ public async Task DepositToTeamVault(string teamName, long amount) { using var connection = OpenConnection(); using var cmd = connection.CreateCommand(); - cmd.CommandText = @"INSERT INTO team_vault (team_name, gold) VALUES (@team, @amount) - ON CONFLICT(team_name) DO UPDATE SET gold = gold + @amount;"; + // v1.1.12: the capacity is enforced here, from the vault level in the same statement, so two + // deposits at once cannot overfill it; false when it would not fit + cmd.CommandText = @"WITH cap AS (SELECT @base + @per * COALESCE((SELECT level FROM team_upgrades + WHERE team_name = @team AND upgrade_type = 'vault'), 0) AS c) + INSERT INTO team_vault (team_name, gold) + SELECT @team, @amount WHERE @amount > 0 AND @amount <= (SELECT c FROM cap) + ON CONFLICT(team_name) DO UPDATE SET gold = gold + @amount + WHERE team_vault.gold + @amount <= (SELECT c FROM cap);"; cmd.Parameters.AddWithValue("@team", teamName); cmd.Parameters.AddWithValue("@amount", amount); - await cmd.ExecuteNonQueryAsync(); - return true; + cmd.Parameters.AddWithValue("@base", GameConfig.TeamVaultBaseCapacity); + cmd.Parameters.AddWithValue("@per", GameConfig.TeamVaultCapacityPerLevel); + return await cmd.ExecuteNonQueryAsync() == 1; } catch (Exception ex) { DebugLogger.Instance.LogError("SQL", $"Failed to deposit to vault: {ex.Message}"); return false; } } diff --git a/Scripts/Systems/SysOpConsoleManager.cs b/Scripts/Systems/SysOpConsoleManager.cs index 9708ce40..7bd94b69 100644 --- a/Scripts/Systems/SysOpConsoleManager.cs +++ b/Scripts/Systems/SysOpConsoleManager.cs @@ -700,7 +700,8 @@ private async Task DeletePlayerOnline(SqlSaveBackend sqlBackend) // v1.1.11: the same purge as permadeath, once the delete has succeeded (a failed delete must not // leave a living character purged, review). The character's Name2 is read from the save first. - var deletedSave = await sqlBackend.ReadGameData(target.Username); + // v1.1.12: read from the row itself, so a banned character's Name2 is found too + string? deletedName2 = sqlBackend.GetStoredName2(target.Username); if (!sqlBackend.DeleteGameData(target.Username)) { terminal.SetColor("red"); @@ -709,7 +710,7 @@ private async Task DeletePlayerOnline(SqlSaveBackend sqlBackend) return; } await PermadeathHelper.PurgeDeletedCharacterAsync(sqlBackend, target.Username, - deletedSave?.Player?.Name2 ?? target.DisplayName); + deletedName2 ?? target.DisplayName); terminal.SetColor("green"); terminal.WriteLine($" {target.DisplayName} has been permanently deleted."); DebugLogger.Instance.LogWarning("SYSOP", $"Deleted player '{target.DisplayName}'"); diff --git a/Scripts/Systems/TeamHQBonus.cs b/Scripts/Systems/TeamHQBonus.cs index 67a38b09..d1aca728 100644 --- a/Scripts/Systems/TeamHQBonus.cs +++ b/Scripts/Systems/TeamHQBonus.cs @@ -53,11 +53,26 @@ private static bool Current(Character c) public static double XPMultiplier(Character c) => 1.0 + Training(c) * TrainingPerLevel; public static double PotionHealMultiplier(Character c) => 1.0 + Infirmary(c) * InfirmaryPerLevel; + // v1.1.12: the awakening boons (AwakeningBonus) ride on these three, in the same place and order, + // multiplied with the HQ factor and rounded once. They apply offline too, and only to a player with a stage of their own (AwakeningBonus.StageOf). + /// Damage dealt by the player, after every other modifier. - public static long ApplyAttack(Character c, long damage) => Armory(c) > 0 ? (long)Math.Round(damage * AttackMultiplier(c)) : damage; + public static long ApplyAttack(Character c, long damage) + { + double m = (Armory(c) > 0 ? AttackMultiplier(c) : 1.0) * AwakeningBonus.DamageMultiplier(c); + return m != 1.0 ? (long)Math.Round(damage * m) : damage; + } /// Damage taken by the player, after every other modifier and before any floor. - public static long ApplyDefense(Character c, long damage) => Barracks(c) > 0 ? (long)Math.Round(damage / DefenseMultiplier(c)) : damage; - public static long ApplyXP(Character c, long xp) => Training(c) > 0 ? (long)Math.Round(xp * XPMultiplier(c)) : xp; + public static long ApplyDefense(Character c, long damage) + { + double m = AwakeningBonus.DefenseFactor(c) / (Barracks(c) > 0 ? DefenseMultiplier(c) : 1.0); + return m != 1.0 ? (long)Math.Round(damage * m) : damage; + } + public static long ApplyXP(Character c, long xp) + { + double m = (Training(c) > 0 ? XPMultiplier(c) : 1.0) * AwakeningBonus.XPMultiplier(c); + return m != 1.0 ? (long)Math.Round(xp * m) : xp; + } public static long ApplyPotionHeal(Character c, long heal) => Infirmary(c) > 0 ? (long)Math.Round(heal * PotionHealMultiplier(c)) : heal; /// diff --git a/Scripts/Systems/WorldEventSystem.cs b/Scripts/Systems/WorldEventSystem.cs index c5836f77..2ff51be8 100644 --- a/Scripts/Systems/WorldEventSystem.cs +++ b/Scripts/Systems/WorldEventSystem.cs @@ -628,6 +628,10 @@ public long GetAdjustedGold(long baseGold) return (long)(baseGold * GlobalGoldModifier); } + // v1.1.12: the world event's own share of a reward, for the combat bonus line. + public long GetWorldEventXPBonus(long baseXP) => GetAdjustedXP(baseXP) - baseXP; + public long GetWorldEventGoldBonus(long baseGold) => GetAdjustedGold(baseGold) - baseGold; + /// /// Check if a location is accessible based on current events /// diff --git a/Scripts/Systems/WorldSimulator.cs b/Scripts/Systems/WorldSimulator.cs index ed0653f2..46402bcf 100644 --- a/Scripts/Systems/WorldSimulator.cs +++ b/Scripts/Systems/WorldSimulator.cs @@ -7577,7 +7577,7 @@ bool IsSleepersPartner(NPC n) => var saveData = SqlBackend.ReadGameData(sleeper.Username).GetAwaiter().GetResult(); if (saveData?.Player == null) continue; - var sleeperChar = PlayerCharacterLoader.CreateFromSaveData(saveData.Player, sleeper.Username); + var sleeperChar = PlayerCharacterLoader.CreateFromSaveData(saveData.Player, sleeper.Username, story: saveData.StorySystems); // Apply Inn defense boost if (sleeper.InnDefenseBoost) diff --git a/Tests/Awakening1112Tests.cs b/Tests/Awakening1112Tests.cs new file mode 100644 index 00000000..0e98c34a --- /dev/null +++ b/Tests/Awakening1112Tests.cs @@ -0,0 +1,785 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Reflection; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using UsurperRemake; +using UsurperRemake.Locations; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.12: the awakening points model, its saved insights, the wired moments, the announcement, +/// the Ocean Journal and the stage boosts. +/// +[Collection("SharedGameSingletons")] +public class Awakening1112Tests : IDisposable +{ + private static OceanPhilosophySystem Ocean => OceanPhilosophySystem.Instance; + + public Awakening1112Tests() => Ocean.Reset(); + public void Dispose() => Ocean.Reset(); + + // ---------- 1. points model ---------- + + [Fact] + public void Points_AreThreePerMoment_TwoPerFragment_OnePerDistinctInsight() + { + Ocean.ExperienceMoment(AwakeningMoment.SparedAnEnemy); + Ocean.CollectFragment(WaveFragment.Origin); + Ocean.GainInsight("dream:a"); + Ocean.GainInsight("dream:a"); + Ocean.GainInsight("dream:b"); + Ocean.Points.Should().Be(3 + 2 + 2, "the same insight counts once"); + } + + [Fact] + public void Stages_FollowTheThresholds() + { + Ocean.GainInsight("i1"); Ocean.GainInsight("i2"); Ocean.GainInsight("i3"); + Ocean.AwakeningLevel.Should().Be(0); + Ocean.GainInsight("i4"); + Ocean.AwakeningLevel.Should().Be(1, "4 points is stage 1"); + for (int i = 5; i <= 12; i++) Ocean.GainInsight("i" + i); + Ocean.AwakeningLevel.Should().Be(2, "12 points is stage 2"); + for (int i = 13; i <= 62; i++) Ocean.GainInsight("i" + i); + Ocean.AwakeningLevel.Should().Be(6, "stage 7 needs more than points"); + } + + [Fact] + public void Stage7_NeedsTheStage6Points_AndAllSeals() + { + Ocean.ExperienceMoment(AwakeningMoment.AllSealsCollected); + Ocean.AwakeningLevel.Should().BeLessThan(7, "the seals alone are not enough"); + for (int i = 0; Ocean.Points < OceanPhilosophySystem.StageThresholds[6]; i++) Ocean.GainInsight("i" + i); + Ocean.AwakeningLevel.Should().Be(7); + } + + [Fact] + public void TrueIdentityRevealed_StillGrants7AtOnce() + { + Ocean.ExperienceMoment(AwakeningMoment.TrueIdentityRevealed); + Ocean.AwakeningLevel.Should().Be(7); + } + + [Fact] + public void Restore_NeverLowersTheSavedLevel_AndAnnouncesNothing() + { + // an old save: level 4 from the old formula (4 fragments + 2 moments), 14 points now + Ocean.RestoreFromSave( + new[] { WaveFragment.Origin, WaveFragment.FirstSeparation, WaveFragment.TheForgetting, WaveFragment.TheCycle }, + new[] { AwakeningMoment.FirstCompanionDeath, AwakeningMoment.SacrificedForAnother }, + null, savedLevel: 4); + Ocean.AwakeningLevel.Should().Be(4); + Ocean.PendingAnnouncementStage.Should().Be(0, "a restore replays stages already announced"); + Ocean.TakePendingAnnouncement().Should().BeNull(); + } + + [Fact] + public void Restore_RaisesAboveTheSavedLevel_WhenThePointsReachHigher() + { + var insights = Enumerable.Range(0, 30).Select(i => "i" + i).ToList(); + Ocean.RestoreFromSave(Array.Empty(), Array.Empty(), insights, savedLevel: 1); + Ocean.AwakeningLevel.Should().Be(3, "30 points is stage 3"); + Ocean.InsightIds.Should().HaveCount(30); + } + + [Fact] + public void Insights_SurviveTheSave() + { + Ocean.GainInsight("dream:dream_mirror"); + Ocean.GainInsight("feature:wave_self"); + Ocean.CollectFragment(WaveFragment.Origin); + var data = SaveSystem.Instance.SerializeStorySystemsPublic(); + data.OceanInsightIds.Should().BeEquivalentTo(new[] { "dream:dream_mirror", "feature:wave_self" }); + + var json = JsonSerializer.Serialize(data); + var back = JsonSerializer.Deserialize(json)!; + Ocean.Reset(); + SaveSystem.Instance.RestoreStorySystems(back); + Ocean.InsightIds.Should().BeEquivalentTo(new[] { "dream:dream_mirror", "feature:wave_self" }); + Ocean.Points.Should().Be(4); + Ocean.AwakeningLevel.Should().Be(1); + } + + [Fact] + public void AnOldSave_WithoutInsightIds_Loads() + { + var back = JsonSerializer.Deserialize("{\"AwakeningLevel\":2,\"CollectedFragments\":[0],\"ExperiencedMoments\":[0]}")!; + SaveSystem.Instance.RestoreStorySystems(back); + Ocean.AwakeningLevel.Should().Be(2); + Ocean.InsightIds.Should().BeEmpty(); + } + + // ---------- 2. the moments ---------- + + private const BindingFlags F = BindingFlags.NonPublic | BindingFlags.Instance; + + private static (TerminalEmulator term, MemoryStream output) Terminal(string input = "") + { + var output = new MemoryStream(); + return (new TerminalEmulator(new MemoryStream(Encoding.UTF8.GetBytes(input)), output), output); + } + + private static string Shown(TerminalEmulator term, MemoryStream output) + { + term.StreamWriterInternal?.Flush(); + return Encoding.UTF8.GetString(output.ToArray()); + } + + private static Character Hero() => new() + { + Name1 = "wave", Name2 = "Wave", Class = CharacterClass.Warrior, Level = 8, HP = 500, MaxHP = 500, + BaseMaxHP = 500, BaseWisdom = 10, Wisdom = 10, AI = CharacterAI.Human + }; + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !(Directory.Exists(Path.Combine(dir.FullName, "Scripts")) && Directory.Exists(Path.Combine(dir.FullName, "Localization")))) + dir = dir.Parent; + return dir!.FullName; + } + + private static string Source(string relative) => File.ReadAllText(Path.Combine(RepoRoot(), relative)); + + [Fact] + public async Task SparingASurrenderedFoe_RecordsSparedAnEnemy() + { + var (term, _) = Terminal("1\n"); + var engine = new CombatEngine(term); + var hero = Hero(); + var npc = new NPC { ID = "npc_spare", Name1 = "Foe", Name2 = "Foe", Level = 5, HP = 0, MaxHP = 100, HpAtRoundStart = 100 }; + var result = new CombatResult { Player = hero, Opponent = npc }; + var offer = typeof(CombatEngine).GetMethod("OfferNPCSurrenderAsync", F)!; + bool spared = await (Task)offer.Invoke(engine, new object[] { hero, npc, result })!; + spared.Should().BeTrue(); + Ocean.ExperiencedMoments.Should().Contain(AwakeningMoment.SparedAnEnemy); + } + + [Fact] + public async Task FinishingASurrenderedFoe_RecordsNothing() + { + var (term, _) = Terminal("2\n"); + var engine = new CombatEngine(term); + var hero = Hero(); + var npc = new NPC { ID = "npc_finish", Name1 = "Foe", Name2 = "Foe", Level = 5, HP = 0, MaxHP = 100, HpAtRoundStart = 100 }; + var offer = typeof(CombatEngine).GetMethod("OfferNPCSurrenderAsync", F)!; + await (Task)offer.Invoke(engine, new object[] { hero, npc, new CombatResult { Player = hero, Opponent = npc } })!; + Ocean.ExperiencedMoments.Should().NotContain(AwakeningMoment.SparedAnEnemy); + } + + [Fact] + public void TheManweDialogue_RecordsMetManwe_AndStartDialogueCallsIt() + { + DialogueSystem.RecordDialogueMoments("veloura_encounter"); + Ocean.ExperiencedMoments.Should().NotContain(AwakeningMoment.MetManwe); + DialogueSystem.RecordDialogueMoments("manwe_encounter"); + Ocean.ExperiencedMoments.Should().Contain(AwakeningMoment.MetManwe); + Source("Scripts/Systems/DialogueSystem.cs").Should().Contain("RecordDialogueMoments(treeId);"); + } + + [Fact] + public void TheManweAlliance_GrantsARealFragment() + { + var ds = DialogueSystem.Instance; + var trees = (System.Collections.IDictionary)typeof(DialogueSystem).GetField("dialogueTrees", F)!.GetValue(ds)!; + var tree = (DialogueTree)trees["manwe_encounter"]!; + var node = tree.AllNodes["manwe_alliance"]; + var old = typeof(DialogueSystem).GetField("currentPlayer", F)!.GetValue(ds); + try + { + typeof(DialogueSystem).GetField("currentPlayer", F)!.SetValue(ds, Hero()); + typeof(DialogueSystem).GetMethod("ApplyNodeEffects", F)!.Invoke(ds, new object[] { node }); + Ocean.CollectedFragments.Should().Contain(WaveFragment.TheChoice); + Ocean.InsightIds.Should().Contain("dialogue:manwe_alliance"); + } + finally { typeof(DialogueSystem).GetField("currentPlayer", F)!.SetValue(ds, old); } + } + + [Theory] + [InlineData("refuse_paradise", AwakeningMoment.RejectedParadise)] + [InlineData("take_darkness", AwakeningMoment.AbsorbedDarkness)] + [InlineData("refuse_power", AwakeningMoment.LetGoOfPower)] + public void TheParadoxChoices_RecordTheirMoments(string optionId, AwakeningMoment moment) + { + // the real options carry these ids + var paradoxes = (System.Collections.IDictionary)typeof(MoralParadoxSystem).GetField("paradoxes", F)!.GetValue(MoralParadoxSystem.Instance)!; + paradoxes.Values.Cast().SelectMany(p => p.Choices).Select(c => c.Id).Should().Contain(optionId); + + MoralParadoxSystem.Instance.ApplyChoiceEffects(new ParadoxOption { Id = optionId }, Hero()); + Ocean.ExperiencedMoments.Should().Contain(moment); + } + + [Fact] + public void AcceptingManwesOffer_RecordsLetGoOfPower() + { + // the Offer is mid-fight against the Creator; the recording sits in the accept branch + string src = Source("Scripts/Systems/CombatEngine.cs"); + int accept = src.IndexOf("Player accepts The Offer"); + int record = src.LastIndexOf("ExperienceMoment(AwakeningMoment.LetGoOfPower)", accept); + record.Should().BeGreaterThan(src.LastIndexOf("if (GameConfig.IsAffirmative(response))", accept)); + } + + [Fact] + public async Task ALoreSong_RecordsHeardOldGodLoreSong() + { + var shop = new MusicShopLocation(); + var hero = Hero(); + var (term, _) = Terminal("\n\n"); + typeof(BaseLocation).GetField("terminal", F)!.SetValue(shop, term); + typeof(BaseLocation).GetField("currentPlayer", F)!.SetValue(shop, hero); + var play = typeof(MusicShopLocation).GetMethod("PlayLoreSong", F)!; + await (Task)play.Invoke(shop, new object[] { OldGodType.Maelketh, "t", "white", new[] { "v" } })!; + Ocean.ExperiencedMoments.Should().Contain(AwakeningMoment.HeardOldGodLoreSong); + Ocean.InsightIds.Should().Contain("song:Maelketh"); + } + + [Fact] + public void ReachingAcceptance_RecordsAcceptedGrief() + { + var grief = GriefSystem.Instance; + grief.Reset(); + try + { + grief.BeginNpcGrief("npc_grief_test", "Friend", DeathType.Combat); + for (int day = 1; day <= 6; day++) grief.UpdateGrief(day * 10000); + Ocean.ExperiencedMoments.Should().Contain(AwakeningMoment.AcceptedGrief); + } + finally { grief.Reset(); } + } + + [Fact] + public void FourMemories_RecordMemoriesRecovered() + { + var amnesia = AmnesiaSystem.Instance; + var saved = amnesia.Serialize(); + try + { + amnesia.Deserialize(new AmnesiaData()); + amnesia.RecoverMemory(MemoryFragment.TheFirstThought); + amnesia.RecoverMemory(MemoryFragment.CreatingLight); + amnesia.RecoverMemory(MemoryFragment.WatchingThem); + Ocean.ExperiencedMoments.Should().NotContain(AwakeningMoment.MemoriesRecovered); + amnesia.RecoverMemory(MemoryFragment.TheDecision); + Ocean.ExperiencedMoments.Should().Contain(AwakeningMoment.MemoriesRecovered); + amnesia.TruthRevealed.Should().BeFalse("the floor memories are not the full truth"); + } + finally { amnesia.Deserialize(saved); } + } + + // ---------- 3. the announcement ---------- + + private static void RaiseTo(int stage) + { + for (int i = 0; Ocean.AwakeningLevel < stage; i++) Ocean.GainInsight("rise" + i); + } + + [Fact] + public void ARise_IsQueued_NotShownAtOnce() + { + RaiseTo(1); + Ocean.PendingAnnouncementStage.Should().Be(1); + Ocean.PendingAnnouncementFromStage.Should().Be(0); + RaiseTo(2); + Ocean.PendingAnnouncementStage.Should().Be(2, "rises before the next safe point fold into one screen"); + Ocean.PendingAnnouncementFromStage.Should().Be(0); + } + + [Fact] + public async Task TheAnnouncement_ShowsOnce_WithStageLoreBoostAndWhatOpened() + { + RaiseTo(2); + var hero = Hero(); + var (term, output) = Terminal("\n\n\n"); + (await AwakeningScreens.ShowPending(term, hero)).Should().BeTrue(); + string shown = Shown(term, output); + shown.Should().Contain(Loc.Get("ocean.awakening_header")); + shown.Should().Contain(Loc.Get("ocean.awakening_stage", 2, 7, Loc.Get("base.awakening_aware"))); + shown.Should().Contain(Loc.Get("ocean.stage.2.lore.1")); + shown.Should().Contain(AwakeningBonus.GainedAt(1)).And.Contain(AwakeningBonus.GainedAt(2)); + shown.Should().Contain(Loc.Get("ocean.stage.2.opens")); + hero.HintsShown.Should().Contain(HintSystem.HINT_AWAKENING, "the first rise explains the awakening"); + + var (term2, _) = Terminal("\n\n"); + (await AwakeningScreens.ShowPending(term2, hero)).Should().BeFalse("shown once"); + } + + [Fact] + public void TheAnnouncement_WaitsForTheLocationLoop() + { + string loop = Source("Scripts/Locations/BaseLocation.cs"); + // inside the per-command loop, before the command runs: every dungeon room action comes back here + int loopStart = loop.IndexOf("while (!exitLocation && currentPlayer.IsAlive)"); + int show = loop.IndexOf("await AwakeningScreens.ShowPending(terminal, currentPlayer);"); + int process = loop.IndexOf("exitLocation = await ProcessChoice(choice);"); + loopStart.Should().BeGreaterThan(0); + show.Should().BeGreaterThan(loopStart).And.BeLessThan(process); + Source("Scripts/Locations/DungeonLocation.cs").Should().NotContain("override async Task LocationLoop"); + Source("Scripts/Systems/CombatEngine.cs").Should().NotContain("AwakeningScreens.ShowPending"); + Source("Scripts/Systems/DialogueSystem.cs").Should().NotContain("AwakeningScreens.ShowPending"); + } + + [Fact] + public void EveryStage_HasLore_AndWhatItOpens() + { + for (int stage = 1; stage <= 7; stage++) + { + for (int i = 1; i <= AwakeningScreens.LoreLinesPerStage; i++) + Loc.Get($"ocean.stage.{stage}.lore.{i}").Should().NotBe($"ocean.stage.{stage}.lore.{i}"); + Loc.Get($"ocean.stage.{stage}.opens").Should().NotBe($"ocean.stage.{stage}.opens"); + AwakeningBonus.GainedAt(stage).Should().NotBeNullOrEmpty(); + } + // only stages 6 and 7 speak of the Creator, and only 7 names him + for (int stage = 1; stage <= 6; stage++) + for (int i = 1; i <= AwakeningScreens.LoreLinesPerStage; i++) + Loc.Get($"ocean.stage.{stage}.lore.{i}").Should().NotContain("Manwe"); + Enumerable.Range(1, 4).Select(i => Loc.Get($"ocean.stage.7.lore.{i}")).Should().Contain(l => l.Contains("Manwe")); + } + + [Fact] + public void ANewCharacter_IsToldSomethingSleeps_AndTheStatusLinePointsToTheJournal() + { + Source("Scripts/Systems/OpeningStorySystem.cs").Should().Contain("opening_story.dream_asleep"); + Loc.Get("opening_story.dream_asleep").Should().NotContain("Manwe"); + string status = Source("Scripts/Locations/BaseLocation.cs"); + status.Should().Contain("base.stat_awakening_hint"); + status.Should().Contain("HintSystem.Instance.TryShowHint(HintSystem.HINT_AWAKENING, terminal, currentPlayer?.HintsShown)"); + } + + [Fact] + public void TheAwakeningHint_IsRegistered_AndShownOnce() + { + var hero = Hero(); + var (term, output) = Terminal(); + HintSystem.Instance.TryShowHint(HintSystem.HINT_AWAKENING, term, hero.HintsShown).Should().BeTrue(); + HintSystem.Instance.TryShowHint(HintSystem.HINT_AWAKENING, term, hero.HintsShown).Should().BeFalse(); + Shown(term, output).Should().Contain(Loc.Get("hint.awakening.title")); + } + + // ---------- 4. the Ocean Journal ---------- + + [Fact] + public void TheJournalSummary_UsesTheSevenStageScale() + { + Ocean.CollectFragment(WaveFragment.Origin); + Ocean.ExperienceMoment(AwakeningMoment.SparedAnEnemy); + var (term, output) = Terminal(); + AwakeningScreens.WriteJournalSummary(term); + string shown = Shown(term, output); + shown.Should().Contain(Loc.Get("ocean.journal_stage", 1, 7, Loc.Get("base.awakening_stirring"))); + shown.Should().Contain(Loc.Get("ocean.journal_points", 5, 12)); + shown.Should().Contain(Loc.Get("ocean.journal_fragments", 1, 10)); + shown.Should().Contain(Loc.Get("ocean.journal_moments", 1)); + shown.Should().NotContain("/5"); + } + + [Fact] + public async Task TheJournalPages_ShowEachFragmentsLore_AndTheMomentsLived() + { + Ocean.CollectFragment(WaveFragment.TheSevenDrops); + Ocean.ExperienceMoment(AwakeningMoment.SacrificedForAnother); + var (term, output) = Terminal("\n"); + await AwakeningScreens.ShowJournal(term); + string shown = Shown(term, output); + shown.Should().Contain(Loc.Get("ocean.fragment.TheSevenDrops.title")); + shown.Should().Contain(Loc.Get("ocean.fragment.TheSevenDrops.text").Split(' ').Take(6).Aggregate((a, b) => a + " " + b)); + shown.Should().NotContain(Loc.Get("ocean.fragment.Origin.title"), "only found fragments are readable"); + shown.Should().Contain(Loc.Get("ocean.journal_hidden", 9)); + shown.Should().Contain(Loc.Get("ocean.moment.SacrificedForAnother")); + } + + [Fact] + public void EveryFragmentAndMoment_HasJournalText() + { + foreach (WaveFragment f in Enum.GetValues(typeof(WaveFragment))) + { + Loc.Get($"ocean.fragment.{f}.title").Should().NotBe($"ocean.fragment.{f}.title"); + Loc.Get($"ocean.fragment.{f}.text").Should().NotBe($"ocean.fragment.{f}.text"); + } + foreach (AwakeningMoment m in Enum.GetValues(typeof(AwakeningMoment))) + Loc.Get($"ocean.moment.{m}").Should().NotBe($"ocean.moment.{m}"); + } + + [Fact] + public void TheFiveScale_IsGone() + { + string src = Source("Scripts/Locations/MainStreetLocation.cs"); + src.Should().NotContain("main_street.awakening_level").And.NotContain("main_street.awakening_0"); + src.Should().Contain("AwakeningScreens.WriteJournalSummary(terminal)").And.Contain("AwakeningScreens.ShowJournal(terminal)"); + string en = Source("Localization/en.json"); + en.Should().NotContain("\"main_street.awakening_level\"").And.NotContain("\"main_street.story_awakening_"); + en.Should().NotContain("\"main_street.awakening_0\"").And.NotContain("\"main_street.awakening_5\""); + } + + // ---------- 5. the boons ---------- + + private static Character Caster() => new() + { + Name1 = "tide", Name2 = "Tide", Class = CharacterClass.Magician, Level = 20, AI = CharacterAI.Human, + BaseStrength = 10, BaseDexterity = 10, BaseConstitution = 10, BaseIntelligence = 20, BaseWisdom = 20, + BaseCharisma = 10, BaseMaxHP = 400, BaseMaxMana = 200, BaseDefence = 5, BaseStamina = 10, BaseAgility = 10, + HP = 400, Mana = 200 + }; + + private static T AsPlayer(Character hero, Func body) + { + var engine = GameEngine.Instance; + var old = engine.CurrentPlayer; + engine.CurrentPlayer = hero; + try { return body(); } + finally { engine.CurrentPlayer = old; } + } + + private static async Task AsPlayerAsync(Character hero, Func body) + { + var engine = GameEngine.Instance; + var old = engine.CurrentPlayer; + engine.CurrentPlayer = hero; + try { await body(); } + finally { engine.CurrentPlayer = old; } + } + + private static void ToStage(int stage) + { + if (stage >= 7) Ocean.ExperienceMoment(AwakeningMoment.TrueIdentityRevealed); + else RaiseTo(stage); + Ocean.AwakeningLevel.Should().Be(stage); + } + + [Fact] + public void Stage1_AddsWisdom_AtRecalculation_NeverToTheBase() + { + var hero = Caster(); + AsPlayer(hero, () => + { + hero.RecalculateStats(); + long before = hero.Wisdom; + ToStage(1); + hero.RecalculateStats(); + hero.RecalculateStats(); + hero.Wisdom.Should().Be(before + 3); + hero.BaseWisdom.Should().Be(20, "nothing is stored on the character"); + return 0; + }); + } + + [Fact] + public void Stage2_AddsFivePercentMaxMana_Stage7Ten() + { + var hero = Caster(); + AsPlayer(hero, () => + { + ToStage(1); + hero.RecalculateStats(); + long m1 = hero.MaxMana; + m1.Should().BeGreaterThan(0); + ToStage(2); + hero.RecalculateStats(); + hero.MaxMana.Should().Be(m1 + (long)(m1 * 0.05)); + ToStage(7); + hero.RecalculateStats(); + hero.MaxMana.Should().Be(m1 + (long)(m1 * 0.10)); + return 0; + }); + } + + [Fact] + public void Stage4_AddsFivePercentMaxHP_AndSurvivesRecalculation() + { + var hero = Caster(); + AsPlayer(hero, () => + { + hero.RecalculateStats(); + long h0 = hero.MaxHP; + ToStage(4); + hero.RecalculateStats(); + hero.RecalculateStats(); + hero.MaxHP.Should().Be(h0 + (long)(h0 * 0.05)); + return 0; + }); + } + + [Fact] + public void Stage3_AddsFivePercentCombatXP_WhereTheTrainingBonusApplies() + { + var hero = Caster(); + AsPlayer(hero, () => + { + TeamHQBonus.ApplyXP(hero, 1000).Should().Be(1000); + ToStage(2); + TeamHQBonus.ApplyXP(hero, 1000).Should().Be(1000); + ToStage(3); + TeamHQBonus.ApplyXP(hero, 1000).Should().Be(1050); + ToStage(7); + TeamHQBonus.ApplyXP(hero, 1000).Should().Be(1100); + return 0; + }); + } + + [Fact] + public void Stage5_AddsThreePercentDamage_WhereTheArmoryApplies() + { + var hero = Caster(); + AsPlayer(hero, () => + { + ToStage(4); + TeamHQBonus.ApplyAttack(hero, 1000).Should().Be(1000); + ToStage(5); + TeamHQBonus.ApplyAttack(hero, 1000).Should().Be(1030); + ToStage(7); + TeamHQBonus.ApplyAttack(hero, 1000).Should().Be(1080); + return 0; + }); + } + + [Fact] + public void Stage6_TakesThreePercentLessDamage_WhereTheBarracksApplies() + { + var hero = Caster(); + AsPlayer(hero, () => + { + ToStage(5); + TeamHQBonus.ApplyDefense(hero, 1000).Should().Be(1000); + ToStage(6); + TeamHQBonus.ApplyDefense(hero, 1000).Should().Be(970); + ToStage(7); + TeamHQBonus.ApplyDefense(hero, 1000).Should().Be(920); + return 0; + }); + } + + [Fact] + public async Task ARise_RecalculatesAtTheSafePoint_NotAtOnce() + { + var hero = Caster(); + await AsPlayerAsync(hero, async () => + { + hero.RecalculateStats(); + long h0 = hero.MaxHP; + ToStage(4); + hero.MaxHP.Should().Be(h0, "a rise mid-fight leaves the stats alone"); + var (term, _) = Terminal("\n\n\n"); + await AwakeningScreens.ShowPending(term, hero); + hero.MaxHP.Should().Be(h0 + (long)(h0 * 0.05)); + }); + } + + [Fact] + public void OnlyTheSessionsPlayer_IsAwakened() + { + var hero = Caster(); + var companion = Caster(); + var npc = new NPC { ID = "npc_awake", Name1 = "N", Name2 = "N", Level = 10, BaseMaxHP = 100, BaseWisdom = 10 }; + AsPlayer(hero, () => + { + ToStage(7); + AwakeningBonus.StageOf(hero).Should().Be(7); + AwakeningBonus.StageOf(companion).Should().Be(0); + AwakeningBonus.StageOf(npc).Should().Be(0); + TeamHQBonus.ApplyAttack(companion, 1000).Should().Be(1000); + TeamHQBonus.ApplyDefense(npc, 1000).Should().Be(1000); + TeamHQBonus.ApplyXP(companion, 1000).Should().Be(1000); + return 0; + }); + } + + [Fact] + public void AGroupedFollower_KeepsItsOwnStage_InTheLeadersSession() + { + // v1.1.12: a follower's fights and rewards run in the leader's session, whose own stage is 0 + var follower = Caster(); + var leader = Caster(); leader.Name1 = "lead"; leader.Name2 = "Lead"; + var plain = Caster(); plain.Name1 = "plain"; plain.Name2 = "Plain"; + (long hp, long mana, long wis) own = AsPlayer(follower, () => + { + ToStage(7); // the follower's own session stamps the rise + follower.RecalculateStats(); + follower.HP = follower.MaxHP; follower.Mana = follower.MaxMana; + return (follower.MaxHP, follower.MaxMana, follower.Wisdom); + }); + follower.AwakeningStage.Should().Be(7); + Ocean.Reset(); // the leader's session: no awakening of its own + AsPlayer(leader, () => + { + plain.RecalculateStats(); + AwakeningBonus.StageOf(follower).Should().Be(7); + TeamHQBonus.ApplyAttack(follower, 1000).Should().Be(1080); + TeamHQBonus.ApplyDefense(follower, 1000).Should().Be(920); + TeamHQBonus.ApplyXP(follower, 1000).Should().Be(1100); + follower.RecalculateStats(); // equipping loot or levelling in the leader's session + follower.MaxHP.Should().Be(own.hp).And.BeGreaterThan(plain.MaxHP); + follower.MaxMana.Should().Be(own.mana).And.BeGreaterThan(plain.MaxMana); + follower.Wisdom.Should().Be(own.wis).And.Be(plain.Wisdom + AwakeningBonus.Wisdom1); + follower.HP.Should().Be(own.hp, "nothing clamped the pools"); + follower.Mana.Should().Be(own.mana); + AwakeningBonus.StageOf(plain).Should().Be(0, "an unstamped character still gets nothing"); + return 0; + }); + + var four = Caster(); four.Name1 = "four"; four.Name2 = "Four"; + Ocean.Reset(); + long fourHP = AsPlayer(four, () => { ToStage(4); four.RecalculateStats(); four.HP = four.MaxHP; return four.MaxHP; }); + Ocean.Reset(); + AsPlayer(leader, () => + { + TeamHQBonus.ApplyXP(four, 1000).Should().Be(1050); + four.RecalculateStats(); + four.MaxHP.Should().Be(fourHP); + four.HP.Should().Be(fourHP); + return 0; + }); + Source("Scripts/Locations/DungeonLocation.cs").Should().Contain("AwakeningBonus.Stamp(player);", "the follower is stamped as it joins"); + } + + [Fact] + public void ALoadedPlayer_GetsTheBoons_AndKeepsTheSavedHP() + { + var hero = Caster(); + AsPlayer(hero, () => + { + hero.RecalculateStats(); // the load recalculates before the story systems + long savedHP = hero.MaxHP + 10; // saved with the stage 4 bonus + hero.HP = hero.MaxHP; + Ocean.RestoreFromSave(Array.Empty(), Array.Empty(), null, savedLevel: 4); + AwakeningBonus.RecalculateAfterRestore(hero, savedHP, 0); + hero.MaxHP.Should().BeGreaterThanOrEqualTo(savedHP); + hero.HP.Should().Be(savedHP); + return 0; + }); + Source("Scripts/Core/GameEngine.cs").Split("AwakeningBonus.RecalculateAfterRestore(currentPlayer").Length.Should().Be(3, "the offline and the online load"); + } + + [Fact] + public void TheStatusScreen_AndTheJournal_ShowTheBoons() + { + Source("Scripts/Locations/BaseLocation.cs").Should().Contain("AwakeningBonus.ActiveAt(awakeningLevel)"); + var lines = AwakeningBonus.ActiveAt(7); + lines.Should().HaveCount(7); + lines.Should().Contain(Loc.Get("ocean.title_awakened_line")); + lines.Should().Contain(Loc.Get("ocean.boost.5", 8)); + AwakeningBonus.ActiveAt(0).Should().BeEmpty(); + } + + // ---------- 6. chapter 1 ---------- + + [Theory] + [InlineData("en", "The Drowning", "THE DROWNING")] + [InlineData("es", "El Ahogamiento", "EL AHOGAMIENTO")] + [InlineData("fr", "La Noyade", "LA NOYADE")] + [InlineData("hu", "A Fulladás", "A FULLADÁS")] + [InlineData("it", "L'Annegamento", "L'ANNEGAMENTO")] + public void Chapter1_IsTheDrowning_NotTheMetersName(string lang, string chapter, string banner) + { + var json = JsonDocument.Parse(Source($"Localization/{lang}.json")).RootElement; + json.GetProperty("dungeon.chapter_awakening").GetString().Should().StartWith(chapter); + json.GetProperty("opening_story.the_awakening").GetString().Should().Be(banner); + } + + [Fact] + public void Reset_ClearsInsights() + { + Ocean.GainInsight("x"); + Ocean.Reset(); + Ocean.InsightIds.Should().BeEmpty(); + Ocean.Points.Should().Be(0); + } + + // ---------- a duel defender's own awakening (v1.1.12 design change) ---------- + + private static PlayerData SaveOf(Character c) => new() + { + Name1 = c.Name1, Name2 = c.Name2, Level = c.Level, Class = c.Class, Race = c.Race, + MaxHP = c.BaseMaxHP, MaxMana = c.BaseMaxMana, + BaseStrength = c.BaseStrength, BaseDexterity = c.BaseDexterity, BaseConstitution = c.BaseConstitution, + BaseIntelligence = c.BaseIntelligence, BaseWisdom = c.BaseWisdom, BaseCharisma = c.BaseCharisma, + BaseMaxHP = c.BaseMaxHP, BaseMaxMana = c.BaseMaxMana, BaseDefence = c.BaseDefence, + BaseStamina = c.BaseStamina, BaseAgility = c.BaseAgility, + }; + + private static StorySystemsData StoryWith(int insights, int savedLevel = 0, params AwakeningMoment[] moments) => new() + { + AwakeningLevel = savedLevel, + OceanInsightIds = Enumerable.Range(0, insights).Select(i => "saved" + i).ToList(), + ExperiencedMoments = moments.Select(m => (int)m).ToList(), + }; + + [Fact] + public void AStage4Defender_UnderAStage0Attacker_FightsWithItsOwnStage() + { + var own = Caster(); + var (ownHP, ownMana, ownWis) = AsPlayer(own, () => { ToStage(4); own.RecalculateStats(); return (own.MaxHP, own.MaxMana, own.Wisdom); }); + Ocean.Reset(); + + var attacker = Caster(); attacker.Name1 = "atk"; attacker.Name2 = "Atk"; + AsPlayer(attacker, () => + { + var save = SaveOf(Caster()); + save.MaxHP = ownHP; save.MaxMana = ownMana; // the maxima its own session saved + var d = PlayerCharacterLoader.CreateFromSaveData(save, "Tide", story: StoryWith(34)); + d.AwakeningStage.Should().Be(4, "34 points, from its own saved insights"); + AwakeningBonus.StageOf(d).Should().Be(4); + d.MaxHP.Should().Be(ownHP, "what its own session computes at stage 4, the boon applied once"); + d.HP.Should().Be(ownHP); + d.MaxMana.Should().Be(ownMana); + d.Wisdom.Should().Be(ownWis); + TeamHQBonus.ApplyXP(d, 1000).Should().Be(1050); + TeamHQBonus.ApplyAttack(d, 1000).Should().Be(1000, "the damage boon starts at stage 5"); + + PlayerCharacterLoader.CreateFromSaveData(SaveOf(Caster()), "Tide", story: StoryWith(0, savedLevel: 4)) + .AwakeningStage.Should().Be(4, "the saved level is the floor"); + var seven = PlayerCharacterLoader.CreateFromSaveData(SaveOf(Caster()), "Tide", story: StoryWith(0, 0, AwakeningMoment.TrueIdentityRevealed)); + TeamHQBonus.ApplyAttack(seven, 1000).Should().Be(1080); + TeamHQBonus.ApplyDefense(seven, 1000).Should().Be(920); + + PlayerCharacterLoader.CreateFromSaveData(SaveOf(Caster()), "Tide", isEcho: true, story: StoryWith(34)) + .AwakeningStage.Should().Be(0, "an echo is only a copy"); + Ocean.AwakeningLevel.Should().Be(0, "the session's Ocean is not touched"); + AwakeningBonus.StageOf(attacker).Should().Be(0); + return 0; + }); + } + + [Fact] + public void AStage0Defender_UnderAStage7Attacker_GetsNothing() + { + var plain = Caster(); plain.Name1 = "plain"; plain.Name2 = "Plain"; + var attacker = Caster(); attacker.Name1 = "atk"; attacker.Name2 = "Atk"; + AsPlayer(attacker, () => + { + ToStage(7); + plain.RecalculateStats(); + var d = PlayerCharacterLoader.CreateFromSaveData(SaveOf(Caster()), "Tide", story: StoryWith(3)); + AwakeningBonus.StageOf(d).Should().Be(0); + d.MaxHP.Should().Be(plain.MaxHP); + d.MaxMana.Should().Be(plain.MaxMana); + TeamHQBonus.ApplyAttack(d, 1000).Should().Be(1000); + TeamHQBonus.ApplyDefense(d, 1000).Should().Be(1000); + PlayerCharacterLoader.CreateFromSaveData(SaveOf(Caster()), "Tide").AwakeningStage.Should().Be(0, "no story data, no stage"); + return 0; + }); + foreach (var f in new[] { "Scripts/Locations/ArenaLocation.cs", "Scripts/Locations/DormitoryLocation.cs", "Scripts/Locations/InnLocation.cs", + "Scripts/Systems/WorldSimulator.cs", "Scripts/Locations/CastleLocation.cs" }) + Source(f).Should().MatchRegex(@"StorySystems|story: kingStory", $"{f} passes the save's own story data"); + } + + [Fact] + public void AnUnstampedCharacter_ThatIsNotTheSessionsPlayer_StillReadsZero() + { + var attacker = Caster(); attacker.Name1 = "atk"; attacker.Name2 = "Atk"; + var other = Caster(); other.Name1 = "other"; other.Name2 = "Other"; + AsPlayer(attacker, () => + { + ToStage(7); + other.AwakeningStage.Should().Be(-1); + AwakeningBonus.StageOf(other).Should().Be(0, "the session's stage is never borrowed"); + return 0; + }); + } +} diff --git a/Tests/CharacterRecreationTests.cs b/Tests/CharacterRecreationTests.cs index d0fa581a..9edb4da2 100644 --- a/Tests/CharacterRecreationTests.cs +++ b/Tests/CharacterRecreationTests.cs @@ -555,6 +555,32 @@ private async System.Threading.Tasks.Task AsOnlineProcess(Func _db.PurgePlayerWorldState("bob_account", "Bob")); + Count("SELECT COUNT(*) FROM messages WHERE to_player = 'Bob Smith';").Should().Be(1); + Count("SELECT COUNT(*) FROM messages WHERE to_player = 'Bob';").Should().Be(0); + } + + [Fact] + public async Task MailToTheKey_IsKept_WhenAnotherCharacterGoesByThatName() + { + // v1.1.12: account "bob" deleting its character "Alice" erased the mail of another account's character "Bob" + Player("bob", "Alice"); + Exec("INSERT INTO players (username, display_name, player_data) VALUES ('robin', 'Robin', '{\"player\":{\"name2\":\"Bob\"}}');"); + await _db.SendMessage("System", "Bob", "mail", "for the other Bob"); + await _db.SendMessage("System", "Alice", "mail", "for the deleted Alice"); + await _db.SendMessage("bob", "Robin", "mail", "sent by the deleted key"); + WithCompleteRoster(() => _db.PurgePlayerWorldState("bob", "Alice")); + Count("SELECT COUNT(*) FROM messages WHERE to_player = 'Bob';").Should().Be(1, "another character's name2 is Bob"); + Count("SELECT COUNT(*) FROM messages WHERE to_player = 'Alice';").Should().Be(0); + Count("SELECT COUNT(*) FROM messages WHERE from_player = 'bob';").Should().Be(0); + } + + [Fact] + public async Task MailToTheKey_IsPurged_WhenNoOtherCharacterGoesByThatName() + { + Player("bob", "Alice"); + Player("robin", "Robin"); + await _db.SendMessage("System", "bob", "mail", "for the deleted key"); + WithCompleteRoster(() => _db.PurgePlayerWorldState("bob", "Alice")); + Count("SELECT COUNT(*) FROM messages WHERE LOWER(to_player) = 'bob';").Should().Be(0); + } + + [Fact] + public void NoTeamOrSiegeScreen_LeavesTheViewerOutByDisplayName() + { + // v1.1.12: a display name can be a teammate's too; the viewer is left out by save key + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Scripts"))) dir = dir.Parent; + foreach (var file in new[] { "TeamCornerLocation.cs", "CastleLocation.cs" }) + { + string src = File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", "Locations", file)); + System.Text.RegularExpressions.Regex.Matches(src, @"GetPlayerTeamMembers\([^)]*,").Count.Should().Be(0, file); + } + } + /// Runs the purge with a roster complete enough to rule out an NPC of the name (NPCSpawnSystem.IsCountPlausible). private static void WithCompleteRoster(Action run, params NPC[] members) { diff --git a/Tests/DungeonRestPersistence1112Tests.cs b/Tests/DungeonRestPersistence1112Tests.cs new file mode 100644 index 00000000..1e37c136 --- /dev/null +++ b/Tests/DungeonRestPersistence1112Tests.cs @@ -0,0 +1,171 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using UsurperRemake; +using UsurperRemake.Locations; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.12: the one rest per dungeon floor is kept in the floor's saved state, so it +/// survives a save and restore, and resets only when the floor is regenerated fresh. +/// +[Collection("SharedGameSingletons")] +public class DungeonRestPersistence1112Tests +{ + private const BindingFlags F = BindingFlags.NonPublic | BindingFlags.Instance; + private const int Floor = 1; + + private static readonly JsonSerializerOptions SaveJson = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + IncludeFields = true, + MaxDepth = 256 + }; + + private static Character Hero() => new() + { + Name1 = "rest", Name2 = "Rest", Class = CharacterClass.Warrior, Level = 8, HP = 100, MaxHP = 500, + AI = CharacterAI.Human + }; + + private static (DungeonLocation dungeon, TerminalEmulator term, MemoryStream output) Dungeon(Character hero) + { + var output = new MemoryStream(); + var term = new TerminalEmulator(new MemoryStream(Encoding.UTF8.GetBytes("\n\n\n\n\n\n")), output); + var dungeon = new DungeonLocation(); + typeof(BaseLocation).GetField("terminal", F)!.SetValue(dungeon, term); + typeof(BaseLocation).GetField("currentPlayer", F)!.SetValue(dungeon, hero); + typeof(DungeonLocation).GetField("currentDungeonLevel", F)!.SetValue(dungeon, Floor); + return (dungeon, term, output); + } + + private static string Shown(TerminalEmulator term, MemoryStream output) + { + term.StreamWriterInternal?.Flush(); + return Encoding.UTF8.GetString(output.ToArray()); + } + + /// Enter the floor the way the dungeon does: generate or restore, then take the rest flag from the result. + private static (bool wasRestored, bool didRespawn) EnterFloor(DungeonLocation dungeon, Character hero) + { + object result = typeof(DungeonLocation).GetMethod("GenerateOrRestoreFloor", F)!.Invoke(dungeon, new object[] { hero, Floor })!; + var t = result.GetType(); + typeof(DungeonLocation).GetField("currentFloor", F)!.SetValue(dungeon, t.GetField("Floor")!.GetValue(result)); + typeof(DungeonLocation).GetField("hasCampedThisFloor", F)!.SetValue(dungeon, (bool)t.GetField("RestedOnThisFloor")!.GetValue(result)!); + return ((bool)t.GetField("WasRestored")!.GetValue(result)!, (bool)t.GetField("DidRespawn")!.GetValue(result)!); + } + + private static async Task Sanctuary(DungeonLocation dungeon, TerminalEmulator term, MemoryStream output) + { + await (Task)typeof(DungeonLocation).GetMethod("RestSpotEncounter", F)!.Invoke(dungeon, null)!; + return Shown(term, output); + } + + /// The real save and restore of the floor states, through the save JSON. + private static Dictionary SaveAndRestore(Character hero) + { + var saved = (Dictionary)typeof(SaveSystem).GetMethod("SerializeDungeonFloorStates", F)! + .Invoke(SaveSystem.Instance, new object[] { hero })!; + string json = JsonSerializer.Serialize(saved, SaveJson); + var loaded = JsonSerializer.Deserialize>(json, SaveJson)!; + return (Dictionary)typeof(GameEngine).GetMethod("RestoreDungeonFloorStates", F)! + .Invoke(GameEngine.Instance, new object[] { loaded })!; + } + + [Fact] + public async Task ARestOnAFloor_SurvivesSaveAndRestore_AndTheSecondRestIsRefused() + { + var hero = Hero(); + var (dungeon, term, output) = Dungeon(hero); + EnterFloor(dungeon, hero).wasRestored.Should().BeFalse("the floor has no saved state yet"); + + string first = await Sanctuary(dungeon, term, output); + first.Should().Contain(Loc.Get("dungeon.sanctuary_camp")); + hero.DungeonFloorStates[Floor].RestedOnThisFloor.Should().BeTrue("the rest is written to the floor's saved state"); + + // log out and back in: a new character object and a new dungeon, from the save + var back = Hero(); + back.DungeonFloorStates = SaveAndRestore(hero); + var (dungeon2, term2, output2) = Dungeon(back); + var entry = EnterFloor(dungeon2, back); + entry.wasRestored.Should().BeTrue(); + entry.didRespawn.Should().BeFalse(); + + long hpBefore = back.HP; + string second = await Sanctuary(dungeon2, term2, output2); + second.Should().Contain(Loc.Get("dungeon.sanctuary_already_rested")); + second.Should().NotContain(Loc.Get("dungeon.sanctuary_camp")); + back.HP.Should().Be(hpBefore, "the second rest on the same floor is refused"); + } + + [Fact] + public async Task AFreshlyGeneratedFloor_AllowsARest() + { + var hero = Hero(); + var (dungeon, term, output) = Dungeon(hero); + EnterFloor(dungeon, hero).wasRestored.Should().BeFalse(); + + long hpBefore = hero.HP; + string shown = await Sanctuary(dungeon, term, output); + shown.Should().Contain(Loc.Get("dungeon.sanctuary_camp")); + hero.HP.Should().BeGreaterThan(hpBefore); + } + + [Fact] + public async Task ARespawnedFloor_AllowsARestAgain() + { + var hero = Hero(); + hero.DungeonFloorStates[Floor] = new DungeonFloorState + { + FloorLevel = Floor, + LastVisitedAt = DateTime.Now.AddHours(-(DungeonFloorState.RESPAWN_HOURS + 1)), + RestedOnThisFloor = true + }; + hero.DungeonFloorStates = SaveAndRestore(hero); + var (dungeon, term, output) = Dungeon(hero); + var entry = EnterFloor(dungeon, hero); + entry.wasRestored.Should().BeTrue(); + entry.didRespawn.Should().BeTrue(); + hero.DungeonFloorStates[Floor].RestedOnThisFloor.Should().BeFalse("a respawn regenerates the floor"); + + string shown = await Sanctuary(dungeon, term, output); + shown.Should().Contain(Loc.Get("dungeon.sanctuary_camp")); + } + + [Fact] + public void ARestoredFloorThatDidNotRespawn_KeepsTheRest() + { + var hero = Hero(); + hero.DungeonFloorStates[Floor] = new DungeonFloorState + { + FloorLevel = Floor, + LastVisitedAt = DateTime.Now.AddMinutes(-5), + RestedOnThisFloor = true + }; + var (dungeon, _, _) = Dungeon(hero); + EnterFloor(dungeon, hero).didRespawn.Should().BeFalse(); + ((bool)typeof(DungeonLocation).GetField("hasCampedThisFloor", F)!.GetValue(dungeon)!) + .Should().BeTrue("leaving the floor and coming back keeps the spent rest"); + } + + [Fact] + public void NoFloorEntry_ClearsTheRestWithoutTheSavedState() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "usurper-reloaded.csproj"))) + dir = dir.Parent; + dir.Should().NotBeNull(); + string src = File.ReadAllText(Path.Combine(dir!.FullName, "Scripts", "Locations", "DungeonLocation.cs")); + System.Text.RegularExpressions.Regex.IsMatch(src, @"(? +/// v1.1.12 playtest fixes: the world event bonus line, cursed item hints and tags, one rest per +/// floor, the lives hint, the ST bar for casters, the training and auto-combat texts, the world +/// events key on Main Street, and the gear comparison for dungeon finds. +/// +[Collection("SharedGameSingletons")] +public class PlaytestFixes1112Tests +{ + private const BindingFlags F = BindingFlags.NonPublic | BindingFlags.Instance; + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !(Directory.Exists(Path.Combine(dir.FullName, "Scripts")) && Directory.Exists(Path.Combine(dir.FullName, "Localization")))) + dir = dir.Parent; + dir.Should().NotBeNull("the test must run inside the repository"); + return dir!.FullName; + } + + private static string Source(string relative) => File.ReadAllText(Path.Combine(RepoRoot(), relative)); + + /// The body of the first method named , by brace matching. + private static string MethodBody(string relative, string method) + { + string src = Source(relative); + var m = Regex.Match(src, @"(?:private|internal|public|protected)[^\n;=]*\b" + Regex.Escape(method) + @"\s*\("); + m.Success.Should().BeTrue($"{method} must exist in {relative}"); + int open = src.IndexOf('{', m.Index); + int depth = 0; + for (int i = open; i < src.Length; i++) + { + if (src[i] == '{') depth++; + else if (src[i] == '}' && --depth == 0) return src.Substring(open, i - open + 1); + } + throw new InvalidOperationException("unbalanced braces"); + } + + private static (TerminalEmulator term, MemoryStream output) Terminal(string input = "") + { + var output = new MemoryStream(); + return (new TerminalEmulator(new MemoryStream(Encoding.UTF8.GetBytes(input)), output), output); + } + + private static string Shown(TerminalEmulator term, MemoryStream output) + { + term.StreamWriterInternal?.Flush(); + return Encoding.UTF8.GetString(output.ToArray()); + } + + private static Character Hero(CharacterClass cls = CharacterClass.Warrior) => new() + { + Name1 = "fix", Name2 = "Fix", Class = cls, Level = 8, HP = 500, MaxHP = 500, + AI = CharacterAI.Human + }; + + // ---------- 1. world event bonus line ---------- + + [Fact] + public void WorldEventShare_IsZero_AndNothingPrints_WithNoEventRunning() + { + WorldEventSystem.Instance.ClearAllEvents(); + // a level 8 kill: the early-game 2x and difficulty used to count as "world event" + long baseXp = 1000; + long boosted = (long)(baseXp * GameConfig.GetEarlyGameXPMultiplier(8)); + boosted.Should().BeGreaterThan(baseXp, "the early-game multiplier is what the old check picked up"); + long xp = WorldEventSystem.Instance.GetWorldEventXPBonus(baseXp); + long gold = WorldEventSystem.Instance.GetWorldEventGoldBonus(500); + (xp, gold).Should().Be((0L, 0L)); + + var (term, output) = Terminal(); + CombatEngine.ShowWorldEventBonus(term, xp, gold); + Shown(term, output).Should().BeEmpty(); + } + + [Fact] + public void WithAnEventRunning_TheLineShowsTheEventsShareOnly() + { + WorldEventSystem.Instance.ClearAllEvents(); + try + { + WorldEventSystem.Instance.ForceEvent(WorldEventSystem.EventType.KingWarDeclaration, 1); + long expected = (long)(1000 * WorldEventSystem.Instance.GlobalXPModifier) - 1000; + expected.Should().BeGreaterThan(0); + long xp = WorldEventSystem.Instance.GetWorldEventXPBonus(1000); + xp.Should().Be(expected); + + var (term, output) = Terminal(); + CombatEngine.ShowWorldEventBonus(term, xp, 0); + string shown = Shown(term, output); + shown.Should().Contain(Loc.Get("combat.world_event_xp", expected.ToString())); + shown.Should().NotContain(Loc.Get("combat.world_event_gold", "0")); + } + finally { WorldEventSystem.Instance.ClearAllEvents(); } + } + + [Fact] + public void EveryRewardPath_PrintsTheWorldEventLine_OnlyThroughTheHelper() + { + string src = Source("Scripts/Systems/CombatEngine.cs"); + Regex.Matches(src, "\"combat.world_event_xp\"").Count.Should().Be(1, "only ShowWorldEventBonus prints it"); + Regex.Matches(src, "\"combat.world_event_gold\"").Count.Should().Be(1); + Regex.Matches(src, @"ShowWorldEventBonus\(terminal, worldEventXP, worldEventGold\)").Count.Should().Be(3); + } + + // ---------- 2. cursed items ---------- + + [Fact] + public void TheCursedDropHint_NamesTheMagicShop() + { + Loc.Get("inventory.visit_healer_curse").Should().Contain("Magic Shop").And.NotContain("Healer"); + // the Healer only scans equipped gear; the Magic Shop scans the backpack + MethodBody("Scripts/Locations/MagicShopLocation.cs", "RemoveCurse").Should().Contain("player.Inventory.Where(i => i.IsCursed)"); + MethodBody("Scripts/Locations/HealerLocation.cs", "RemoveCursedItem").Should().NotContain("Inventory"); + } + + private static string RenderBackpack(Item item) + { + var hero = Hero(); + hero.Inventory.Add(item); + var (term, output) = Terminal(); + var inv = new InventorySystem(term, hero); + typeof(InventorySystem).GetMethod("DisplayBackpack", F)!.Invoke(inv, new object?[] { null }); + return Shown(term, output); + } + + [Fact] + public void TheBackpack_TagsAnIdentifiedCursedItem() + { + RenderBackpack(new Item { Name = "Grim Blade", Type = ObjType.Weapon, Attack = 20, Value = 100, IsCursed = true }) + .Should().Contain("Grim Blade").And.Contain(Loc.Get("shop.cursed_tag").Trim()); + RenderBackpack(new Item { Name = "Fair Blade", Type = ObjType.Weapon, Attack = 20, Value = 100 }) + .Should().NotContain(Loc.Get("shop.cursed_tag").Trim()); + } + + [Fact] + public void TheBackpack_DoesNotRevealTheCurseOfAnUnidentifiedItem() + { + RenderBackpack(new Item { Name = "Grim Blade", Type = ObjType.Weapon, Attack = 20, Value = 100, IsCursed = true, IsIdentified = false }) + .Should().NotContain(Loc.Get("shop.cursed_tag").Trim()); + } + + [Fact] + public void TheItemView_TagsACursedItem() + { + MethodBody("Scripts/Systems/InventorySystem.cs", "ManageBackpackItem").Should().Contain("shop.cursed_tag"); + } + + [Fact] + public async Task EquippingACursedItem_WarnsItCannotBeRemoved() + { + var hero = Hero(); + hero.Inventory.Add(new Item { Name = "Grim Plate", Type = ObjType.Body, Armor = 10, Value = 100, IsCursed = true }); + var (term, output) = Terminal("y\n\n"); + var inv = new InventorySystem(term, hero); + await (Task)typeof(InventorySystem).GetMethod("EquipFromBackpack", F)!.Invoke(inv, new object?[] { 0, null })!; + Shown(term, output).Should().Contain(Loc.Get("inventory.cursed_equip_warning")).And.Contain(Loc.Get("inventory.equip_confirm").Trim(), "asked into an empty slot"); + hero.GetEquipment(EquipmentSlot.Body)!.IsCursed.Should().BeTrue(); + hero.UnequipSlot(EquipmentSlot.Body).Should().BeNull("a cursed item cannot be removed, as the warning says"); + } + + [Fact] + public async Task ACursedItem_IntoAnEmptySlot_IsConfirmed_AndNoLeavesTheSlotEmpty() + { + var hero = Hero(); + hero.GetEquipment(EquipmentSlot.Body).Should().BeNull("the slot starts empty"); + hero.Inventory.Add(new Item { Name = "Grim Plate", Type = ObjType.Body, Armor = 10, Value = 100, IsCursed = true }); + var (term, output) = Terminal("n\n\n"); + var inv = new InventorySystem(term, hero); + await (Task)typeof(InventorySystem).GetMethod("EquipFromBackpack", F)!.Invoke(inv, new object?[] { 0, null })!; + string shown = Shown(term, output); + shown.Should().Contain(Loc.Get("inventory.equip_confirm").Trim()); + shown.Should().Contain(Loc.Get("ui.cancelled")); + hero.GetEquipment(EquipmentSlot.Body).Should().BeNull("declined"); + hero.Inventory.Should().ContainSingle(i => i.Name == "Grim Plate"); + } + + // ---------- 3. one rest per floor ---------- + + private static async Task SanctuaryWith(bool alreadyRested) + { + var hero = Hero(); + hero.HP = 100; + var dungeon = new DungeonLocation(); + var (term, output) = Terminal("\n\n\n\n"); + typeof(BaseLocation).GetField("terminal", F)!.SetValue(dungeon, term); + typeof(BaseLocation).GetField("currentPlayer", F)!.SetValue(dungeon, hero); + typeof(DungeonLocation).GetField("hasCampedThisFloor", F)!.SetValue(dungeon, alreadyRested); + await (Task)typeof(DungeonLocation).GetMethod("RestSpotEncounter", F)!.Invoke(dungeon, null)!; + return Shown(term, output); + } + + [Fact] + public async Task ASanctuaryAfterCamping_SaysWhyItRefuses() + { + string shown = await SanctuaryWith(alreadyRested: true); + shown.Should().Contain(Loc.Get("dungeon.sanctuary_already_rested")); + shown.Should().Contain(Loc.Get("dungeon.rest_once_per_floor")); + } + + [Fact] + public void TheFirstRest_AndTheCamp_BothStateTheRule() + { + MethodBody("Scripts/Locations/DungeonLocation.cs", "RestInRoom").Should().Contain("dungeon.rest_once_per_floor"); + Regex.Matches(MethodBody("Scripts/Locations/DungeonLocation.cs", "RestSpotEncounter"), "dungeon.rest_once_per_floor") + .Count.Should().Be(2, "once on the first rest, once in the refusal"); + } + + // ---------- 4. lives hint ---------- + + private static void SetOnline(bool on) => + typeof(UsurperRemake.BBS.DoorMode).GetField("_onlineMode", BindingFlags.NonPublic | BindingFlags.Static)!.SetValue(null, on); + + [Fact] + public void TheLivesHint_IsRegistered_AndShownOnce() + { + bool wasOnline = UsurperRemake.BBS.DoorMode.IsOnlineMode; + bool permadeath = GameConfig.OnlinePermadeathEnabled; + try + { + SetOnline(true); + GameConfig.OnlinePermadeathEnabled = true; + var hero = Hero(); + hero.Resurrections = 2; + hero.MaxResurrections = 3; + + var (term, output) = Terminal(); + HintSystem.Instance.TryShowLivesHint(hero, term).Should().BeTrue(); + HintSystem.Instance.TryShowLivesHint(hero, term).Should().BeFalse("once per character"); + hero.HintsShown.Should().Contain(HintSystem.HINT_LIVES); + string shown = Shown(term, output); + shown.Should().Contain(Loc.Get("hint.lives.title")); + shown.Should().Contain("2 of 3"); + } + finally + { + SetOnline(wasOnline); + GameConfig.OnlinePermadeathEnabled = permadeath; + } + } + + [Fact] + public void TheLivesHint_IsNotShown_WhereThereAreNoLives() + { + bool wasOnline = UsurperRemake.BBS.DoorMode.IsOnlineMode; + try + { + SetOnline(false); + var hero = Hero(); + var (term, _) = Terminal(); + HintSystem.Instance.TryShowLivesHint(hero, term).Should().BeFalse("single-player death never reads the lives counter"); + hero.HintsShown.Should().NotContain(HintSystem.HINT_LIVES); + } + finally { SetOnline(wasOnline); } + } + + [Fact] + public void TheLivesHint_IsOfferedOnDungeonEntry() + { + Source("Scripts/Locations/DungeonLocation.cs").Should().Contain("HintSystem.Instance.TryShowLivesHint(player, term)"); + } + + // ---------- 5. ST bar ---------- + + [Fact] + public void TheStaminaBar_ShowsForFighters_AndCastersWithAStaminaAbility_Only() + { + CombatEngine.ShowsStaminaBar(Hero(CharacterClass.Warrior)).Should().BeTrue(); + + var mage = Hero(CharacterClass.Magician); + mage.Quickbar = new List(new string?[9]) { [0] = "spell:1", [1] = "spell:2" }; + mage.CurrentCombatStamina = mage.MaxCombatStamina; + CombatEngine.ShowsStaminaBar(mage).Should().BeFalse("a caster's quickbar of spells never spends ST"); + mage.CurrentCombatStamina = mage.MaxCombatStamina - 15; + CombatEngine.ShowsStaminaBar(mage).Should().BeTrue("Power Attack is open to every class and spends ST"); + mage.CurrentCombatStamina = mage.MaxCombatStamina; + + ClassAbilitySystem.GetAbility("power_strike")!.StaminaCost.Should().BeGreaterThan(0); + mage.Quickbar[2] = "power_strike"; + CombatEngine.ShowsStaminaBar(mage).Should().BeTrue("a slotted stamina ability does spend ST"); + } + + [Fact] + public void TheStatusBar_DrawsST_OnlyThroughTheCheck() + { + string src = Source("Scripts/Systems/CombatEngine.cs"); + int st = src.IndexOf("terminal.Write($\" {Loc.Get(\"combat.bar_st\")}:\");", StringComparison.Ordinal); + st.Should().BeGreaterThan(0); + src.Substring(Math.Max(0, st - 400), 400).Should().Contain("if (ShowsStaminaBar(player))"); + } + + // ---------- 6 and 7. texts ---------- + + [Fact] + public void TheTrainingPointsHint_NamesTheLevelMastersKey() + { + Loc.Get("base.training_points_hint").Should().Contain("[V]").And.Contain("Main Street"); + MethodBody("Scripts/Locations/MainStreetLocation.cs", "ProcessChoice").Should().MatchRegex(@"case ""V"":\s*await NavigateToLocation\(GameLocation\.Master\)"); + } + + [Fact] + public void TheAutoCombatNotice_SaysWhatItDoes() + { + string text = Loc.Get("combat.auto_combat_on"); + text.Should().Contain("basic attack").And.Contain("healing potion").And.Contain("No spells"); + } + + // ---------- 8. world events key ---------- + + [Fact] + public void TheWorldEventsKey_IsDrawnInEveryMainStreetMenu() + { + const string file = "Scripts/Locations/MainStreetLocation.cs"; + MethodBody(file, "DisplayLocationBBS").Should().Contain("terminal.Write(\"$\")").And.Contain("main_street.menu_events_suffix"); + MethodBody(file, "ShowClassicMenu").Should().Contain("ML(\"$\", Loc.Get(\"menu.action.world_events\")"); + MethodBody(file, "ShowScreenReaderMenu").Should().Contain("$ - {Loc.Get(\"menu.action.world_events\")}"); + } + + // ---------- 9. comparison for dungeon finds ---------- + + private static Character HeroWithASword() + { + var hero = Hero(); + var sword = new Equipment { Name = "Old Sword", Slot = EquipmentSlot.MainHand, WeaponPower = 10, MinLevel = 1, Handedness = WeaponHandedness.OneHanded, WeaponType = WeaponType.Sword }; + hero.EquippedItems[EquipmentSlot.MainHand] = EquipmentDatabase.RegisterDynamic(sword); + return hero; + } + + [Fact] + public void AFoundWeapon_IsComparedWithTheEquippedOne() + { + var hero = HeroWithASword(); + var (term, output) = Terminal(); + DiscoverySystem.ShowFoundGear(term, hero, new Item { Name = "Vault Sword", Type = ObjType.Weapon, Attack = 25, Value = 500 }); + string shown = Shown(term, output); + shown.Should().Contain(Loc.Get("combat.currently_equipped", "Old Sword")); + shown.Should().Contain(Loc.Get("combat.compare_attack", 10, 25)); + shown.Should().NotContain(Loc.Get("combat.loot_cursed_warning")); + } + + [Fact] + public void ACursedFind_CarriesTheWarning_AndAPotionShowsNothing() + { + var hero = HeroWithASword(); + var (term, output) = Terminal(); + DiscoverySystem.ShowFoundGear(term, hero, new Item { Name = "Grim Helm", Type = ObjType.Head, Armor = 5, Value = 50, IsCursed = true }); + Shown(term, output).Should().Contain(Loc.Get("combat.loot_cursed_warning")); + + var (term2, output2) = Terminal(); + DiscoverySystem.ShowFoundGear(term2, hero, new Item { Name = "Tonic", Type = ObjType.Potion, Value = 5 }); + DiscoverySystem.ShowFoundGear(term2, hero, new Item { Name = "Hidden Sword", Type = ObjType.Weapon, Attack = 30, IsIdentified = false }); + Shown(term2, output2).Should().BeEmpty("a potion is not gear, and an unidentified find keeps its stats hidden"); + } + + [Fact] + public void GrantLoot_AndTheMerchant_UseTheSharedComparison() + { + MethodBody("Scripts/Systems/DiscoverySystem.cs", "GrantLoot").Should().Contain("ShowFoundGear(terminal, player, item)"); + var merchant = MethodBody("Scripts/Locations/DungeonLocation.cs", "PurchaseRareItem"); + merchant.Should().Contain("CombatEngine.ShowEquipmentComparison(terminal, item.LootItem, player)"); + merchant.Should().NotContain("merchant_current_weapon"); + } +} diff --git a/Tests/TeamCornerFixes1112Tests.cs b/Tests/TeamCornerFixes1112Tests.cs new file mode 100644 index 00000000..e42d0c37 --- /dev/null +++ b/Tests/TeamCornerFixes1112Tests.cs @@ -0,0 +1,1256 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using UsurperRemake; +using UsurperRemake.Locations; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.12 Team Corner audit fixes: the password a join checks, dissolving a team with its upgrades and +/// vault, keeping the protection while players remain, creating a team (insert first, trimmed, case), +/// team wars left active or with no rounds, the vault and upgrades, equipping, and the team size. +/// +[Collection("SharedGameSingletons")] +public class TeamCornerFixes1112Tests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"usurper-tcf-{Guid.NewGuid():N}.db"); + private readonly SqlSaveBackend _db; + + public TeamCornerFixes1112Tests() { _db = new SqlSaveBackend(_path); } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + try { File.Delete(_path); } catch { } + } + + private void Exec(string sql) => TeamCornerRig.Exec(_path, sql); + private object? Scalar(string sql) => TeamCornerRig.Scalar(_path, sql); + private long Long(string sql) => Convert.ToInt64(Scalar(sql) ?? 0L); + + private static string RepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null && !(Directory.Exists(Path.Combine(dir.FullName, "Scripts")) && Directory.Exists(Path.Combine(dir.FullName, "Localization")))) + dir = dir.Parent; + return dir!.FullName; + } + + private static string MethodBody(string method) + { + string src = File.ReadAllText(Path.Combine(RepoRoot(), "Scripts/Locations/TeamCornerLocation.cs")); + var m = Regex.Match(src, @"(?:private|internal|public|protected)[^\n;=]*\b" + Regex.Escape(method) + @"\s*\("); + m.Success.Should().BeTrue($"{method} must exist"); + int open = src.IndexOf('{', m.Index); + int depth = 0; + for (int i = open; i < src.Length; i++) + { + if (src[i] == '{') depth++; + else if (src[i] == '}' && --depth == 0) return src.Substring(open, i - open + 1); + } + throw new InvalidOperationException("unbalanced braces"); + } + + // ---------- 1. the team password ---------- + + [Fact] + public async Task PasswordChange_UpdatesTheHashAJoinChecks_AndOnlyForTheLeader() + { + await _db.CreatePlayerTeam("Keyholders", SqlSaveBackend.HashTeamPassword("old"), "boss"); + _db.ChangeTeamPassword("Keyholders", "someone", "old", "new").Should().BeFalse("only the leader"); + _db.ChangeTeamPassword("Keyholders", "boss", "wrong", "new").Should().BeFalse("the old password must match"); + _db.ChangeTeamPassword("Keyholders", "boss", "old", "new").Should().BeTrue(); + (await _db.VerifyPlayerTeam("Keyholders", "new")).passwordCorrect.Should().BeTrue(); + (await _db.VerifyPlayerTeam("Keyholders", "old")).passwordCorrect.Should().BeFalse(); + } + + [Fact] + public async Task PasswordScreen_ChangesTheStoredPassword_ForTheLeader_AndRefusesAMember() + { + await TeamCornerRig.Online(async (db, path) => + { + var hero = TeamCornerRig.Hero(name: "Pw Leader", team: "Lockers"); + await db.CreatePlayerTeam("Lockers", SqlSaveBackend.HashTeamPassword("old"), GameEngine.InheritanceKey(hero)); + var rig = new TeamCornerRig(hero, new[] { "old", "fresh" }); + await rig.Run("ChangeTeamPassword"); + (await db.VerifyPlayerTeam("Lockers", "fresh")).passwordCorrect.Should().BeTrue("a join checks the stored hash"); + (await db.VerifyPlayerTeam("Lockers", "old")).passwordCorrect.Should().BeFalse(); + + // a team led by someone else + await db.CreatePlayerTeam("Led Elsewhere", SqlSaveBackend.HashTeamPassword("theirs"), "someone_else"); + var member = TeamCornerRig.Hero(name: "Pw Member", team: "Led Elsewhere"); + var refused = new TeamCornerRig(member, new[] { "theirs", "mine" }); + string shown = await refused.Run("ChangeTeamPassword"); + shown.Should().Contain(Loc.Get("team.password_leader_only")); + (await db.VerifyPlayerTeam("Led Elsewhere", "theirs")).passwordCorrect.Should().BeTrue(); + }); + } + + // ---------- 2 and 3. quitting ---------- + + [Fact] + public async Task Quit_KeepsTheProtection_WhileAPlayerIsStillOnTheTeam() + { + await TeamCornerRig.Online(async (db, path) => + { + TeamCornerRig.PlayerRow(path, "tomas", "Stayers"); + await db.CreatePlayerTeam("Stayers", "x", "tomas"); + WorldSimulator.RegisterPlayerTeam("Stayers"); + try + { + var hero = TeamCornerRig.Hero(name: "Quit Hero", team: "Stayers"); + await new TeamCornerRig(hero, new[] { "y", "" }).Run("QuitTeam"); + hero.Team.Should().BeEmpty(); + WorldSimulator.IsPlayerTeam("Stayers").Should().BeTrue("a player is still on it"); + } + finally { WorldSimulator.UnregisterPlayerTeam("Stayers"); } + }); + } + + [Fact] + public async Task Quit_TheLastMember_DissolvesTheTeam_WithItsUpgradesAndVault() + { + await TeamCornerRig.Online(async (db, path) => + { + TeamCornerRig.Exec(path, "INSERT INTO player_teams (team_name, password_hash, created_by) VALUES ('Leavers', 'x', 'quit hero');"); + TeamCornerRig.Exec(path, "INSERT INTO team_upgrades (team_name, upgrade_type, level) VALUES ('Leavers', 'vault', 3);"); + TeamCornerRig.Exec(path, "INSERT INTO team_vault (team_name, gold) VALUES ('Leavers', 5000);"); + WorldSimulator.RegisterPlayerTeam("Leavers"); + var hero = TeamCornerRig.Hero(name: "Quit Hero", team: "Leavers"); + await new TeamCornerRig(hero, new[] { "y", "" }).Run("QuitTeam"); + WorldSimulator.IsPlayerTeam("Leavers").Should().BeFalse("no player is left"); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT COUNT(*) FROM player_teams WHERE team_name = 'Leavers'")).Should().Be(0); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT COUNT(*) FROM team_upgrades WHERE team_name = 'Leavers'")).Should().Be(0, "a later team of the name must not inherit them"); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT COUNT(*) FROM team_vault WHERE team_name = 'Leavers'")).Should().Be(0); + }); + } + + [Fact] + public async Task Quit_TheLastPlayer_KeepsTheTeam_WhileADeadNpcMemberWillRespawn() + { + await TeamCornerRig.Online(async (db, path) => + { + TeamCornerRig.Exec(path, "INSERT INTO player_teams (team_name, password_hash, created_by) VALUES ('Stayers', 'x', 'quit hero');"); + TeamCornerRig.Exec(path, "INSERT INTO team_upgrades (team_name, upgrade_type, level) VALUES ('Stayers', 'vault', 3);"); + TeamCornerRig.Exec(path, "INSERT INTO team_vault (team_name, gold) VALUES ('Stayers', 50000);"); + var npc = TeamCornerRig.Npc("tc_quit_dead_1", "Fallen Npc", "Stayers", dead: true); + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + try + { + var hero = TeamCornerRig.Hero(name: "Quit Hero", team: "Stayers"); + await new TeamCornerRig(hero, new[] { "y", "" }).Run("QuitTeam"); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT COUNT(*) FROM player_teams WHERE team_name = 'Stayers'")).Should().Be(1); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT gold FROM team_vault WHERE team_name = 'Stayers'")).Should().Be(50000, "the dead member respawns on the team"); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT level FROM team_upgrades WHERE team_name = 'Stayers'")).Should().Be(3); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); } + }); + } + + [Fact] + public async Task Quit_TheLastPlayer_DissolvesATeamWhoseOnlyNpcDiedOfAge() + { + // v1.1.12: death by age is permanent (IsAgedDeath without IsPermaDead), so it holds no place + await TeamCornerRig.Online(async (db, path) => + { + TeamCornerRig.Exec(path, "INSERT INTO player_teams (team_name, password_hash, created_by) VALUES ('Elders', 'x', 'quit hero');"); + WorldSimulator.RegisterPlayerTeam("Elders"); + var npc = TeamCornerRig.Npc("tc_quit_aged_1", "Aged Npc", "Elders", dead: true); + npc.IsAgedDeath = true; + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + try + { + var hero = TeamCornerRig.Hero(name: "Quit Hero", team: "Elders"); + await new TeamCornerRig(hero, new[] { "y", "" }).Run("QuitTeam"); + WorldSimulator.IsPlayerTeam("Elders").Should().BeFalse(); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT COUNT(*) FROM player_teams WHERE team_name = 'Elders'")).Should().Be(0); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); } + }); + } + + [Fact] + public async Task Quit_DuringARosterRebuild_KeepsTheTeam() + { + // v1.1.12: a roster being rebuilt can show no NPC members; the empty-team sweep removes a truly empty team later + await TeamCornerRig.Online(async (db, path) => + { + TeamCornerRig.Exec(path, "INSERT INTO player_teams (team_name, password_hash, created_by) VALUES ('Reloaders', 'x', 'quit hero');"); + TeamCornerRig.Exec(path, "INSERT INTO team_vault (team_name, gold) VALUES ('Reloaders', 50000);"); + bool was = NPCSpawnSystem.Instance.IsRebuilding; + NPCSpawnSystem.Instance.IsRebuilding = true; + try + { + var hero = TeamCornerRig.Hero(name: "Quit Hero", team: "Reloaders"); + await new TeamCornerRig(hero, new[] { "y", "" }).Run("QuitTeam"); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT gold FROM team_vault WHERE team_name = 'Reloaders'")).Should().Be(50000); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT COUNT(*) FROM player_teams WHERE team_name = 'Reloaders'")).Should().Be(1); + } + finally { NPCSpawnSystem.Instance.IsRebuilding = was; } + }); + } + + [Theory] + [InlineData("ConfirmAndRecruit", "var liveRecruit = NPCSpawnSystem.Instance.ActiveNPCs")] + [InlineData("ResurrectTeammate", "var live = LiveTeamNpc(toResurrect);")] + public void TheCapacityQuery_RunsBeforeTheLiveNpcLookup(string method, string lookup) + { + // v1.1.12: the query yields; a roster reload during it would leave an NPC looked up before it stale + string body = MethodBody(method); + int query = body.LastIndexOf("await TeamSlotsUsed(", StringComparison.Ordinal); + int live = body.IndexOf(lookup, StringComparison.Ordinal); + query.Should().BeGreaterThan(0); + live.Should().BeGreaterThan(query, "the NPC is looked up after the last await before it is changed"); + } + + [Fact] + public void Quit_SavesOnce() + { + string body = MethodBody("QuitTeam"); + body.Should().Contain("PersistTeamMembershipChange()"); + body.Should().NotContain("ResetAutoSaveThrottle").And.NotContain("AutoSave("); + } + + // ---------- 4. creating a team ---------- + + [Fact] + public async Task CreateInsert_IsGuarded_AndIgnoresCase_AndStampsTheJoin() + { + (await _db.CreatePlayerTeam("Ravens", "x", "a")).Should().BeTrue(); + (await _db.CreatePlayerTeam("Ravens", "x", "b")).Should().BeFalse("the name is taken"); + (await _db.CreatePlayerTeam("RAVENS", "x", "b")).Should().BeFalse("a name that differs only in case is taken"); + _db.IsTeamNameTaken("ravens").Should().BeTrue(); + Scalar("SELECT last_join_at FROM player_teams WHERE team_name = 'Ravens'").Should().NotBeNull(); + } + + [Fact] + public async Task ASleepersTeam_IsReadFromTheirSave_SoTheirNpcTeammatesSpareThem() + { + // v1.1.12: the world sim excludes the sleeper's teammates from the attackers by this name; the + // lookup read '$.Player.Team', which a real save never has, and always returned "" + await _db.WriteGameData("sleepy", new SaveGameData + { + Version = GameConfig.SaveVersion, + Player = new PlayerData { Name1 = "sleepy", Name2 = "Sleepy", Team = "Night Watch", Level = 12, HP = 90, MaxHP = 90 } + }); + _db.GetPlayerTeamName("sleepy").Should().Be("Night Watch"); + _db.GetPlayerTeamName("nobody").Should().BeEmpty(); + } + + [Fact] + public async Task ANewTeam_StartsWithoutAnOldTeamsLeftoverUpgradesAndVault() + { + // v1.1.12: 1.1.11's last-member dissolve left these under the name for the next team of that name + Exec("INSERT INTO team_upgrades (team_name, upgrade_type, level) VALUES ('Wolves', 'armory', 4);"); + Exec("INSERT INTO team_vault (team_name, gold) VALUES ('wolves', 9000);"); + (await _db.CreatePlayerTeam("Wolves", "x", "a")).Should().BeTrue(); + Long("SELECT COUNT(*) FROM team_upgrades WHERE team_name = 'Wolves'").Should().Be(0); + Long("SELECT COUNT(*) FROM team_vault WHERE LOWER(team_name) = 'wolves'").Should().Be(0); + + Exec("INSERT INTO team_upgrades (team_name, upgrade_type, level) VALUES ('Wolves', 'barracks', 2);"); + (await _db.CreatePlayerTeam("WOLVES", "x", "b")).Should().BeFalse("the name is taken"); + Long("SELECT COUNT(*) FROM team_upgrades WHERE team_name = 'Wolves'").Should().Be(1, "a refused create touches nothing"); + } + + [Fact] + public async Task Create_ANameTakenInAnotherCase_IsRefused_AndNothingIsCharged() + { + await TeamCornerRig.Online(async (db, path) => + { + await db.CreatePlayerTeam("Ravens", "x", "someone"); + var hero = TeamCornerRig.Hero(name: "Create Hero", gold: 50000); + string shown = await new TeamCornerRig(hero, new[] { " RAVENS ", "pw" }).Run("CreateTeam"); + shown.Should().Contain(Loc.Get("team.player_team_exists")); + hero.Gold.Should().Be(50000); + hero.Team.Should().BeEmpty(); + }); + } + + [Fact] + public async Task Create_ANameAnNpcTeamHasInAnotherCase_IsRefused_AndABlankNameToo() + { + var npc = TeamCornerRig.Npc("tc_create_1", "Crow Npc", "Night Crows"); + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + try + { + var hero = TeamCornerRig.Hero(name: "Create Hero", gold: 50000); + string shown = await new TeamCornerRig(hero, new[] { " night crows ", "pw" }).Run("CreateTeam"); + shown.Should().Contain(Loc.Get("team.team_name_exists")); + hero.Team.Should().BeEmpty(); + hero.Gold.Should().Be(50000); + + string blank = await new TeamCornerRig(hero, new[] { " ", "pw" }).Run("CreateTeam"); + blank.Should().Contain(Loc.Get("team.invalid_team_name")); + hero.Team.Should().BeEmpty(); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); } + } + + [Fact] + public async Task Create_Online_InsertsTheRowThenCharges() + { + await TeamCornerRig.Online(async (db, path) => + { + var hero = TeamCornerRig.Hero(name: "Create Hero", gold: 50000); + await new TeamCornerRig(hero, new[] { " Brand New ", "pw", "" }).Run("CreateTeam"); + hero.Team.Should().Be("Brand New", "the name is trimmed"); + hero.Gold.Should().BeLessThan(50000); + db.IsTeamNameTaken("Brand New").Should().BeTrue(); + WorldSimulator.UnregisterPlayerTeam("Brand New"); + }); + string body = MethodBody("CreateTeam"); + body.IndexOf("CreatePlayerTeam(").Should().BeLessThan(body.IndexOf("currentPlayer.Gold -= creationCost"), "the fee is taken only for a created row"); + } + + [Fact] + public async Task Create_RefusesAnAccentedCaseVariant_OnAFreshConnection() + { + // v1.1.12: SQLite LOWER() leaves accented letters alone; the guards fold case as C# does + (await _db.CreatePlayerTeam("\u00c9lite", "x", "a")).Should().BeTrue(); + SqliteConnection.ClearAllPools(); + var fresh = new SqlSaveBackend(_path); + fresh.IsTeamNameTaken("\u00e9lite").Should().BeTrue(); + (await fresh.CreatePlayerTeam("\u00e9lite", "x", "b")).Should().BeFalse("a name that differs only in case is taken"); + Long("SELECT COUNT(*) FROM player_teams WHERE team_name = '\u00e9lite'").Should().Be(0); + + // variants already in the table stay separate teams, each joined by its exact name + Exec("INSERT INTO player_teams (team_name, password_hash, created_by) VALUES ('\u00e9lite', '" + SqlSaveBackend.HashTeamPassword("low") + "', 'c');"); + Exec("UPDATE player_teams SET password_hash = '" + SqlSaveBackend.HashTeamPassword("up") + "' WHERE team_name = '\u00c9lite';"); + (await fresh.VerifyPlayerTeam("\u00e9lite", "low")).passwordCorrect.Should().BeTrue(); + (await fresh.VerifyPlayerTeam("\u00c9lite", "up")).passwordCorrect.Should().BeTrue(); + (await fresh.VerifyPlayerTeam("\u00c9lite", "low")).passwordCorrect.Should().BeFalse(); + } + + [Fact] + public void Rankings_KeepCaseVariants_AsSeparateTeams() + { + // v1.1.12: older data can hold "Grey Band" and "grey band"; each stays its own row, joined by its exact name + var a = TeamCornerRig.Npc("tc_rank_1", "Rank One", "Grey Band", level: 10); + var b = TeamCornerRig.Npc("tc_rank_2", "Rank Two", "grey band", level: 20); + var rows = TeamCornerLocation.BuildTeamRankings(new[] { a, b }, Array.Empty(), null); + rows.Select(r => r.TeamName).Should().BeEquivalentTo(new[] { "Grey Band", "grey band" }); + rows.Should().OnlyContain(r => r.MemberCount == 1); + } + + [Fact] + public async Task CaseVariants_BothListed_TheLowercasePlayerTeamJoins_AndAThirdIsRefused() + { + var npc = TeamCornerRig.Npc("tc_case_1", "Case Wolf", "Wolves"); + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + try + { + await TeamCornerRig.Online(async (db, path) => + { + // a player team from before the create guard, differing from the NPC team only in case + TeamCornerRig.Exec(path, $"INSERT INTO player_teams (team_name, password_hash, created_by) VALUES ('wolves', '{SqlSaveBackend.HashTeamPassword("howl")}', 'boss');"); + TeamCornerRig.PlayerRow(path, "boss", "wolves"); + var hero = TeamCornerRig.Hero(name: "Case Joiner"); + string shown = await new TeamCornerRig(hero, new[] { "wolves", "howl", "" }).Run("JoinTeam"); + shown.Should().Contain("Wolves").And.Contain("wolves"); + hero.Team.Should().Be("wolves", "the player team, chosen by its exact name"); + shown.Should().Contain(Loc.Get("team.joined_team", "wolves")); + WorldSimulator.UnregisterPlayerTeam("wolves"); + + var other = TeamCornerRig.Hero(name: "Case Founder", gold: 50000); + string refused = await new TeamCornerRig(other, new[] { "WOLVES", "pw" }).Run("CreateTeam"); + refused.Should().Contain(Loc.Get("team.team_name_exists")); + other.Team.Should().BeEmpty(); + other.Gold.Should().Be(50000); + }); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); } + } + + // ---------- 5. the live NPC ---------- + + [Fact] + public void LiveTeamNpc_FindsTheReloadedCopy_ByID() + { + var npc = TeamCornerRig.Npc("tc_live_1", "Live Npc", "Live Band"); + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + try + { + var copy = TeamCornerRig.Reload(npc); + TeamCornerLocation.LiveTeamNpc(npc).Should().BeSameAs(copy); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.RemoveAll(n => n.ID == "tc_live_1"); } + TeamCornerLocation.LiveTeamNpc(npc).Should().BeNull("gone from the live list"); + } + + [Fact] + public void Resurrect_SavesTheSharedState_AndSack_OffersTheGearFirst() + { + MethodBody("ResurrectTeammate").Should().Contain("SaveAllSharedState()"); + string sack = MethodBody("SackMember"); + sack.Should().Contain("team.sack_gear_warning").And.Contain("MoveEquipmentToPlayer(member, cursed)"); + sack.IndexOf("MoveEquipmentToPlayer(").Should().BeLessThan(sack.IndexOf("live.Team = \"\""), "the gear is offered before the NPC goes"); + } + + [Fact] + public void Sack_SavesTheGearAtOnce_AndStripsAReloadedCopy_SoItIsInOnePlace() + { + // v1.1.12: the move is followed by the save with nothing awaited between, before the pause and the report + string sack = MethodBody("SackMember"); + int move = sack.IndexOf("recovered = MoveEquipmentToPlayer("); + int save = sack.IndexOf("if (tookGear) await SaveRecoveredGear();"); + move.Should().BeGreaterThan(0); + save.Should().BeGreaterThan(move); + sack.Substring(move, save - move).Should().NotContain("await", "nothing awaited between the move and the save"); + save.Should().BeLessThan(sack.IndexOf("ReportEquipmentTaken(")); + sack.IndexOf("StripRecoveredGear(live, recovered)").Should().BeGreaterThan(sack.IndexOf("var live = LiveTeamNpc(member);")) + .And.BeLessThan(sack.IndexOf("live.Team = \"\"")); + + var sword = new Equipment { Name = "Sack Test Blade", Slot = EquipmentSlot.MainHand, WeaponPower = 12, Value = 100 }; + var helm = new Equipment { Name = "Sack Test Helm", Slot = EquipmentSlot.Head, ArmorClass = 4, Value = 100 }; + int swordId = EquipmentDatabase.RegisterDynamic(sword), helmId = EquipmentDatabase.RegisterDynamic(helm); + var npc = TeamCornerRig.Npc("tc_sack_gear_1", "Gear Npc", "Sack Band"); + npc.EquippedItems[EquipmentSlot.MainHand] = swordId; + npc.EquippedItems[EquipmentSlot.Head] = helmId; + var bystander = TeamCornerRig.Npc("tc_sack_gear_2", "Gear Npc", "Sack Band"); // same name, other ID + bystander.EquippedItems[EquipmentSlot.MainHand] = swordId; + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + NPCSpawnSystem.Instance.ActiveNPCs.Add(bystander); + try + { + var hero = TeamCornerRig.Hero(team: "Sack Band"); + var rig = new TeamCornerRig(hero, Array.Empty()); + var recovered = rig.Loc.MoveEquipmentToPlayer(npc, new System.Collections.Generic.List()); + recovered.Should().HaveCount(2); + + // the roster is rebuilt from a snapshot read before the save: the copy still wears the gear, the looted + // blade registered again under a new ID as the NPC restore does, the helm under its own + var copy = TeamCornerRig.Reload(npc); + int reloadedSwordId = EquipmentDatabase.RegisterDynamic(new Equipment { Name = "Sack Test Blade", Slot = EquipmentSlot.MainHand, WeaponPower = 12, Value = 100 }); + reloadedSwordId.Should().NotBe(swordId); + copy.EquippedItems[EquipmentSlot.MainHand] = reloadedSwordId; + copy.EquippedItems[EquipmentSlot.Head] = helmId; + + var live = TeamCornerLocation.LiveTeamNpc(npc); + live.Should().BeSameAs(copy); + TeamCornerLocation.StripRecoveredGear(live!, recovered).Should().Be(2); + + copy.EquippedItems.Should().BeEmpty("the reloaded copy no longer wears what was taken"); + hero.Inventory.Count(i => i.Name == "Sack Test Blade").Should().Be(1); + hero.Inventory.Count(i => i.Name == "Sack Test Helm").Should().Be(1); + NPCSpawnSystem.Instance.ActiveNPCs.Where(n => n.ID == "tc_sack_gear_1") + .Should().OnlyContain(n => n.EquippedItems.Count == 0); + bystander.EquippedItems[EquipmentSlot.MainHand].Should().Be(swordId, "matched by ID, never by name"); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.RemoveAll(n => n.ID is "tc_sack_gear_1" or "tc_sack_gear_2"); } + } + + // ---------- 6. team wars ---------- + + private int War(string challenger, string defender, int minutesAgo, int cWins, int dWins, string key = "bran", long wager = 500) + { + Exec($"INSERT INTO team_wars (challenger_team, defender_team, status, challenger_wins, defender_wins, gold_wagered, started_at, challenger_key) " + + $"VALUES ('{challenger}', '{defender}', 'active', {cWins}, {dWins}, {wager}, datetime('now', '-{minutesAgo} minutes'), '{key}');"); + return Convert.ToInt32(Scalar("SELECT MAX(id) FROM team_wars")); + } + + [Fact] + public void AStaleWar_IsAbandoned_AndNoLongerBlocks_AndAWarWithNoRoundIsRefundedOnce() + { + int id = War("Reds", "Blues", minutesAgo: 20, 0, 0); + _db.HasActiveTeamWar("Reds").Should().BeFalse("a war left by a lost session must not block for ever"); + _db.HasActiveTeamWar("Blues").Should().BeFalse(); + Scalar($"SELECT status FROM team_wars WHERE id = {id}").Should().Be("abandoned"); + Long("SELECT COUNT(*) FROM pending_gold_transfers WHERE recipient_username = 'bran' AND amount = 500").Should().Be(1); + _db.ExpireStaleTeamWars().Should().Be(0); + Long("SELECT COUNT(*) FROM pending_gold_transfers").Should().Be(1, "refunded once"); + } + + [Fact] + public void ADeletedPayersUnfinishedWar_IsNotRefunded_ToANewCharacterOnTheSameKey() + { + // v1.1.12: the wager belonged to the deleted character; a recreated one on the same key must not collect it + int id = War("Reds", "Blues", minutesAgo: 1, 0, 0, key: "bran", wager: 50000); + _db.PurgePlayerWorldState("bran"); + Exec($"UPDATE team_wars SET started_at = datetime('now', '-20 minutes') WHERE id = {id};"); + _db.ExpireStaleTeamWars().Should().Be(1); + Scalar($"SELECT status FROM team_wars WHERE id = {id}").Should().Be("abandoned"); + Long("SELECT COUNT(*) FROM pending_gold_transfers").Should().Be(0); + + War("Greens", "Golds", minutesAgo: 20, 0, 0, key: "tomas"); + _db.ExpireStaleTeamWars().Should().Be(1); + Long("SELECT COUNT(*) FROM pending_gold_transfers WHERE recipient_username = 'tomas'").Should().Be(1, "another payer's refund is untouched"); + } + + [Fact] + public void AStaleWarWithRounds_IsAbandoned_WithoutARefund_AndAFreshWarStillBlocks() + { + int played = War("Reds", "Blues", minutesAgo: 20, 1, 0); + War("Greens", "Golds", minutesAgo: 1, 0, 0); + _db.HasActiveTeamWar("Reds").Should().BeFalse(); + Scalar($"SELECT status FROM team_wars WHERE id = {played}").Should().Be("abandoned"); + Long("SELECT COUNT(*) FROM pending_gold_transfers").Should().Be(0, "leaving a war under way must not pay"); + _db.HasActiveTeamWar("Greens").Should().BeTrue("a war under way is not stale"); + } + + [Fact] + public async Task CreateTeamWar_IsGuarded_AndKeepsThePayer() + { + int first = await _db.CreateTeamWar("Reds", "Blues", 500, "Bran"); + first.Should().BeGreaterThan(0); + (await _db.CreateTeamWar("Blues", "Greens", 500, "tomas")).Should().Be(-1, "Blues is already at war"); + Scalar($"SELECT challenger_key FROM team_wars WHERE id = {first}").Should().Be("bran"); + } + + [Fact] + public async Task AWarWithNoRoundFought_IsAbandoned_TheWagerReturned_AndNoDailyWarUsed() + { + await TeamCornerRig.Online(async (db, path) => + { + await db.CreatePlayerTeam("Home Side", "x", "war hero"); + await db.CreatePlayerTeam("Away Side", "x", "ghost"); + // saves that cannot be loaded: every round is skipped + TeamCornerRig.PlayerRow(path, "mate", "Home Side", rawPlayer: "{\"team\":\"Home Side\",\"level\":\"bad\"}"); + TeamCornerRig.PlayerRow(path, "ghost", "Away Side", rawPlayer: "{\"team\":\"Away Side\",\"level\":\"bad\"}"); + var hero = TeamCornerRig.Hero(name: "War Hero", team: "Home Side", gold: 5000); + hero.Level = 1; + var rig = new TeamCornerRig(hero, new[] { "1", "y", "", "" }); + string shown = await rig.Run("ChallengeTeamWar", db); + shown.Should().Contain(Loc.Get("team.war_no_rounds", $"{1000:N0}")); + hero.Gold.Should().Be(5000); + hero.TeamWarsToday.Should().Be(0); + TeamCornerRig.Scalar(path, "SELECT status FROM team_wars").Should().Be("abandoned"); + // v1.1.12: the settled war is not refunded again by the stale cleanup + TeamCornerRig.Exec(path, "UPDATE team_wars SET started_at = datetime('now', '-20 minutes');"); + db.ExpireStaleTeamWars().Should().Be(0); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT COUNT(*) FROM pending_gold_transfers")).Should().Be(0, "refunded once, at the war"); + }); + } + + [Fact] + public async Task AWarWithNoRound_WhoseSettlementFails_IsNotRefundedAtOnce_AndTheCleanupRefundsItOnce() + { + await TeamCornerRig.Online(async (db, path) => + { + await db.CreatePlayerTeam("Home Side", "x", "war hero"); + await db.CreatePlayerTeam("Away Side", "x", "ghost"); + TeamCornerRig.PlayerRow(path, "mate", "Home Side", rawPlayer: "{\"team\":\"Home Side\",\"level\":\"bad\"}"); + TeamCornerRig.PlayerRow(path, "ghost", "Away Side", rawPlayer: "{\"team\":\"Away Side\",\"level\":\"bad\"}"); + // the settlement write fails (as a DB error would), leaving the war active + TeamCornerRig.Exec(path, "CREATE TRIGGER no_settle BEFORE UPDATE OF status ON team_wars WHEN NEW.status = 'abandoned' BEGIN SELECT RAISE(ABORT, 'test'); END;"); + var hero = TeamCornerRig.Hero(name: "War Hero", team: "Home Side", gold: 5000); + hero.Level = 1; + string shown = await new TeamCornerRig(hero, new[] { "1", "y", "", "" }).Run("ChallengeTeamWar", db); + shown.Should().Contain(Loc.Get("team.war_no_rounds_pending", $"{1000:N0}")); + hero.Gold.Should().Be(4000, "not refunded while the war is unsettled"); + TeamCornerRig.Scalar(path, "SELECT status FROM team_wars").Should().Be("active"); + + TeamCornerRig.Exec(path, "DROP TRIGGER no_settle; UPDATE team_wars SET started_at = datetime('now', '-20 minutes');"); + db.ExpireStaleTeamWars().Should().Be(1); + db.ExpireStaleTeamWars().Should().Be(0); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT COUNT(*) FROM pending_gold_transfers WHERE amount = 1000")).Should().Be(1, "one refund in all"); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT COUNT(*) FROM pending_gold_transfers")).Should().Be(1); + }); + } + + [Fact] + public async Task CompleteTeamWar_OnAWarNoLongerActive_DoesNotLand() + { + int id = War("Reds", "Blues", minutesAgo: 20, 0, 0); + _db.ExpireStaleTeamWars().Should().Be(1); + (await _db.CompleteTeamWar(id, "abandoned")).Should().BeFalse("the cleanup settled it first"); + (await _db.CompleteTeamWar(id, "challenger_won")).Should().BeFalse(); + Scalar($"SELECT status FROM team_wars WHERE id = {id}").Should().Be("abandoned"); + int live = War("Greens", "Golds", minutesAgo: 1, 1, 0); + (await _db.CompleteTeamWar(live, "challenger_won")).Should().BeTrue(); + (await _db.CompleteTeamWar(live, "abandoned")).Should().BeFalse("settled once"); + } + + [Fact] + public void TheWager_IsSavedBeforeTheWarRow_AndTheResultAfter() + { + string body = MethodBody("ChallengeTeamWar"); + int deduct = body.IndexOf("currentPlayer.Gold -= wager"); + int save = body.IndexOf("ForcePlayerSave()", deduct); + body.IndexOf("CreateTeamWar(").Should().BeGreaterThan(save).And.BeGreaterThan(0); + body.IndexOf("ForcePlayerSave()", body.IndexOf("currentPlayer.TeamWarsToday++")).Should().BeGreaterThan(0, "the daily count and gold are saved"); + } + + // ---------- 7. the vault and upgrades ---------- + + [Fact] + public void Upgrade_IsGuardedOnTheLevel_AndTheCap() + { + _db.TryUpgradeTeamFacility("Smiths", "armory", 0, 100, payFromVault: false).Should().BeTrue(); + _db.TryUpgradeTeamFacility("Smiths", "armory", 0, 100, payFromVault: false).Should().BeFalse("someone else raised it first"); + _db.TryUpgradeTeamFacility("Smiths", "armory", 1, 100, payFromVault: false).Should().BeTrue(); + _db.GetTeamUpgradeLevel("Smiths", "armory").Should().Be(2); + Exec("UPDATE team_upgrades SET level = 10 WHERE team_name = 'Smiths';"); + _db.TryUpgradeTeamFacility("Smiths", "armory", 10, 100, payFromVault: false).Should().BeFalse("the cap"); + _db.GetTeamUpgradeLevel("Smiths", "armory").Should().Be(10); + } + + [Fact] + public async Task Upgrade_PaidFromTheVault_IsOneTransaction() + { + (await _db.DepositToTeamVault("Smiths", 1000)).Should().BeTrue(); + _db.TryUpgradeTeamFacility("Smiths", "vault", 0, 5000, payFromVault: true).Should().BeFalse("the vault is short"); + (await _db.GetTeamVaultGold("Smiths")).Should().Be(1000); + _db.GetTeamUpgradeLevel("Smiths", "vault").Should().Be(0); + + _db.TryUpgradeTeamFacility("Smiths", "vault", 0, 400, payFromVault: true).Should().BeTrue(); + (await _db.GetTeamVaultGold("Smiths")).Should().Be(600); + _db.TryUpgradeTeamFacility("Smiths", "vault", 0, 400, payFromVault: true).Should().BeFalse("the level moved"); + (await _db.GetTeamVaultGold("Smiths")).Should().Be(600, "a failed upgrade takes nothing"); + } + + [Fact] + public async Task Deposit_EnforcesTheCapacity_InTheSql() + { + (await _db.DepositToTeamVault("Misers", 50000)).Should().BeTrue(); + (await _db.DepositToTeamVault("Misers", 1)).Should().BeFalse("the vault holds 50,000 at level 0"); + (await _db.GetTeamVaultGold("Misers")).Should().Be(50000); + _db.TryUpgradeTeamFacility("Misers", "vault", 0, 1, payFromVault: false).Should().BeTrue(); + (await _db.DepositToTeamVault("Misers", 50000)).Should().BeTrue("level 1 holds 100,000"); + (await _db.DepositToTeamVault("Newcomers", 60000)).Should().BeFalse("a first deposit is capped too"); + } + + [Fact] + public async Task VaultDeposit_AndWithdraw_SaveThePlayerAtOnce_AndAWithdrawIsConfirmed() + { + await TeamCornerRig.Online(async (db, path) => + { + var hero = TeamCornerRig.Hero(name: "Vault Hero", team: "Savers", gold: 1000); + string key = UsurperRemake.BBS.DoorMode.GetPlayerName().ToLowerInvariant(); // the save key AutoSave uses here + await new TeamCornerRig(hero, new[] { "600" }).Run("DepositToVault", db, "Savers"); + (await db.GetTeamVaultGold("Savers")).Should().Be(600); + hero.Gold.Should().Be(400); + Convert.ToInt64(TeamCornerRig.Scalar(path, $"SELECT json_extract(player_data, '$.player.gold') FROM players WHERE username = '{key}'")).Should().Be(400, "the gold left the player on disk too"); + + await new TeamCornerRig(hero, new[] { "100", "n" }).Run("WithdrawFromVault", db, "Savers"); + (await db.GetTeamVaultGold("Savers")).Should().Be(600, "the withdraw was declined"); + + await new TeamCornerRig(hero, new[] { "100", "y" }).Run("WithdrawFromVault", db, "Savers"); + (await db.GetTeamVaultGold("Savers")).Should().Be(500); + Convert.ToInt64(TeamCornerRig.Scalar(path, $"SELECT json_extract(player_data, '$.player.gold') FROM players WHERE username = '{key}'")).Should().Be(500); + }); + } + + // ---------- 8. equipping ---------- + + [Fact] + public void Equip_NothingTakenFromThePlayer_MeansNothingEquipped() + { + var hero = TeamCornerRig.Hero(); + var rig = new TeamCornerRig(hero, Array.Empty()); + var item = new Equipment { Name = "Phantom Blade", WeaponPower = 5 }; + rig.Loc.TakeFromPlayerForEquip(item, wasEquipped: false, sourceSlot: null).Should().BeFalse(); + hero.Inventory.Add(new Item { Name = "Phantom Blade", Attack = 5 }); + rig.Loc.TakeFromPlayerForEquip(item, wasEquipped: false, sourceSlot: null).Should().BeTrue(); + hero.Inventory.Should().BeEmpty(); + + string body = MethodBody("EquipItemToCharacter"); + body.IndexOf("if (!TakeFromPlayerForEquip(").Should().BeGreaterThan(0).And.BeLessThan(body.IndexOf("target.EquipItem(")); + } + + // ---------- 9. team size ---------- + + [Fact] + public void Slots_CountTheDead_ButNotTheGoneForGood() + { + var npcs = new[] + { + TeamCornerRig.Npc("s1", "S1", "Sizers"), + TeamCornerRig.Npc("s2", "S2", "Sizers", dead: true), + new NPC { ID = "s3", Name2 = "S3", Team = "Sizers", IsPermaDead = true }, + TeamCornerRig.Npc("s4", "S4", "Others"), + }; + TeamCornerLocation.CountTeamSlots(npcs, "Sizers", playerMembers: 2).Should().Be(4); + } + + [Fact] + public async Task Recruit_ATeamWhoseDeadFillIt_IsFull() + { + var npcs = new[] + { + TeamCornerRig.Npc("tc_full_1", "Full One", "Full Crew"), + TeamCornerRig.Npc("tc_full_2", "Full Two", "Full Crew"), + TeamCornerRig.Npc("tc_full_3", "Full Three", "Full Crew"), + TeamCornerRig.Npc("tc_full_4", "Full Four", "Full Crew", dead: true), + }; + NPCSpawnSystem.Instance.ActiveNPCs.AddRange(npcs); + try + { + string shown = await new TeamCornerRig(TeamCornerRig.Hero(team: "Full Crew"), new[] { "" }).Run("RecruitNPCToTeam"); + shown.Should().Contain(Loc.Get("team.team_full", 5), "the dead member holds a slot"); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.RemoveAll(n => n.ID.StartsWith("tc_full_")); } + } + + [Fact] + public async Task Join_AFullTeam_IsRefused() + { + var npcs = Enumerable.Range(1, 5).Select(i => TeamCornerRig.Npc($"tc_jfull_{i}", $"Packed {i}", "Packed House", dead: i == 5)).ToList(); + NPCSpawnSystem.Instance.ActiveNPCs.AddRange(npcs); + try + { + var hero = TeamCornerRig.Hero(); + string shown = await new TeamCornerRig(hero, new[] { "packed house", "pw", "" }).Run("JoinTeam"); + shown.Should().Contain(Loc.Get("team.join_team_full", "Packed House", 5)); + hero.Team.Should().BeEmpty(); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.RemoveAll(n => n.ID.StartsWith("tc_jfull_")); } + } + + [Fact] + public async Task Join_AFullTeam_WithAMemberOfTheJoinersDisplayName_IsRefused() + { + // v1.1.12: the joiner was left out of the count by display name, which also dropped a member of that name + await TeamCornerRig.Online(async (db, path) => + { + await db.CreatePlayerTeam("Smithy", "pw", "bsmith"); + TeamCornerRig.Exec(path, "INSERT INTO players (username, display_name, player_data) VALUES ('bsmith', 'Bob Smith', " + + "'{\"player\":{\"name2\":\"Bob Smith\",\"team\":\"Smithy\",\"level\":12,\"class\":0}}');"); + var npcs = Enumerable.Range(1, 4).Select(i => TeamCornerRig.Npc($"tc_jname_{i}", $"Smithy {i}", "Smithy")).ToList(); + NPCSpawnSystem.Instance.ActiveNPCs.AddRange(npcs); + try + { + var hero = new Character { Name1 = "bob", Name2 = "Bob", FamilySurname = "Smith", Class = CharacterClass.Warrior, Level = 20, HP = 300, MaxHP = 300, Gold = 10000 }; + hero.DisplayName.Should().Be("Bob Smith"); + string shown = await new TeamCornerRig(hero, new[] { "smithy", "pw", "" }).Run("JoinTeam"); + shown.Should().Contain(Loc.Get("team.join_team_full", "Smithy", 5)); + hero.Team.Should().BeEmpty(); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.RemoveAll(n => n.ID.StartsWith("tc_jname_")); } + }); + } + + [Fact] + public async Task Join_ATeamThatFillsDuringThePassword_IsRefused() + { + var npcs = Enumerable.Range(1, 4).Select(i => TeamCornerRig.Npc($"tc_jlate_{i}", $"Late {i}", "Late House")).ToList(); + NPCSpawnSystem.Instance.ActiveNPCs.AddRange(npcs); + try + { + var hero = TeamCornerRig.Hero(); + // line 1 is the password; a fifth member joins while it is asked + var rig = new TeamCornerRig(hero, new[] { "late house", "pw", "" }, + i => { if (i == 1) NPCSpawnSystem.Instance.ActiveNPCs.Add(TeamCornerRig.Npc("tc_jlate_5", "Late 5", "Late House")); }); + string shown = await rig.Run("JoinTeam"); + shown.Should().Contain(Loc.Get("team.join_team_full", "Late House", 5)); + shown.Should().NotContain(Loc.Get("team.joined_team", "Late House")); + hero.Team.Should().BeEmpty(); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.RemoveAll(n => n.ID.StartsWith("tc_jlate_")); } + } + + [Fact] + public async Task Join_APlayerTeamThatFillsDuringThePassword_IsRefused() + { + var npcs = Enumerable.Range(1, 4).Select(i => TeamCornerRig.Npc($"tc_jlp_{i}", $"Lodge {i}", "Late Lodge")).ToList(); + NPCSpawnSystem.Instance.ActiveNPCs.AddRange(npcs); + try + { + await TeamCornerRig.Online(async (db, path) => + { + (await db.CreatePlayerTeam("Late Lodge", SqlSaveBackend.HashTeamPassword("secret"), "boss")).Should().BeTrue(); + var hero = TeamCornerRig.Hero(); + // a player joins the team while the password is asked + var rig = new TeamCornerRig(hero, new[] { "late lodge", "secret", "" }, + i => { if (i == 1) TeamCornerRig.PlayerRow(path, "tomas", "Late Lodge"); }); + string shown = await rig.Run("JoinTeam"); + shown.Should().Contain(Loc.Get("team.join_team_full", "Late Lodge", 5)); + hero.Team.Should().BeEmpty(); + }); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.RemoveAll(n => n.ID.StartsWith("tc_jlp_")); } + } + + [Fact] + public async Task Sack_AnNpcThatLeftTheTeamAtTheGearPrompt_KeepsItsGear() + { + int shield = EquipmentDatabase.GetShields().First().Id; + var npc = TeamCornerRig.Npc("tc_sgear_1", "Geared Npc", "Gear Crew"); + npc.EquippedItems[EquipmentSlot.OffHand] = shield; + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + try + { + var hero = TeamCornerRig.Hero(team: "Gear Crew"); + int packBefore = hero.Inventory.Count; + NPC? live = null; + // line 2 answers the take-gear prompt; before it the NPC is reloaded onto another team + var rig = new TeamCornerRig(hero, new[] { "geared", "y", "y", "" }, i => + { + if (i != 2) return; + live = TeamCornerRig.Reload(npc); + live.EquippedItems[EquipmentSlot.OffHand] = shield; + live.Team = "Other Crew"; + }); + string shown = await rig.Run("SackMember"); + live.Should().NotBeNull(); + shown.Should().Contain(Loc.Get("team.sack_gear_gone", "Geared Npc")); + live!.GetEquipment(EquipmentSlot.OffHand).Should().NotBeNull("nothing is taken from an NPC off the team"); + hero.Inventory.Count.Should().Be(packBefore); + live.Team.Should().Be("Other Crew"); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.RemoveAll(n => n.ID == "tc_sgear_1"); } + } + + [Fact] + public async Task Resurrect_ATeamWithFiveLiving_CannotReviveASixth() + { + var npcs = Enumerable.Range(1, 4).Select(i => TeamCornerRig.Npc($"tc_rfull_{i}", $"Living {i}", "Crowded")).ToList(); + var dead = TeamCornerRig.Npc("tc_rfull_dead", "Fallen One", "Crowded", level: 1, dead: true); + npcs.Add(dead); + NPCSpawnSystem.Instance.ActiveNPCs.AddRange(npcs); + try + { + var hero = TeamCornerRig.Hero(team: "Crowded", gold: 10000); + string shown = await new TeamCornerRig(hero, new[] { "fallen", "y", "" }).Run("ResurrectTeammate"); + shown.Should().Contain(Loc.Get("team.team_full", 5)); + dead.IsAlive.Should().BeFalse(); + hero.Gold.Should().Be(10000); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.RemoveAll(n => n.ID.StartsWith("tc_rfull_")); } + } + + // ---------- the menus ---------- + + [Fact] + public void ViewInventories_IsOnTheScreenReaderAndBbsMenus() + { + MethodBody("DisplayLocationSR").Should().Contain("WriteSRMenuOption(\"V\""); + MethodBody("DisplayLocationBBS").Should().Contain("(\"V\", \"bright_yellow\""); + } + + // ---------- second review ---------- + + /// A one-round war the challenger always wins: a strong mate against a weak defender. + private static async Task<(Character hero, string shown)> WinAWar(SqlSaveBackend db, string path, params string[] triggers) + { + await db.CreatePlayerTeam("Home Side", "x", "war hero"); + await db.CreatePlayerTeam("Away Side", "x", "ghost"); + await db.WriteGameData("mate", new SaveGameData { Version = GameConfig.SaveVersion, + Player = new PlayerData { Name1 = "mate", Name2 = "Mate", Team = "Home Side", Level = 100, Strength = 1000, BaseStrength = 1000, HP = 90, MaxHP = 90 } }); + await db.WriteGameData("ghost", new SaveGameData { Version = GameConfig.SaveVersion, + Player = new PlayerData { Name1 = "ghost", Name2 = "Ghost", Team = "Away Side", Level = 1, Strength = 1, BaseStrength = 1, HP = 90, MaxHP = 90 } }); + foreach (var t in triggers) TeamCornerRig.Exec(path, t); + var hero = TeamCornerRig.Hero(name: "War Hero", team: "Home Side", gold: 5000); + hero.Level = 1; + string shown = await new TeamCornerRig(hero, new[] { "1", "y", "", "" }).Run("ChallengeTeamWar", db); + return (hero, shown); + } + + [Fact] + public async Task AWonWar_WhoseScoreAndSettlementFail_PaysNothing_AndTheCleanupRefundsTheWagerOnce() + { + await TeamCornerRig.Online(async (db, path) => + { + var (hero, shown) = await WinAWar(db, path, + "CREATE TRIGGER no_score BEFORE UPDATE OF challenger_wins, defender_wins ON team_wars BEGIN SELECT RAISE(ABORT, 'test'); END;", + "CREATE TRIGGER no_result BEFORE UPDATE OF status ON team_wars WHEN NEW.status IN ('challenger_won', 'defender_won') BEGIN SELECT RAISE(ABORT, 'test'); END;"); + shown.Should().Contain(Loc.Get("team.war_result_pending", $"{1000:N0}")); + hero.Gold.Should().Be(4000, "no spoils while the war is unsettled"); + hero.TeamWarsToday.Should().Be(0); + TeamCornerRig.Scalar(path, "SELECT status FROM team_wars").Should().Be("active"); + + TeamCornerRig.Exec(path, "DROP TRIGGER no_score; DROP TRIGGER no_result; UPDATE team_wars SET started_at = datetime('now', '-20 minutes');"); + db.ExpireStaleTeamWars().Should().Be(1); + db.ExpireStaleTeamWars().Should().Be(0); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT COUNT(*) FROM pending_gold_transfers WHERE amount = 1000")).Should().Be(1, "the wager, once"); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT COUNT(*) FROM pending_gold_transfers")).Should().Be(1); + }); + } + + [Fact] + public async Task AWonWar_TheCleanupClosedFirst_PaysNothing_AndSaysSo() + { + await TeamCornerRig.Online(async (db, path) => + { + // the stale sweep flips the war to abandoned between its round and its settlement + var (hero, shown) = await WinAWar(db, path, + "CREATE TRIGGER sweep AFTER UPDATE OF challenger_wins ON team_wars BEGIN UPDATE team_wars SET status = 'abandoned', finished_at = datetime('now') WHERE id = NEW.id; END;"); + shown.Should().Contain(Loc.Get("team.war_already_closed")); + hero.Gold.Should().Be(4000, "the sweep closed it; the settlement pays zero"); + TeamCornerRig.Scalar(path, "SELECT status FROM team_wars").Should().Be("abandoned"); + Convert.ToInt64(TeamCornerRig.Scalar(path, "SELECT COUNT(*) FROM pending_gold_transfers")).Should().Be(0); + }); + } + + [Fact] + public async Task AnAltsWarRefund_IsDeliveredToTheAlt_NotToTheMain() + { + var saved = UsurperRemake.Server.SessionContext.Current; + try + { + void Playing(string key) => UsurperRemake.Server.SessionContext.Current = new UsurperRemake.Server.SessionContext + { InputStream = Stream.Null, OutputStream = Stream.Null, Username = "rage", CharacterKey = key }; + var term = new TerminalEmulator(new MemoryStream(), new MemoryStream()); + + Playing("rage__alt"); + var alt = new Character { Name1 = "rage__alt", Name2 = "Rager" }; + int id = await _db.CreateTeamWar("Reds", "Blues", 700, GameEngine.GoldTransferKey(alt)); + id.Should().BeGreaterThan(0); + Exec($"UPDATE team_wars SET started_at = datetime('now', '-20 minutes') WHERE id = {id};"); + _db.ExpireStaleTeamWars().Should().Be(1); + + Playing("rage"); + var main = new Character { Name1 = "rage", Name2 = "Rage" }; + (await GameEngine.DeliverPendingGoldTransfers(main, term, _db)).Should().Be(0, "the main did not pay"); + main.BankGold.Should().Be(0); + + Playing("rage__alt"); + (await GameEngine.DeliverPendingGoldTransfers(alt, term, _db)).Should().Be(700); + alt.BankGold.Should().Be(700); + Long("SELECT COUNT(*) FROM pending_gold_transfers").Should().Be(0); + } + finally { UsurperRemake.Server.SessionContext.Current = saved; } + } + + [Fact] + public async Task PasswordScreen_OnAnNpcFoundedTeam_ChangesTheNpcHeldPassword() + { + var npc = TeamCornerRig.Npc("tc_pw_npc_1", "Old Guard Npc", "Old Guard"); + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + try + { + await TeamCornerRig.Online(async (db, path) => + { + // no player_teams row: joined from the NPCs + var hero = TeamCornerRig.Hero(name: "Npc Team Leader", team: "Old Guard"); + hero.TeamPW = "pw"; + string wrong = await new TeamCornerRig(hero, new[] { "nope", "x" }).Run("ChangeTeamPassword"); + wrong.Should().Contain(Loc.Get("team.wrong_password_short")); + npc.TeamPW.Should().Be("pw"); + + string shown = await new TeamCornerRig(hero, new[] { "pw", "fresh" }).Run("ChangeTeamPassword"); + shown.Should().Contain(Loc.Get("team.password_changed")).And.NotContain(Loc.Get("team.password_leader_only")); + npc.TeamPW.Should().Be("fresh", "a join checks the NPC-held password"); + hero.TeamPW.Should().Be("fresh"); + + // a player team still needs its leader and its stored hash + await db.CreatePlayerTeam("Row Team", SqlSaveBackend.HashTeamPassword("theirs"), "someone_else"); + var member = TeamCornerRig.Hero(name: "Row Member", team: "Row Team"); + (await new TeamCornerRig(member, new[] { "theirs", "mine" }).Run("ChangeTeamPassword")) + .Should().Contain(Loc.Get("team.password_leader_only")); + (await db.VerifyPlayerTeam("Row Team", "theirs")).passwordCorrect.Should().BeTrue(); + }); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); } + } + + [Fact] + public async Task Examine_APlayer_ShowsTheSavedHpManaAndAge() + { + var npc = TeamCornerRig.Npc("tc_exam_hp_1", "Hurt Band Npc", "Hurt Band"); + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + try + { + await TeamCornerRig.Online(async (db, path) => + { + await db.WriteGameData("tomas", new SaveGameData + { + Version = GameConfig.SaveVersion, + Player = new PlayerData { Name1 = "tomas", Name2 = "Tomas", Team = "Hurt Band", Class = CharacterClass.Magician, Level = 12, Strength = 55, BaseStrength = 55, + Intelligence = 60, BaseIntelligence = 60, Wisdom = 60, BaseWisdom = 60, + HP = 20, MaxHP = 90, BaseMaxHP = 90, Mana = 5, MaxMana = 50, BaseMaxMana = 50, Age = 33 } + }); + var hero = TeamCornerRig.Hero(team: "Hurt Band"); + string shown = await new TeamCornerRig(hero, new[] { "tomas", "" }).Run("ExamineMember"); + shown.Should().Contain($"{Loc.Get("combat.bar_hp")}: 20/", "the injured player's HP, not full"); + shown.Should().Contain($"{Loc.Get("ui.mana_label")}: 5/"); + shown.Should().Contain($"{Loc.Get("team.examine_age")}: 33"); + }); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); } + } + + [Fact] + public async Task Examine_AnAwakenedPlayer_ShowsTheSavedPoolsAndMaxima() + { + // v1.1.12: saved at stage 4 with its HP boon; the loader recalculates without it + var npc = TeamCornerRig.Npc("tc_exam_aw_1", "Tide Band Npc", "Tide Band"); + NPCSpawnSystem.Instance.ActiveNPCs.Add(npc); + try + { + await TeamCornerRig.Online(async (db, path) => + { + await db.WriteGameData("marin", new SaveGameData + { + Version = GameConfig.SaveVersion, + Player = new PlayerData { Name1 = "marin", Name2 = "Marin", Team = "Tide Band", Class = CharacterClass.Magician, Level = 30, + Constitution = 10, BaseConstitution = 10, Intelligence = 10, BaseIntelligence = 10, Wisdom = 10, BaseWisdom = 10, + HP = 1030, MaxHP = 1050, BaseMaxHP = 1000, Mana = 520, MaxMana = 525, BaseMaxMana = 500, Age = 40 }, + StorySystems = new StorySystemsData { AwakeningLevel = 4 } + }); + var hero = TeamCornerRig.Hero(team: "Tide Band"); + string shown = await new TeamCornerRig(hero, new[] { "marin", "" }).Run("ExamineMember"); + shown.Should().Contain($"{Loc.Get("combat.bar_hp")}: 1030/1050"); + shown.Should().Contain($"{Loc.Get("ui.mana_label")}: 520/525"); + }); + } + finally { NPCSpawnSystem.Instance.ActiveNPCs.Remove(npc); } + } + + // ---------- third review ---------- + + [Fact] + public async Task GearSaves_TakeSavesTheSharedStateFirst_GiveSavesThePlayerFirst() + { + // v1.1.12: a crash between the two saves loses gear rather than copying it + var order = new System.Collections.Generic.List(); + BaseLocation.GearSaveHookForTests = step => { order.Add(step); return Task.CompletedTask; }; + try + { + var rig = new TeamCornerRig(TeamCornerRig.Hero(team: "Gear Band"), Array.Empty()); + await rig.Loc.SaveGearTakenFromNpc(null); + order.Should().Equal("shared", "player"); + order.Clear(); + await rig.Loc.SaveGearGivenToNpc(null); + order.Should().Equal("player", "shared"); + } + finally { BaseLocation.GearSaveHookForTests = null; } + } + + [Fact] + public void EveryEquipMenuMove_IsSavedAtOnce_InTheOrderForItsDirection() + { + // take all and unequip: saved NPC side first, straight after the move + string takeAll = MethodBody("TakeAllEquipment"); + int move = takeAll.IndexOf("MoveEquipmentToPlayer(target, cursedItems)"); + int save = takeAll.IndexOf("await SaveGearTakenFromNpc(target)"); + move.Should().BeGreaterThan(0); + save.Should().BeGreaterThan(move).And.BeLessThan(takeAll.IndexOf("ReportEquipmentTaken(")); + takeAll.Substring(move, save - move).Should().NotContain("await "); + string unequip = MethodBody("UnequipItemFromCharacter"); + move = unequip.IndexOf("currentPlayer.Inventory.Add(legacyItem)"); + save = unequip.IndexOf("await SaveGearTakenFromNpc(target)"); + move.Should().BeGreaterThan(0); + save.Should().BeGreaterThan(move); + unequip.Substring(move, save - move).Should().NotContain("await "); + File.ReadAllText(Path.Combine(RepoRoot(), "Scripts/Locations/TeamCornerLocation.cs")) + .Should().Contain("private Task SaveRecoveredGear() => SaveGearTakenFromNpc(null);", "a sack saves the same way"); + + // equip: the give is saved first (player side first), then the displaced items come back and are saved as a take + string equip = MethodBody("EquipItemToCharacter"); + int give = equip.IndexOf("await SaveGearGivenToNpc(target)"); + int back = equip.IndexOf("currentPlayer.Inventory.Add(displaced)"); + int take = equip.IndexOf("await SaveGearTakenFromNpc(target)"); + give.Should().BeGreaterThan(equip.IndexOf("target.EquipItem(")); + back.Should().BeGreaterThan(give); + take.Should().BeGreaterThan(back); + + // the menu no longer saves once at the end, in one order for moves both ways + string menu = MethodBody("EquipMember"); + menu.Should().NotContain("ForcePlayerSave").And.NotContain("SaveAllSharedState").And.NotContain("AutoSave"); + + // equip best (BaseLocation): the same two steps + string src = File.ReadAllText(Path.Combine(RepoRoot(), "Scripts/Locations/BaseLocation.cs")); + int start = src.IndexOf("protected async Task RunEquipBestGear("); + string best = src.Substring(start, src.IndexOf("protected static int ScoreEquipment(") - start); + give = best.IndexOf("await SaveGearGivenToNpc(target)"); + back = best.IndexOf("currentPlayer.Inventory.Add(displaced)"); + take = best.IndexOf("await SaveGearTakenFromNpc(target)"); + give.Should().BeGreaterThan(0); + back.Should().BeGreaterThan(give); + take.Should().BeGreaterThan(back); + best.Should().NotContain("SaveAllSharedState()").And.NotContain("AutoSave("); + } + + [Fact] + public async Task ARecruitedEcho_IsStoredAndLoadedByItsSaveKey_NeverAsTheViewersOwnSave() + { + // v1.1.12: account "robin" plays "Alice"; a teammate on account "sam" is named "Robin". The echo list kept + // "Robin", and the dungeon read that as a username: the viewer's own save. + var saved = UsurperRemake.Server.SessionContext.Current; + var partyBefore = GameEngine.Instance.DungeonPartyPlayerNames.ToList(); + try + { + await TeamCornerRig.Online(async (db, path) => + { + await db.CreatePlayerTeam("Echo Band", "x", "robin"); + await db.WriteGameData("robin", new SaveGameData { Version = GameConfig.SaveVersion, + Player = new PlayerData { Name1 = "robin", Name2 = "Alice", Team = "Echo Band", Level = 7, HP = 50, MaxHP = 50 } }); + await db.WriteGameData("sam", new SaveGameData { Version = GameConfig.SaveVersion, + Player = new PlayerData { Name1 = "sam", Name2 = "Robin", Team = "Echo Band", Level = 9, HP = 60, MaxHP = 60 } }); + UsurperRemake.Server.SessionContext.Current = new UsurperRemake.Server.SessionContext + { InputStream = Stream.Null, OutputStream = Stream.Null, Username = "robin", CharacterKey = "robin" }; + GameEngine.Instance.SetDungeonPartyPlayers(Array.Empty()); + + var hero = TeamCornerRig.Hero(name: "Alice", team: "Echo Band"); + await new TeamCornerRig(hero, new[] { "1", "" }).Run("RecruitPlayerAlly"); + GameEngine.Instance.DungeonPartyPlayerNames.Should().Equal(new[] { "sam" }, "the teammate's save key is stored"); + + var (echo, key) = await DungeonLocation.LoadEchoSave(db, "sam", "robin"); + echo!.Player.Name2.Should().Be("Robin"); + key.Should().Be("sam"); + // an entry from an older recruit list holds the display name; it never loads the viewer's own save + var (legacy, _) = await DungeonLocation.LoadEchoSave(db, "Robin", "robin"); + legacy.Should().BeNull(); + var (self, _) = await DungeonLocation.LoadEchoSave(db, "robin", "robin"); + self.Should().BeNull(); + // an older entry, a display name that is no username, still loads through the name + var (old, oldKey) = await DungeonLocation.LoadEchoSave(db, "Alice", "someone_else"); + old!.Player.Name2.Should().Be("Alice"); + oldKey.Should().Be("robin"); + }); + } + finally + { + UsurperRemake.Server.SessionContext.Current = saved; + GameEngine.Instance.SetDungeonPartyPlayers(partyBefore); + } + } + + // ---------- thirteenth review ---------- + + private static void SetViewer(string key) => + UsurperRemake.Server.SessionContext.Current = new UsurperRemake.Server.SessionContext + { InputStream = Stream.Null, OutputStream = Stream.Null, Username = key, CharacterKey = key }; + + private static Task WriteSave(SqlSaveBackend db, string key, string name, int level) => + db.WriteGameData(key, new SaveGameData { Version = GameConfig.SaveVersion, + Player = new PlayerData { Name1 = key, Name2 = name, Team = "Echo Band", Level = level, HP = 50, MaxHP = 50 } }); + + [Fact] + public async Task AnEchoKey_EqualToAnNpcsName_StillLoads_AndStaysRecruited() + { + // v1.1.12: account "robin" plays "Alice"; an NPC "Robin" in the party must not stand in for her echo + var saved = UsurperRemake.Server.SessionContext.Current; + var partyBefore = GameEngine.Instance.DungeonPartyPlayerNames.ToList(); + try + { + await TeamCornerRig.Online(async (db, path) => + { + await db.CreatePlayerTeam("Echo Band", "x", "carl"); + await WriteSave(db, "robin", "Alice", 9); + await WriteSave(db, "sam", "Robin", 7); + SetViewer("carl"); + GameEngine.Instance.SetDungeonPartyPlayers(new[] { "robin" }); + + var dungeon = new DungeonLocation(); + var hero = TeamCornerRig.Hero(name: "Carl", team: "Echo Band"); + typeof(BaseLocation).GetField("currentPlayer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .SetValue(dungeon, hero); + dungeon.teammates.Clear(); + dungeon.teammates.Add(TeamCornerRig.Npc("npc-robin", "Robin", "Echo Band")); + var term = new TeamCornerRig(hero, Array.Empty()).Term; + var restore = typeof(DungeonLocation).GetMethod("RestorePlayerTeammates", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + await (Task)restore.Invoke(dungeon, new object[] { term })!; + + var echo = dungeon.teammates.Single(t => t.IsEcho); + echo.DisplayName.Should().Be("Alice"); + echo.EchoSaveKey.Should().Be("robin"); + GameEngine.Instance.DungeonPartyPlayerNames.Should().Equal(new[] { "robin" }, "she stays recruited"); + + // a second pass, or an older entry naming her, does not add her twice + GameEngine.Instance.SetDungeonPartyPlayers(new[] { "robin", "Alice" }); + await (Task)restore.Invoke(dungeon, new object[] { term })!; + dungeon.teammates.Count(t => t.IsEcho).Should().Be(1); + + // Team Corner shows her as recruited, and the teammate named "Robin" can still be recruited + GameEngine.Instance.SetDungeonPartyPlayers(new[] { "robin" }); + var rig = new TeamCornerRig(hero, new[] { "2", "" }); + string shown = await rig.Run("RecruitPlayerAlly"); + shown.Should().MatchRegex(@"Alice[^\n]*" + Regex.Escape(Loc.Get("team.echo_recruited_tag"))); + GameEngine.Instance.DungeonPartyPlayerNames.Should().Equal(new[] { "robin", "sam" }); + }); + } + finally + { + UsurperRemake.Server.SessionContext.Current = saved; + GameEngine.Instance.SetDungeonPartyPlayers(partyBefore); + } + } + + [Fact] + public async Task AnOlderNameEntry_BesideAnNpcOfThatName_StillLoadsTheEcho() + { + // v1.1.12: an older entry "Robin" names a player; an NPC "Robin" in the party is not that echo + var saved = UsurperRemake.Server.SessionContext.Current; + var partyBefore = GameEngine.Instance.DungeonPartyPlayerNames.ToList(); + try + { + await TeamCornerRig.Online(async (db, path) => + { + await db.CreatePlayerTeam("Echo Band", "x", "carl"); + await WriteSave(db, "sam", "Robin", 7); // the entry is a display name, not this key + SetViewer("carl"); + GameEngine.Instance.SetDungeonPartyPlayers(new[] { "Robin" }); + + var dungeon = new DungeonLocation(); + var hero = TeamCornerRig.Hero(name: "Carl", team: "Echo Band"); + typeof(BaseLocation).GetField("currentPlayer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! + .SetValue(dungeon, hero); + dungeon.teammates.Clear(); + dungeon.teammates.Add(TeamCornerRig.Npc("npc-robin-2", "Robin", "Echo Band")); + var term = new TeamCornerRig(hero, Array.Empty()).Term; + var restore = typeof(DungeonLocation).GetMethod("RestorePlayerTeammates", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + await (Task)restore.Invoke(dungeon, new object[] { term })!; + + dungeon.teammates.Count(t => t.IsEcho).Should().Be(1, "the NPC Robin is not the recruited player's echo"); + dungeon.teammates.Single(t => t.IsEcho).EchoSaveKey.Should().Be("sam"); + }); + } + finally + { + UsurperRemake.Server.SessionContext.Current = saved; + GameEngine.Instance.SetDungeonPartyPlayers(partyBefore); + } + } + + [Fact] + public void RecruitList_MatchesAKeyToItsMemberOnly_AndAnOlderNameByDisplayName() + { + var keys = new[] { "robin", "sam", "carl" }; + var alice = new PlayerSummary { Username = "robin", DisplayName = "Alice" }; + var robin = new PlayerSummary { Username = "sam", DisplayName = "Robin" }; + TeamCornerLocation.IsEchoRecruited(new[] { "robin" }, keys, alice).Should().BeTrue(); + TeamCornerLocation.IsEchoRecruited(new[] { "robin" }, keys, robin).Should().BeFalse("\"robin\" is Alice's key, not a name"); + TeamCornerLocation.IsEchoRecruited(new[] { "Robin" }, new[] { "sam", "carl" }, robin).Should().BeTrue("an older entry is a name"); + } + + [Fact] + public async Task EquipBest_OffersDisplacedGearToTheRemainingSlots_AndNeverHoldsAnItemOnBothSides() + { + // v1.1.12: worn +10 and +2 Wisdom rings, a +20 in the pack: one pass gives +20 and +10, the +2 comes back + Equipment Ring(int wis) + { + var eq = new Equipment { Name = $"Test Wisdom Ring +{wis}", Slot = EquipmentSlot.LFinger, WisdomBonus = wis, MinLevel = 1 }; + EquipmentDatabase.RegisterDynamic(eq); + return eq; + } + var npc = TeamCornerRig.Npc("npc-ringer", "Ringer", ""); + npc.EquipItem(Ring(10), EquipmentSlot.LFinger, out _).Should().BeTrue(); + npc.EquipItem(Ring(2), EquipmentSlot.RFinger, out _).Should().BeTrue(); + var hero = TeamCornerRig.Hero(); + hero.Inventory.Add(new Item { Name = "Test Wisdom Ring +20", Type = ObjType.Fingers, Wisdom = 20, IsIdentified = true }); + + var overlaps = new System.Collections.Generic.List(); + int saves = 0; + BaseLocation.GearSaveHookForTests = step => + { + saves++; + var mine = hero.Inventory.Select(i => i.Name).ToList(); + var theirs = npc.Inventory.Select(i => i.Name) + .Concat(new[] { EquipmentSlot.LFinger, EquipmentSlot.RFinger }.Select(s => npc.GetEquipment(s)?.Name ?? "")).ToList(); + overlaps.AddRange(mine.Intersect(theirs)); + return Task.CompletedTask; + }; + try + { + await new TeamCornerRig(hero, new[] { "Y" }).Run("RunEquipBestGear", npc); + } + finally { BaseLocation.GearSaveHookForTests = null; } + + npc.GetEquipment(EquipmentSlot.LFinger)!.WisdomBonus.Should().Be(20); + npc.GetEquipment(EquipmentSlot.RFinger)!.WisdomBonus.Should().Be(10); + npc.Inventory.Should().BeEmpty(); + hero.Inventory.Select(i => i.Name).Should().Equal("Test Wisdom Ring +2"); + saves.Should().Be(4, "the give, then the take"); + overlaps.Should().BeEmpty(); + } +} diff --git a/Tests/TeamCornerPickerTests.cs b/Tests/TeamCornerPickerTests.cs new file mode 100644 index 00000000..52b9b06b --- /dev/null +++ b/Tests/TeamCornerPickerTests.cs @@ -0,0 +1,443 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using UsurperRemake; +using UsurperRemake.Locations; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.12: a scripted input stream that hands the terminal one line per read, and runs a hook before +/// serving a given line, so a test can change the world between two prompts (a world_state reload). +/// +internal sealed class LineStream : Stream +{ + private readonly List _lines; + private readonly Action? _beforeLine; + private byte[] _pending = Array.Empty(); + private int _pendingPos, _next; + public LineStream(IEnumerable lines, Action? beforeLine = null) { _lines = lines.ToList(); _beforeLine = beforeLine; } + public override int Read(byte[] buffer, int offset, int count) + { + if (_pendingPos >= _pending.Length) + { + if (_next >= _lines.Count) return 0; + _beforeLine?.Invoke(_next); + _pending = Encoding.UTF8.GetBytes(_lines[_next++] + "\n"); + _pendingPos = 0; + } + int n = Math.Min(count, _pending.Length - _pendingPos); + Array.Copy(_pending, _pendingPos, buffer, offset, n); + _pendingPos += n; + return n; + } + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => Task.FromResult(Read(buffer, offset, count)); + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) + { + var tmp = new byte[buffer.Length]; + int n = Read(tmp, 0, tmp.Length); + tmp.AsMemory(0, n).CopyTo(buffer); + return ValueTask.FromResult(n); + } + public override bool CanRead => true; public override bool CanSeek => false; public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => 0; set => throw new NotSupportedException(); } + public override void Flush() { } + public override long Seek(long o, SeekOrigin s) => throw new NotSupportedException(); + public override void SetLength(long v) => throw new NotSupportedException(); + public override void Write(byte[] b, int o, int c) => throw new NotSupportedException(); +} + +/// v1.1.12: drives Team Corner screens on a script and returns what they printed. +internal sealed class TeamCornerRig +{ + private const BindingFlags F = BindingFlags.NonPublic | BindingFlags.Instance; + public readonly TeamCornerLocation Loc = new(); + public readonly TerminalEmulator Term; + private readonly MemoryStream _out = new(); + + public TeamCornerRig(Character hero, IEnumerable lines, Action? beforeLine = null) + { + Term = new TerminalEmulator(new LineStream(lines, beforeLine), _out); + typeof(BaseLocation).GetField("terminal", F)!.SetValue(Loc, Term); + typeof(BaseLocation).GetField("currentPlayer", F)!.SetValue(Loc, hero); + } + + public string Shown() + { + Term.StreamWriterInternal!.Flush(); + return Regex.Replace(Encoding.UTF8.GetString(_out.ToArray()), "\u001b\\[[0-9;]*[A-Za-z]", ""); + } + + public async Task Run(string method, params object[] args) + { + var mi = typeof(TeamCornerLocation).GetMethod(method, F)!; + await (Task)mi.Invoke(Loc, args.Length == 0 ? null : args)!; + return Shown(); + } + + public static Character Hero(string name = "Rig Hero", string team = "", long gold = 10000) => + new Character { Name1 = name, Name2 = name, Class = CharacterClass.Warrior, Level = 20, HP = 300, MaxHP = 300, Gold = gold, Team = team }; + + public static NPC Npc(string id, string name, string team, int level = 10, bool dead = false) => + new NPC { ID = id, Name1 = name, Name2 = name, Team = team, TeamPW = "pw", Class = CharacterClass.Warrior, Level = level, + HP = dead ? 0 : 100, MaxHP = 100, IsDead = dead, CurrentLocation = "Main Street" }; + + /// A copy of an NPC as a world_state reload would build it: same ID, a new object. + public static NPC Reloaded(NPC n) => new NPC + { + ID = n.ID, Name1 = n.Name1, Name2 = n.Name2, Team = n.Team, TeamPW = n.TeamPW, Class = n.Class, Level = n.Level, + HP = n.HP, MaxHP = n.MaxHP, IsDead = n.IsDead, CurrentLocation = n.CurrentLocation, Specialization = n.Specialization, + }; + + /// Replaces the NPC in the live list with its reloaded copy; returns the copy. + public static NPC Reload(NPC n) + { + var list = NPCSpawnSystem.Instance.ActiveNPCs; + var copy = Reloaded(n); + int i = list.IndexOf(n); + list[i] = copy; + return copy; + } + + public static void SetOnline(bool on) => + typeof(UsurperRemake.BBS.DoorMode).GetField("_onlineMode", BindingFlags.NonPublic | BindingFlags.Static)!.SetValue(null, on); + + /// Runs body online against a fresh SQLite database, restoring the save system after. + public static async Task Online(Func body) + { + string path = Path.Combine(Path.GetTempPath(), $"usurper-tc-{Guid.NewGuid():N}.db"); + var field = typeof(SaveSystem).GetField("instance", BindingFlags.NonPublic | BindingFlags.Static)!; + var before = field.GetValue(null); + bool wasOnline = UsurperRemake.BBS.DoorMode.IsOnlineMode; + var db = new SqlSaveBackend(path); + SaveSystem.InitializeWithBackend(db); + SetOnline(true); + try { await body(db, path); } + finally + { + SetOnline(wasOnline); + field.SetValue(null, before); + SqliteConnection.ClearAllPools(); + try { File.Delete(path); } catch { } + } + } + + public static void Exec(string path, string sql) + { + using var conn = new SqliteConnection($"Data Source={path}"); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + public static object? Scalar(string path, string sql) + { + using var conn = new SqliteConnection($"Data Source={path}"); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + return cmd.ExecuteScalar(); + } + + public static void PlayerRow(string path, string key, string team, int level = 12, int str = 55, string? rawPlayer = null) => + Exec(path, $"INSERT INTO players (username, display_name, player_data) VALUES ('{key}', '{char.ToUpper(key[0]) + key.Substring(1)}', " + + $"'{{\"player\":{rawPlayer ?? $"{{\"name2\":\"{char.ToUpper(key[0]) + key.Substring(1)}\",\"team\":\"{team}\",\"level\":{level},\"class\":0,\"strength\":{str},\"hp\":90,\"maxHP\":90}}"}}}');"); +} + +/// +/// v1.1.12: the shared list picker (BaseLocation.PickFromList) and the Team Corner screens that use it: +/// [I] Info, [J] Join, [E] Examine, [2] Sack, [G] Equip, [X] Specialize, [U] Resurrect. +/// +[Collection("SharedGameSingletons")] +public class TeamCornerPickerTests +{ + private static List Names(int n) => Enumerable.Range(1, n).Select(i => $"Name {i:00}").ToList(); + + private static async Task<(string? picked, string shown)> Pick(IReadOnlyList items, params string[] lines) + { + var rig = new TeamCornerRig(TeamCornerRig.Hero(), lines); + var picked = await rig.Loc.PickFromList(items, s => s, s => s, "team.pick_examine_title"); + return (picked, rig.Shown()); + } + + // ---------- the picker ---------- + + [Fact] + public async Task Picker_PagesOfTen_AndANumberCountsAcrossPages() + { + var (picked, shown) = await Pick(Names(23), "N", "15"); + picked.Should().Be("Name 15"); + shown.Should().Contain("[10] Name 10").And.Contain("[11] Name 11").And.Contain("[20] Name 20"); + shown.Should().Contain(Loc.Get("team.recruit_page_footer", 1, 10, 23, 1, 3)); + shown.Should().Contain(Loc.Get("team.recruit_page_footer", 11, 20, 23, 2, 3)); + shown.Substring(0, shown.IndexOf("[10] Name 10")).Should().NotContain("[11]", "the first page stops at ten"); + } + + [Fact] + public async Task Picker_ANumberOnALaterPage_CanBeTypedFromTheFirst() + { + var (picked, _) = await Pick(Names(23), "23"); + picked.Should().Be("Name 23"); + } + + [Fact] + public async Task Picker_PreviousPage_GoesBack() + { + var (picked, shown) = await Pick(Names(23), "N", "N", "P", "0"); + picked.Should().BeNull(); + shown.Should().Contain(Loc.Get("team.recruit_page_footer", 21, 23, 23, 3, 3)); + shown.LastIndexOf(Loc.Get("team.recruit_page_footer", 11, 20, 23, 2, 3)).Should().BeGreaterThan(shown.IndexOf(Loc.Get("team.recruit_page_footer", 21, 23, 23, 3, 3))); + } + + [Fact] + public async Task Picker_AUniqueStartOfAName_Chooses() + { + var (picked, _) = await Pick(new[] { "Aldric", "Bram", "Brom", "Cedric" }, "ald"); + picked.Should().Be("Aldric"); + } + + [Fact] + public async Task Picker_AnExactName_WinsOverALongerOne() + { + var (picked, _) = await Pick(new[] { "Bobby", "Bob" }, "bob"); + picked.Should().Be("Bob"); + } + + [Fact] + public async Task Picker_AStartThatFitsSeveral_ListsOnlyThem_ThenANumberChoosesAmongThem() + { + var (picked, shown) = await Pick(new[] { "Aldric", "Bram", "Brom", "Cedric" }, "br", "2"); + picked.Should().Be("Brom"); + shown.Should().Contain(Loc.Get("base.pick_matching", "br")); + } + + [Fact] + public async Task Picker_EnterOrZero_Cancels() + { + (await Pick(Names(5), "")).picked.Should().BeNull(); + (await Pick(Names(5), "0")).picked.Should().BeNull(); + // with a filter on, the first Enter brings the full list back and the second leaves + var (picked, shown) = await Pick(new[] { "Bram", "Brom", "Cedric" }, "br", "", ""); + picked.Should().BeNull(); + shown.LastIndexOf("[3] Cedric").Should().BeGreaterThan(shown.IndexOf(Loc.Get("base.pick_matching", "br"))); + } + + [Fact] + public async Task Picker_ANumberPastTheEnd_OrANameNotThere_SaysSo() + { + var (_, shown) = await Pick(Names(12), "40", "", "zzz", "", "0"); + shown.Should().Contain(Loc.Get("base.pick_no_number", 40, 12)); + shown.Should().Contain(Loc.Get("base.pick_no_match", "zzz")); + } + + [Fact] + public async Task Picker_ScreenReaderRows_AreNumberDotText() + { + var hero = TeamCornerRig.Hero(); + hero.ScreenReaderMode = true; + var rig = new TeamCornerRig(hero, new[] { "0" }); + await rig.Loc.PickFromList(Names(3), s => s, s => s, "team.pick_examine_title"); + string shown = rig.Shown(); + shown.Should().Contain(" 3. Name 03").And.Contain($"0. {Loc.Get("ui.cancel")}").And.NotContain("[3]"); + } + + [Fact] + public async Task Picker_AFewEntries_ShowNoPaging() + { + var (_, shown) = await Pick(Names(4), "0"); + shown.Should().Contain("[4] Name 04").And.NotContain("Page 1/"); + } + + // ---------- the call sites ---------- + + private static async Task WithNpcs(IEnumerable npcs, Func body) + { + var list = NPCSpawnSystem.Instance.ActiveNPCs; + var added = npcs.ToList(); + list.AddRange(added); + try { await body(); } + finally { list.RemoveAll(n => added.Any(a => a.ID == n.ID)); } + } + + [Fact] + public async Task Info_ListsTheTeamsWithTheRankingsNumbers_AndShowsTheOneChosen() + { + var a = TeamCornerRig.Npc("tc_info_1", "Info One", "Info Wolves", level: 10); + var b = TeamCornerRig.Npc("tc_info_2", "Info Two", "Info Wolves", level: 20); + await WithNpcs(new[] { a, b }, async () => + { + var rig = new TeamCornerRig(TeamCornerRig.Hero(), new[] { "info wolves", "" }); + string shown = await rig.Run("ShowTeamInfo"); + long power = 10 + a.Strength + a.Defence + 20 + b.Strength + b.Defence; + shown.Should().Contain(Loc.Get("team.pick_team_row", "Info Wolves", 2, 15, power)); + shown.Should().Contain(Loc.Get("team.info_header", "Info Wolves")); + shown.Should().Contain("Info Two"); + }); + } + + [Fact] + public async Task Join_ListsTheTeams_ANameInAnyCaseChooses_AndThePasswordIsStillAsked() + { + var a = TeamCornerRig.Npc("tc_join_1", "Hawk One", "Join Hawks"); + await WithNpcs(new[] { a }, async () => + { + var hero = TeamCornerRig.Hero(); + var wrong = new TeamCornerRig(hero, new[] { "join hawks", "nope", "" }); + string shown = await wrong.Run("JoinTeam"); + shown.Should().Contain(Loc.Get("team.pick_team_join_title")); + shown.Should().Contain(Loc.Get("team.wrong_password")); + hero.Team.Should().BeEmpty(); + + var right = new TeamCornerRig(hero, new[] { "join hawks", "pw", "" }); + await right.Run("JoinTeam"); + hero.Team.Should().Be("Join Hawks"); + }); + } + + [Fact] + public async Task Examine_ListsPlayerMembers_AndShowsOne() + { + var npc = TeamCornerRig.Npc("tc_exam_1", "Exam Npc", "Exam Band"); + await WithNpcs(new[] { npc }, () => TeamCornerRig.Online(async (db, path) => + { + await db.WriteGameData("tomas", new SaveGameData + { + Version = GameConfig.SaveVersion, + Player = new PlayerData { Name1 = "tomas", Name2 = "Tomas", Team = "Exam Band", Level = 12, Strength = 55, BaseStrength = 55, HP = 90, MaxHP = 90 } + }); + var hero = TeamCornerRig.Hero(team: "Exam Band"); + var rig = new TeamCornerRig(hero, new[] { "tomas", "" }); + string shown = await rig.Run("ExamineMember"); + shown.Should().Contain("Tomas - ").And.Contain("Exam Npc - ").And.Contain(hero.DisplayName); + shown.Should().Contain("TOMAS"); + shown.Should().Contain("STR: 55", "the player's strength comes from their save"); + })); + } + + [Fact] + public async Task Examine_ListsATeammateWhoSharesTheViewersDisplayName() + { + // v1.1.12: the viewer was left out by display name, which also hid a teammate of that name + await TeamCornerRig.Online(async (db, path) => + { + await db.WriteGameData("bsmith", new SaveGameData + { + Version = GameConfig.SaveVersion, + Player = new PlayerData { Name1 = "bsmith", Name2 = "Bob Smith", Team = "Smith Band", Level = 14, Strength = 61, BaseStrength = 61, HP = 90, MaxHP = 90 } + }); + TeamCornerRig.Exec(path, "UPDATE players SET display_name = 'Bob Smith' WHERE username = 'bsmith';"); + var hero = new Character { Name1 = "bob", Name2 = "Bob", FamilySurname = "Smith", Team = "Smith Band", Class = CharacterClass.Warrior, Level = 20, HP = 300, MaxHP = 300 }; + hero.DisplayName.Should().Be("Bob Smith"); + string shown = await new TeamCornerRig(hero, new[] { "2", "" }).Run("ExamineMember"); + shown.Should().Contain("STR: 61", "the other Bob Smith is listed second and can be examined"); + }); + } + + [Fact] + public async Task Examine_AnNpc_StillShowsTheNpcCard() + { + var npc = TeamCornerRig.Npc("tc_exam_2", "Card Npc", "Card Band"); + await WithNpcs(new[] { npc }, async () => + { + var rig = new TeamCornerRig(TeamCornerRig.Hero(team: "Card Band"), new[] { "card", "" }); + string shown = await rig.Run("ExamineMember"); + shown.Should().Contain("CARD NPC").And.Contain(Loc.Get("team.examine_section_identity")); + shown.Should().Contain(GameConfig.GetLocalizedRaceName(npc.Race)); + }); + } + + [Fact] + public async Task Sack_APlayerMember_IsRefused() + { + await TeamCornerRig.Online(async (db, path) => + { + TeamCornerRig.PlayerRow(path, "tomas", "Sack Band"); + var rig = new TeamCornerRig(TeamCornerRig.Hero(team: "Sack Band"), new[] { "tomas", "" }); + string shown = await rig.Run("SackMember"); + shown.Should().Contain(Loc.Get("team.sack_player_refused", "Tomas")); + TeamCornerRig.Scalar(path, "SELECT json_extract(player_data, '$.player.team') FROM players WHERE username = 'tomas'").Should().Be("Sack Band"); + }); + } + + [Fact] + public async Task Sack_AnNpc_ChangesTheLiveNpc_EvenAfterAReloadAtTheConfirm() + { + var npc = TeamCornerRig.Npc("tc_sack_1", "Sack Npc", "Sack Crew"); + await WithNpcs(new[] { npc }, async () => + { + NPC? live = null; + // line 0 picks, line 1 confirms; the reload lands between them + var rig = new TeamCornerRig(TeamCornerRig.Hero(team: "Sack Crew"), new[] { "sack npc", "y", "" }, + i => { if (i == 1) live = TeamCornerRig.Reload(npc); }); + await rig.Run("SackMember"); + live.Should().NotBeNull(); + live!.Team.Should().BeEmpty("the sack must land on the NPC in the live list"); + }); + } + + [Fact] + public async Task Resurrect_ListsTheDead_AsksForTheCost_AndChangesTheLiveNpc() + { + var dead = TeamCornerRig.Npc("tc_res_1", "Res Npc", "Res Crew", level: 3, dead: true); + await WithNpcs(new[] { dead }, async () => + { + var hero = TeamCornerRig.Hero(team: "Res Crew", gold: 10000); + var no = new TeamCornerRig(hero, new[] { "res", "n", "" }); + string shown = await no.Run("ResurrectTeammate"); + shown.Should().Contain(Loc.Get("team.confirm_resurrect", "Res Npc", $"{3000:N0}").TrimEnd()); + hero.Gold.Should().Be(10000); + dead.IsAlive.Should().BeFalse(); + + NPC? live = null; + var yes = new TeamCornerRig(hero, new[] { "res", "y", "" }, i => { if (i == 1) live = TeamCornerRig.Reload(dead); }); + await yes.Run("ResurrectTeammate"); + live.Should().NotBeNull(); + live!.IsAlive.Should().BeTrue("the revival must land on the NPC in the live list"); + live.IsDead.Should().BeFalse(); + hero.Gold.Should().Be(7000); + }); + } + + [Fact] + public async Task Specialize_ChangesTheLiveNpc_EvenAfterAReloadAtTheSpecMenu() + { + var npc = TeamCornerRig.Npc("tc_spec_1", "Spec Npc", "Spec Crew"); + var specs = UsurperRemake.Data.SpecializationData.GetSpecsForClass(npc.Class); + specs.Should().NotBeEmpty(); + await WithNpcs(new[] { npc }, async () => + { + NPC? live = null; + var rig = new TeamCornerRig(TeamCornerRig.Hero(team: "Spec Crew"), new[] { "spec npc", "1", "" }, + i => { if (i == 1) live = TeamCornerRig.Reload(npc); }); + await rig.Run("SpecializeMember"); + live.Should().NotBeNull(); + live!.Specialization.Should().Be(specs[0].Spec, "the choice must land on the NPC in the live list"); + }); + } + + [Fact] + public async Task Equip_ListsTheLivingMembers_InThePicker() + { + var npc = TeamCornerRig.Npc("tc_eq_1", "Equip Npc", "Equip Crew"); + var gone = TeamCornerRig.Npc("tc_eq_2", "Fallen Npc", "Equip Crew", dead: true); + await WithNpcs(new[] { npc, gone }, async () => + { + var rig = new TeamCornerRig(TeamCornerRig.Hero(team: "Equip Crew"), new[] { "0" }); + string shown = await rig.Run("EquipMember"); + shown.Should().Contain("[1] Equip Npc").And.NotContain("Fallen Npc"); + shown.Should().Contain(Loc.Get("base.pick_nav").TrimEnd()); + }); + } +} diff --git a/Tests/TeamHQBonusTests.cs b/Tests/TeamHQBonusTests.cs index a4882ac4..448dde29 100644 --- a/Tests/TeamHQBonusTests.cs +++ b/Tests/TeamHQBonusTests.cs @@ -122,8 +122,12 @@ public void RunTimeOnlyCharacterFields_AreNeverInTheSave() // v1.1.11: IsLoadedPlayer says a Character was built from a player's save this run; a save carrying it // would mark any character loaded from it, and the HQ bookkeeping is the team's, read from the database. var names = typeof(PlayerData).GetMembers(BindingFlags.Public | BindingFlags.Instance).Select(m => m.Name).ToList(); - names.Should().NotContain(new[] { "IsLoadedPlayer", "HQLevelsTeam", "HQLevelsReadAt" }); + // v1.1.12: AwakeningStage is stamped by the owning session; the story data is the saved source. + names.Should().NotContain(new[] { "IsLoadedPlayer", "HQLevelsTeam", "HQLevelsReadAt", "AwakeningStage", "EchoSaveKey" }); typeof(Character).GetProperty("IsLoadedPlayer").Should().NotBeNull("the check above must name a real member"); + var stage = typeof(Character).GetProperty("AwakeningStage"); + stage.Should().NotBeNull("the check above must name a real member"); + stage!.GetCustomAttribute().Should().NotBeNull(); } // ─── the status screen ─── diff --git a/Tests/TeamRankingsTests.cs b/Tests/TeamRankingsTests.cs new file mode 100644 index 00000000..c4f98066 --- /dev/null +++ b/Tests/TeamRankingsTests.cs @@ -0,0 +1,159 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Data.Sqlite; +using UsurperRemake.Systems; +using Xunit; + +namespace UsurperReborn.Tests; + +/// +/// v1.1.12: the team rankings read every player member in one pass over the saves (it was one scan per +/// team), show the real level and power sums (it was members x 50 and level 0), combine a team's NPC and +/// player members (the NPC side used to hide the players) and leave out teams with no members. +/// +[Collection("SharedGameSingletons")] +public class TeamRankingsTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"usurper-rank-{Guid.NewGuid():N}.db"); + private readonly SqlSaveBackend _db; + + public TeamRankingsTests() { _db = new SqlSaveBackend(_path); } + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + try { File.Delete(_path); } catch { } + } + + private void Exec(string sql) + { + using var conn = new SqliteConnection($"Data Source={_path}"); + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = sql; + cmd.ExecuteNonQuery(); + } + + private void Raw(string key, string json, bool banned = false) => + Exec($"INSERT INTO players (username, display_name, player_data, is_banned) VALUES ('{key}', '{key}', '{json}', {(banned ? 1 : 0)});"); + + private void Player(string key, string? team, int level, int str, int def, bool banned = false) => + Raw(key, $"{{\"player\":{{\"level\":{level},\"strength\":{str},\"defence\":{def}" + + $"{(team != null ? $",\"team\":\"{team}\"" : "")},\"filler\":\"xx\"}}}}", banned); + + private void Team(string name, bool turf = false) => + Exec($"INSERT INTO player_teams (team_name, password_hash, created_by, controls_turf) VALUES ('{name}', 'x', 'founder', {(turf ? 1 : 0)});"); + + private static NPC Npc(string id, string team, int level, int str, int def, bool alive = true) => + new NPC { ID = id, Name1 = id, Name2 = id, Team = team, Level = level, Strength = str, Defence = def, HP = alive ? 50 : 0, MaxHP = 50 }; + + [Fact] + public async Task OnePass_CountsAndSumsThePlayers_WithoutBannedOrEmergencyRows() + { + Team("Grey Company"); + Player("alpha", "Grey Company", 10, 100, 50); + Player("beta", "Grey Company", 20, 200, 80); + Player("gamma", "Grey Company", 99, 999, 999, banned: true); + Player("emergency_beta", "Grey Company", 99, 999, 999); + Player("delta", null, 30, 1, 1); + + var team = (await _db.GetPlayerTeams()).Single(t => t.TeamName == "Grey Company"); + team.MemberCount.Should().Be(2); + team.LevelSum.Should().Be(30); + team.PowerSum.Should().Be(10 + 100 + 50 + 20 + 200 + 80); + + var row = TeamCornerLocation.BuildTeamRankings(Array.Empty(), await _db.GetTeamRankingStats(null), null).Single(); + row.MemberCount.Should().Be(2); + row.AverageLevel.Should().Be(15); + row.TotalPower.Should().Be(460); + } + + private void DropJsonIndexes() + { + using var conn = new SqliteConnection($"Data Source={_path}"); + conn.Open(); + var names = new System.Collections.Generic.List(); + using (var q = conn.CreateCommand()) + { + q.CommandText = "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'players' AND sql LIKE '%json%';"; + using var r = q.ExecuteReader(); + while (r.Read()) names.Add(r.GetString(0)); + } + foreach (var n in names) + { + using var d = conn.CreateCommand(); + d.CommandText = $"DROP INDEX \"{n}\";"; + d.ExecuteNonQuery(); + } + } + + [Fact] + public async Task AMalformedSave_DoesNotBreakTheQuery() + { + // a fresh database's expression indexes refuse a malformed blob; an upgraded one may lack them + DropJsonIndexes(); + Team("Grey Company"); + Player("alpha", "Grey Company", 10, 100, 50); + Raw("broken", "{\"player\":{\"level\":5,\"team\":\"Grey Company\""); + Raw("numeric", "{\"player\":{\"level\":5,\"team\":7}}"); + + var team = (await _db.GetPlayerTeams()).Single(t => t.TeamName == "Grey Company"); + team.MemberCount.Should().Be(1); + team.LevelSum.Should().Be(10); + (await _db.GetTeamRankingStats(null)).Should().ContainSingle(t => t.TeamName == "Grey Company"); + } + + [Fact] + public async Task AMixedTeam_CombinesLivingNPCs_AllPlayers_AndTheViewerOnce() + { + // an NPC team with no player_teams row; two other players and the viewer joined it + Player("alpha", "Iron Circle", 10, 100, 100); + Player("beta", "Iron Circle", 20, 100, 100); + Player("viewer", "Iron Circle", 5, 10, 10); // the save lags the in-memory level + var npcs = new[] + { + Npc("npc_rank_a", "Iron Circle", 30, 300, 300), + Npc("npc_rank_b", "Iron Circle", 40, 400, 400), + Npc("npc_rank_dead", "Iron Circle", 90, 900, 900, alive: false), + }; + var viewer = new Character { Name2 = "viewer", Team = "Iron Circle", Level = 25, Strength = 250, Defence = 250 }; + + var rows = TeamCornerLocation.BuildTeamRankings(npcs, await _db.GetTeamRankingStats("VIEWER"), viewer); + + var row = rows.Single(r => r.TeamName == "Iron Circle"); + row.MemberCount.Should().Be(5, "two living NPCs, two players, the viewer once"); + row.AverageLevel.Should().Be((30 + 40 + 10 + 20 + 25) / 5); + row.TotalPower.Should().Be(630 + 840 + 210 + 220 + 525); + row.IsPlayerTeam.Should().BeTrue(); + } + + [Fact] + public async Task TheViewerWhoseSaveHasNoTeamYet_IsCountedFromMemory() + { + Team("Grey Company"); + Player("alpha", "Grey Company", 10, 100, 50); + Player("viewer", null, 5, 10, 10); + var viewer = new Character { Name2 = "viewer", Team = "Grey Company", Level = 12, Strength = 20, Defence = 20 }; + + var row = TeamCornerLocation.BuildTeamRankings(Array.Empty(), await _db.GetTeamRankingStats("viewer"), viewer).Single(); + row.MemberCount.Should().Be(2); + row.TotalPower.Should().Be(160 + 52); + } + + [Fact] + public async Task ATeamWithNoMembers_IsNotListed() + { + Team("Grey Company"); + Team("Nobody Left", turf: true); + Team("Only Dead"); + Player("alpha", "Grey Company", 10, 100, 50); + Player("exiled", "Nobody Left", 10, 100, 50, banned: true); + var npcs = new[] { Npc("npc_rank_gone", "Only Dead", 30, 300, 300, alive: false) }; + + var rows = TeamCornerLocation.BuildTeamRankings(npcs, await _db.GetTeamRankingStats(null), null); + rows.Select(r => r.TeamName).Should().BeEquivalentTo(new[] { "Grey Company" }); + } +}