diff --git a/CMakeLists.txt b/CMakeLists.txt index 2e614d8..195ebc1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -6,6 +6,11 @@ project(hgserver LANGUAGES CXX ) +# Windows: stop windows.h from defining min/max macros (they clobber std::min/std::max) +if(WIN32) + add_compile_definitions(NOMINMAX WIN32_LEAN_AND_MEAN) +endif() + # Output directories - all binaries go to bin/ set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/bin) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG ${CMAKE_SOURCE_DIR}/bin) diff --git a/bin/game_configs/loot_tables.yaml b/bin/game_configs/loot_tables.yaml index ce12d0e..274a68e 100644 --- a/bin/game_configs/loot_tables.yaml +++ b/bin/game_configs/loot_tables.yaml @@ -27,15 +27,10 @@ pools: - { item: 780, weight: 100 } # RedCandy - { item: 781, weight: 100 } # BlueCandy - { item: 782, weight: 100 } # GreenCandy - - { item: 970, weight: 100 } # CritCandy - { item: 391, weight: 20 } # SuperGreenPotion - { item: 650, weight: 20 } # Zemstone of Sacrifice - - { item: 656, weight: 20 } # Xelima Stone - - { item: 657, weight: 20 } # Merien Stone - - { item: 868, weight: 20 } # AncientTablet(LU) - - { item: 869, weight: 20 } # AncientTablet(LD) - - { item: 870, weight: 20 } # AncientTablet(RU) - - { item: 871, weight: 20 } # AncientTablet(RD) + - { item: 656, weight: 20 } # StoneOfXelima + - { item: 657, weight: 20 } # StoneOfMerien - { item: 651, weight: 4 } # GreenBall - { item: 652, weight: 4 } # RedBall - { item: 653, weight: 4 } # YellowBall @@ -52,7 +47,7 @@ pools: - { item: 12, weight: 1 } # MainGauche - { item: 15, weight: 1 } # Gradius - { item: 65, weight: 1 } # SexonAxe - - { item: 62, weight: 1 } # Tomahawk + - { item: 62, weight: 1 } # Tomahoc - { item: 23, weight: 1 } # Sabre - { item: 31, weight: 1 } # Esterk @@ -73,38 +68,35 @@ pools: - { item: 31, weight: 1 } # Esterk - { item: 34, weight: 1 } # Rapier - { item: 72, weight: 1 } # WarAxe+1 - - { item: 844, weight: 1 } # BlackShadowSword + - { item: 926, weight: 1 } # BlackShadowSword melee_tier6: # Ogre, WereWolf, Stalker, Dark-Elf, Ice-Golem, Minotaurus - { item: 47, weight: 1 } # Claymore+1 - { item: 51, weight: 1 } # GreatSword+1 - { item: 55, weight: 1 } # Flameberge+1 - - { item: 34, weight: 1 } # GiantSword - - { item: 74, weight: 1 } # GoldenAxe - - { item: 848, weight: 1 } # HolyBlade - - { item: 924, weight: 1 } # MageSword + - { item: 46, weight: 1 } # GiantSword + - { item: 74, weight: 1 } # 4BladeGoldenAxe + - { item: 924, weight: 1 } # StormBringer melee_tier7: # Liche, Frost, Barlog, Centaurus, Nizie - { item: 47, weight: 1 } # Claymore+1 - { item: 50, weight: 1 } # GreatSword - - { item: 54, weight: 1 } # Flameberge+1 - - { item: 74, weight: 1 } # GoldenAxe - - { item: 850, weight: 1 } # KlonessAxe - - { item: 923, weight: 1 } # BMageSword + - { item: 55, weight: 1 } # Flameberge+1 + - { item: 74, weight: 1 } # 4BladeGoldenAxe + - { item: 929, weight: 1 } # KlonessAxe + - { item: 923, weight: 1 } # The_Devastator melee_tier8: # Demon, Unicorn, Hellclaw, Tigerworm, Gagoyle - { item: 50, weight: 1 } # GreatSword - { item: 560, weight: 1 } # BattleAxe - { item: 615, weight: 1 } # GiantSword - { item: 56, weight: 1 } # Flameberge+2 - - { item: 846, weight: 1 } # The_Devastator + - { item: 923, weight: 1 } # The_Devastator melee_tier9: # Mountain-Giant - { item: 55, weight: 1 } # Flameberge+1 - { item: 615, weight: 1 } # GiantSword - { item: 761, weight: 1 } # BattleHammer - - { item: 762, weight: 1 } # GiantBattleHammer - - { item: 857, weight: 1 } # I.M.C Manual melee_tier10: # Ettin, MasterMage-Orc, Giant-Lizard - { item: 50, weight: 1 } # GreatSword @@ -113,16 +105,14 @@ pools: - { item: 56, weight: 1 } # Flameberge+2 - { item: 615, weight: 1 } # GiantSword - { item: 761, weight: 1 } # BattleHammer - - { item: 762, weight: 1 } # GiantBattleHammer - - { item: 843, weight: 1 } # BarbarianHammer - - { item: 853, weight: 1 } # E.S.W Manual + - { item: 928, weight: 1 } # BarbarianHammer # === Wand tiers === wand_tier2: - - { item: 258, weight: 1 } # MagicWand(MS20) + - { item: 256, weight: 1 } # MagicWand(MS20) wand_tier4: - - { item: 257, weight: 1 } # MagicWand(MS20) + - { item: 256, weight: 1 } # MagicWand(MS20) wand_tier7: - { item: 256, weight: 1 } # MagicWand(MS20) @@ -144,7 +134,7 @@ pools: - { item: 472, weight: 1 } # Hauberk(W) - { item: 461, weight: 1 } # ChainHose(M) - { item: 482, weight: 1 } # ChainHose(W) - - { item: 86, weight: 1 } # KnightShield + - { item: 86, weight: 1 } # KiteShield armor_tier5: # Cyclops, Troll, Beholder, etc. - { item: 455, weight: 1 } # LeatherArmor(M) @@ -183,7 +173,7 @@ pools: - { item: 482, weight: 1 } # ChainHose(W) - { item: 458, weight: 1 } # PlateMail(M) - { item: 478, weight: 1 } # PlateMail(W) - - { item: 86, weight: 1 } # KnightShield + - { item: 86, weight: 1 } # KiteShield - { item: 87, weight: 1 } # TowerShield - { item: 600, weight: 1 } # Helm(M) - { item: 602, weight: 1 } # Helm(W) @@ -192,13 +182,13 @@ pools: armor_tier8: # Demon, Unicorn, Hellclaw, Tigerworm, Gagoyle - { item: 402, weight: 1 } # Cape - - { item: 451, weight: 1 } # Boots - - { item: 926, weight: 1 } # ShieldOfFaith - - { item: 927, weight: 1 } # ShieldOfBrave + - { item: 451, weight: 1 } # LongBoots + - { item: 926, weight: 1 } # BlackShadowSword + - { item: 927, weight: 1 } # DragonSword armor_tier9: # Mountain-Giant - { item: 402, weight: 1 } # Cape - - { item: 451, weight: 1 } # Boots + - { item: 451, weight: 1 } # LongBoots armor_tier10: # Ettin, MasterMage-Orc, Giant-Lizard - { item: 457, weight: 1 } # ScaleMail(M) @@ -210,11 +200,11 @@ pools: # These are from bGetItemNameWhenDeleteNpc - items dropped when NPC corpse despawns skeleton_rares: # sprite 11 - ring pool - - { item: 334, weight: 30 } # SapphireRing - - { item: 336, weight: 30 } # AmethystRing + - { item: 334, weight: 30 } # LuckyGoldRing + - { item: 336, weight: 30 } # SapphireRing - { item: 335, weight: 20 } # EmeraldRing - { item: 337, weight: 20 } # RubyRing - - { item: 333, weight: 10 } # DiamondRing + - { item: 333, weight: 10 } # PlatinumRing - { item: 634, weight: 5 } # RingofWizard - { item: 635, weight: 2 } # RingofMage @@ -223,47 +213,44 @@ pools: - { item: 308, weight: 30 } # MagicNecklace MS10 - { item: 305, weight: 15 } # MagicNecklace DM+1 - { item: 300, weight: 15 } # MagicNecklace RM10 - - { item: 632, weight: 5 } # KnecklaceOfStoneGolem + - { item: 647, weight: 5 } # KnecklaceOfStoneGolem - { item: 637, weight: 3 } # KnecklaceOfLightPro - { item: 638, weight: 2 } # KnecklaceOfFirePro orc_rares: # sprite 14 (Orc), 17 (Scorpion), 57 (Giant-Frog) - ring pool (same as skeleton) - - { item: 334, weight: 30 } # SapphireRing - - { item: 336, weight: 30 } # AmethystRing + - { item: 334, weight: 30 } # LuckyGoldRing + - { item: 336, weight: 30 } # SapphireRing - { item: 335, weight: 20 } # EmeraldRing - { item: 337, weight: 20 } # RubyRing - - { item: 333, weight: 10 } # DiamondRing + - { item: 333, weight: 10 } # PlatinumRing - { item: 634, weight: 5 } # RingofWizard - { item: 635, weight: 2 } # RingofMage zombie_rares: # sprite 18 (Zombie), 22 (Amphis) - sword pool - { item: 613, weight: 30 } # SwordOfMedusa - - { item: 639, weight: 20 } # StormBringer - - { item: 641, weight: 10 } # SpecialSword - - { item: 640, weight: 5 } # KnecklaceOfSufferent + - { item: 924, weight: 20 } # StormBringer + - { item: 641, weight: 10 } # NecklaceOfMedusa + - { item: 640, weight: 5 } # NecklaceOfSufferent clay_golem_rares: # sprite 23 - wand/mail pool - - { item: 738, weight: 30 } # BerserkWand MS.20 - - { item: 621, weight: 20 } # XelimaArmor(M) - - { item: 622, weight: 20 } # XelimaArmor(W) - - { item: 647, weight: 10 } # NecklaceOfStoneGolem + - { item: 738, weight: 30 } # MasterWandMS30 + - { item: 621, weight: 20 } # MerienPlateMail(M) + - { item: 622, weight: 20 } # MerienPlateMail(W) + - { item: 647, weight: 10 } # KnecklaceOfStoneGolem troll_rares: # sprite 28 - ring pool (same structure) - - { item: 334, weight: 30 } # SapphireRing - - { item: 336, weight: 30 } # AmethystRing + - { item: 334, weight: 30 } # LuckyGoldRing + - { item: 336, weight: 30 } # SapphireRing - { item: 335, weight: 20 } # EmeraldRing - { item: 337, weight: 20 } # RubyRing - - { item: 333, weight: 10 } # DiamondRing + - { item: 333, weight: 10 } # PlatinumRing - { item: 634, weight: 5 } # RingofWizard - { item: 635, weight: 2 } # RingofMage liche_rares: # sprite 30 - magic drops - - { item: 852, weight: 30 } # SSS Manual - { item: 380, weight: 20 } # IceStormManual - - { item: 853, weight: 15 } # E.S.W Manual - { item: 643, weight: 10 } # KnecklaceOfIceEle - - { item: 648, weight: 5 } # KnecklaceOfSufferent - - { item: 734, weight: 3 } # RingOfArcmage + - { item: 734, weight: 3 } # RingOfArchMage demon_rares: # sprite 31 - magic drops - { item: 382, weight: 25 } # BloodyShockW.Manual @@ -273,23 +260,20 @@ pools: - { item: 381, weight: 15 } # MassFireStrikeManual - { item: 633, weight: 10 } # RingofDemonpower - { item: 645, weight: 5 } # KnecklaceOfEfreet - - { item: 616, weight: 3 } # GiantSwordOfVengeance + - { item: 616, weight: 3 } # DemonSlayer unicorn_rares: # sprite 32 - shield/mail pool - { item: 620, weight: 30 } # MerienShield - - { item: 621, weight: 25 } # XelimaArmor(M) - - { item: 622, weight: 25 } # XelimaArmor(W) + - { item: 621, weight: 25 } # MerienPlateMail(M) + - { item: 622, weight: 25 } # MerienPlateMail(W) - { item: 644, weight: 10 } # KnecklaceOfAirEle werewolf_rares: # sprite 33, 48 (Stalker) - manual pool - - { item: 852, weight: 30 } # SSS Manual - - { item: 857, weight: 25 } # I.M.C Manual - - { item: 853, weight: 25 } # E.S.W Manual - { item: 620, weight: 10 } # MerienShield hellclaw_rares: # sprite 49 - 100% drop from own pool - { item: 308, weight: 5000 } # MagicNecklace MS10 - - { item: 259, weight: 5000 } # MagicWand M.Shield + - { item: 259, weight: 5000 } # WandMShield - { item: 337, weight: 3000 } # RubyRing - { item: 335, weight: 2000 } # EmeraldRing - { item: 300, weight: 2500 } # MagicNecklace RM10 @@ -298,7 +282,7 @@ pools: - { item: 634, weight: 700 } # RingofWizard - { item: 635, weight: 144 } # RingofMage - { item: 614, weight: 78 } # SwordofIceElemental - - { item: 640, weight: 39 } # KnecklaceOfSufferent + - { item: 640, weight: 39 } # NecklaceOfSufferent - { item: 637, weight: 20 } # KnecklaceOfLightPro - { item: 620, weight: 10 } # MerienShield - { item: 644, weight: 5 } # KnecklaceOfAirEle @@ -314,7 +298,6 @@ pools: - { item: 492, weight: 313 } # BloodRapier - { item: 490, weight: 156 } # BloodSword - { item: 491, weight: 78 } # BloodAxe - - { item: 947, weight: 39 } # DragonWand MS40 - { item: 643, weight: 20 } # KnecklaceOfIceEle - { item: 612, weight: 10 } # XelimaRapier - { item: 610, weight: 6 } # XelimaBlade @@ -329,62 +312,53 @@ pools: - { item: 381, weight: 15 } # MassFireStrikeManual - { item: 633, weight: 10 } # RingofDemonpower - { item: 645, weight: 5 } # KnecklaceOfEfreet - - { item: 630, weight: 3 } # RingoftheXelima + - { item: 630, weight: 3 } # RingOfXelima - { item: 631, weight: 2 } # RingoftheAbaddon - { item: 735, weight: 5 } # RingOfDragonpower - - { item: 20, weight: 2 } # Excaliber + - { item: 20, weight: 2 } # Excalibur beholder_rares: # sprite 53 - IceStormManual only - { item: 380, weight: 1 } # IceStormManual darkelf_rares: # sprite 54 - equipment - - { item: 618, weight: 30 } # XelimaBoots - - { item: 958, weight: 25 } # XelimaHelm - - { item: 956, weight: 25 } # XelimaCap - - { item: 957, weight: 20 } # XelimaHat + - { item: 618, weight: 30 } # DarkElfBow ettin_rares: # sprite 59 - ring/magic - { item: 735, weight: 40 } # RingOfDragonpower - - { item: 853, weight: 35 } # E.S.W Manual - { item: 382, weight: 25 } # BloodyShockW.Manual frost_rares: # sprite 63, 79 (Nizie) - ice weapons - - { item: 943, weight: 40 } # IceAxe - - { item: 942, weight: 35 } # IceHammer - - { item: 732, weight: 25 } # IceStaff + - { item: 732, weight: 25 } # MasterWandMS20 barlog_rares: # sprite 70 - magic - { item: 382, weight: 40 } # BloodyShockW.Manual - { item: 381, weight: 35 } # MassFireStrikeManual - - { item: 732, weight: 25 } # IceStaff + - { item: 732, weight: 25 } # MasterWandMS20 centaurus_rares: # sprite 71 - magic - { item: 735, weight: 55 } # RingOfDragonpower - - { item: 732, weight: 45 } # IceStaff + - { item: 732, weight: 45 } # MasterWandMS20 clawturt_rares: # sprite 72 - magic - { item: 735, weight: 40 } # RingOfDragonpower - - { item: 732, weight: 30 } # IceStaff - - { item: 853, weight: 30 } # E.S.W Manual + - { item: 732, weight: 30 } # MasterWandMS20 crayfish_rares: # sprite 74 - magic - { item: 735, weight: 40 } # RingOfDragonpower - - { item: 732, weight: 30 } # IceStaff + - { item: 732, weight: 30 } # MasterWandMS20 - { item: 382, weight: 30 } # BloodyShockW.Manual lizard_rares: # sprite 75 - magic - { item: 735, weight: 40 } # RingOfDragonpower - - { item: 853, weight: 35 } # E.S.W Manual - { item: 382, weight: 25 } # BloodyShockW.Manual mastermage_rares: # sprite 77 - magic - { item: 735, weight: 40 } # RingOfDragonpower - - { item: 853, weight: 35 } # E.S.W Manual - { item: 382, weight: 25 } # BloodyShockW.Manual minotaurus_rares: # sprite 78 - magic - { item: 735, weight: 40 } # RingOfDragonpower - - { item: 732, weight: 30 } # IceStaff + - { item: 732, weight: 30 } # MasterWandMS20 - { item: 382, weight: 30 } # BloodyShockW.Manual # === Boss multi-drop pools (on_despawn with multi_drop) === @@ -398,33 +372,30 @@ pools: # Secondary tier - { item: 642, weight: 8 } # KnecklaceOfIcePro - { item: 643, weight: 5 } # KnecklaceOfIceEle - - { item: 943, weight: 10 } # IceAxe - - { item: 734, weight: 7 } # RingOfArcmage - - { item: 942, weight: 8 } # IceHammer - - { item: 738, weight: 8 } # BerserkWand MS.20 + - { item: 734, weight: 7 } # RingOfArchMage + - { item: 738, weight: 8 } # MasterWandMS30 # Common tier - { item: 95, weight: 80 } # GreenPotion - { item: 92, weight: 40 } # BigRedPotion - { item: 94, weight: 30 } # BigBluePotion - { item: 650, weight: 15 } # Zemstone of Sacrifice - - { item: 334, weight: 20 } # SapphireRing + - { item: 336, weight: 20 } # SapphireRing - { item: 335, weight: 15 } # EmeraldRing - { item: 337, weight: 15 } # RubyRing fire_wyvern_multi: # sprite 73 (Fire-Wyvern) - fire themed # Rare tier - - { item: 860, weight: 2 } # NecklaceOfXelima - - { item: 630, weight: 3 } # RingoftheXelima - - { item: 738, weight: 3 } # BerserkWand MS.20 + - { item: 630, weight: 3 } # RingOfXelima + - { item: 738, weight: 3 } # MasterWandMS30 - { item: 735, weight: 3 } # RingOfDragonpower - - { item: 20, weight: 3 } # Excaliber + - { item: 20, weight: 3 } # Excalibur - { item: 382, weight: 3 } # BloodyShockW.Manual - { item: 381, weight: 3 } # MassFireStrikeManual # Secondary tier - { item: 645, weight: 10 } # KnecklaceOfEfreet - { item: 638, weight: 8 } # KnecklaceOfFirePro - { item: 636, weight: 5 } # RingofGrandMage - - { item: 734, weight: 7 } # RingOfArcmage + - { item: 734, weight: 7 } # RingOfArchMage - { item: 634, weight: 7 } # RingofWizard - { item: 290, weight: 5 } # Flameberge+3 LLF - { item: 490, weight: 5 } # BloodSword @@ -435,18 +406,15 @@ pools: - { item: 92, weight: 40 } # BigRedPotion - { item: 94, weight: 30 } # BigBluePotion - { item: 650, weight: 15 } # Zemstone of Sacrifice - - { item: 334, weight: 15 } # SapphireRing + - { item: 336, weight: 15 } # SapphireRing - { item: 335, weight: 10 } # EmeraldRing - { item: 337, weight: 10 } # RubyRing abaddon_multi: # sprite 81 (Abaddon) - final boss # Rare tier - - { item: 20, weight: 5 } # Excaliber - - { item: 647, weight: 5 } # NecklaceOfStoneGolem - - { item: 860, weight: 5 } # NecklaceOfXelima - - { item: 936, weight: 5 } # MerienHat + - { item: 20, weight: 5 } # Excalibur + - { item: 647, weight: 5 } # KnecklaceOfStoneGolem - { item: 631, weight: 3 } # RingoftheAbaddon - - { item: 937, weight: 3 } # MerienHelm # Secondary tier - { item: 650, weight: 20 } # Zemstone of Sacrifice - { item: 490, weight: 10 } # BloodSword @@ -459,13 +427,12 @@ pools: - { item: 638, weight: 8 } # KnecklaceOfFirePro - { item: 382, weight: 8 } # BloodyShockW.Manual - { item: 381, weight: 8 } # MassFireStrikeManual - - { item: 259, weight: 8 } # MagicWand M.Shield - - { item: 947, weight: 5 } # DragonWand MS40 + - { item: 259, weight: 8 } # WandMShield # Common tier - { item: 95, weight: 80 } # GreenPotion - { item: 92, weight: 40 } # BigRedPotion - { item: 94, weight: 30 } # BigBluePotion - - { item: 334, weight: 20 } # SapphireRing + - { item: 336, weight: 20 } # SapphireRing - { item: 335, weight: 15 } # EmeraldRing - { item: 337, weight: 15 } # RubyRing diff --git a/bin/game_configs/magic.yaml b/bin/game_configs/magic.yaml index dcb9d98..36a488e 100644 --- a/bin/game_configs/magic.yaml +++ b/bin/game_configs/magic.yaml @@ -1321,7 +1321,9 @@ magic: attribute: 2 - id: 76 name: Cancellation - type: 21 + # Legacy cfg had a placeholder row copied from Lightning-Strike (type 21); + # corrected to the real cancellation type + type: 28 delay: 0 duration: 0 mana_cost: 90 @@ -1345,7 +1347,9 @@ magic: attribute: 2 - id: 77 name: Illusion-Movement - type: 21 + # Legacy cfg had a placeholder row copied from Lightning-Strike (type 21); + # corrected to the confusion/illusion type + type: 16 delay: 0 duration: 0 mana_cost: 90 @@ -1537,7 +1541,9 @@ magic: attribute: 4 - id: 95 name: Mass-Illusion-Movement - type: 21 + # Legacy cfg had a placeholder row copied from Lightning-Strike (type 21); + # corrected to the confusion/illusion type + type: 16 delay: 0 duration: 0 mana_cost: 90 @@ -1561,7 +1567,9 @@ magic: attribute: 2 - id: 94 name: Resurrection - type: 21 + # Legacy cfg had a placeholder row copied from Lightning-Strike (type 21); + # corrected to the real resurrection type + type: 32 delay: 0 duration: 0 mana_cost: 90 diff --git a/bin/game_configs/npcs.yaml b/bin/game_configs/npcs.yaml index ae1b17c..3599466 100644 --- a/bin/game_configs/npcs.yaml +++ b/bin/game_configs/npcs.yaml @@ -1,86 +1,86 @@ -npcs: - - {name: Attack-Dummy, sprite_id: 34, hit_dice: 10, defense_ratio: 30, hit_ratio: 300, min_bravery: 100, exp_dice: 384, attack_dice: 0, attack_sides: 0, size: 1, side: 10, action_limit: 0, action_time: 2100, resist_magic: 5, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Guard-Aresden, sprite_id: 21, hit_dice: 115, defense_ratio: 150, hit_ratio: 230, min_bravery: 3, exp_dice: 0, attack_dice: 3, attack_sides: 8, size: 0, side: 1, action_limit: 0, action_time: 1000, resist_magic: 100, magic_level: -10, day_of_week: 10, chat_msg: 1, target_search_range: 8, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 1000, magic_hit_ratio: 130, attack_range: 5} - - {name: Guard-Elvine, sprite_id: 21, hit_dice: 115, defense_ratio: 150, hit_ratio: 230, min_bravery: 3, exp_dice: 0, attack_dice: 3, attack_sides: 8, size: 0, side: 2, action_limit: 0, action_time: 1000, resist_magic: 100, magic_level: -10, day_of_week: 10, chat_msg: 1, target_search_range: 8, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 1000, magic_hit_ratio: 130, attack_range: 5} - - {name: Guard-Neutral, sprite_id: 21, hit_dice: 115, defense_ratio: 150, hit_ratio: 230, min_bravery: 3, exp_dice: 0, attack_dice: 3, attack_sides: 8, size: 0, side: 0, action_limit: 0, action_time: 1000, resist_magic: 100, magic_level: -10, day_of_week: 10, chat_msg: 1, target_search_range: 8, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 1000, magic_hit_ratio: 130, attack_range: 5} - - {name: Slime, sprite_id: 10, hit_dice: 2, defense_ratio: 20, hit_ratio: 30, min_bravery: 1, exp_dice: 46, attack_dice: 1, attack_sides: 4, size: 0, side: 10, action_limit: 0, action_time: 2100, resist_magic: 5, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 2, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Giant-Ant, sprite_id: 16, hit_dice: 3, defense_ratio: 30, hit_ratio: 40, min_bravery: 2, exp_dice: 74, attack_dice: 2, attack_sides: 3, size: 0, side: 10, action_limit: 0, action_time: 1200, resist_magic: 10, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 2, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Amphis, sprite_id: 22, hit_dice: 4, defense_ratio: 60, hit_ratio: 50, min_bravery: 1, exp_dice: 75, attack_dice: 2, attack_sides: 4, size: 0, side: 10, action_limit: 0, action_time: 1000, resist_magic: 20, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 3, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Orc, sprite_id: 14, hit_dice: 4, defense_ratio: 75, hit_ratio: 70, min_bravery: 2, exp_dice: 106, attack_dice: 3, attack_sides: 3, size: 0, side: 10, action_limit: 0, action_time: 1200, resist_magic: 25, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 5, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Orc-Mage, sprite_id: 14, hit_dice: 12, defense_ratio: 70, hit_ratio: 100, min_bravery: 3, exp_dice: 686, attack_dice: 4, attack_sides: 3, size: 0, side: 10, action_limit: 0, action_time: 1500, resist_magic: 45, magic_level: 3, day_of_week: 10, chat_msg: 0, target_search_range: 6, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 150, magic_hit_ratio: 10, attack_range: 1} - - {name: Zombie, sprite_id: 18, hit_dice: 10, defense_ratio: 80, hit_ratio: 90, min_bravery: 100, exp_dice: 282, attack_dice: 4, attack_sides: 4, size: 0, side: 10, action_limit: 0, action_time: 1500, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 6, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Scorpion, sprite_id: 17, hit_dice: 6, defense_ratio: 70, hit_ratio: 80, min_bravery: 3, exp_dice: 282, attack_dice: 5, attack_sides: 3, size: 0, side: 10, action_limit: 0, action_time: 1200, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 4, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Skeleton, sprite_id: 11, hit_dice: 8, defense_ratio: 80, hit_ratio: 100, min_bravery: 100, exp_dice: 378, attack_dice: 5, attack_sides: 4, size: 0, side: 10, action_limit: 0, action_time: 800, resist_magic: 40, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 5, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 2} - - {name: Giant-Frog, sprite_id: 57, hit_dice: 35, defense_ratio: 90, hit_ratio: 170, min_bravery: 4, exp_dice: 1402, attack_dice: 7, attack_sides: 5, size: 0, side: 10, action_limit: 0, action_time: 900, resist_magic: 60, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 20, attack_range: 2} - - {name: Stone-Golem, sprite_id: 12, hit_dice: 25, defense_ratio: 110, hit_ratio: 150, min_bravery: 100, exp_dice: 930, attack_dice: 7, attack_sides: 4, size: 1, side: 10, action_limit: 0, action_time: 1200, resist_magic: 50, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 5, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Clay-Golem, sprite_id: 23, hit_dice: 30, defense_ratio: 100, hit_ratio: 150, min_bravery: 100, exp_dice: 852, attack_dice: 7, attack_sides: 4, size: 1, side: 10, action_limit: 0, action_time: 1200, resist_magic: 50, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 5, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Hellbound, sprite_id: 27, hit_dice: 35, defense_ratio: 90, hit_ratio: 170, min_bravery: 4, exp_dice: 1202, attack_dice: 7, attack_sides: 5, size: 0, side: 10, action_limit: 0, action_time: 900, resist_magic: 60, magic_level: 5, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 3, abs_damage: 0, max_mana: 250, magic_hit_ratio: 20, attack_range: 1} - - {name: Cyclops, sprite_id: 13, hit_dice: 60, defense_ratio: 100, hit_ratio: 180, min_bravery: 100, exp_dice: 2550, attack_dice: 8, attack_sides: 6, size: 1, side: 10, action_limit: 0, action_time: 1200, resist_magic: 65, magic_level: 5, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 3, abs_damage: 0, max_mana: 450, magic_hit_ratio: 50, attack_range: 3} - - {name: Troll, sprite_id: 28, hit_dice: 55, defense_ratio: 85, hit_ratio: 200, min_bravery: 100, exp_dice: 2570, attack_dice: 8, attack_sides: 5, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 60, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Cannibal-Plant, sprite_id: 60, hit_dice: 90, defense_ratio: 80, hit_ratio: 230, min_bravery: 100, exp_dice: 3700, attack_dice: 8, attack_sides: 8, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 70, magic_level: 5, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 300, magic_hit_ratio: 65, attack_range: 2} - - {name: Cannibal_Plant, sprite_id: 60, hit_dice: 90, defense_ratio: 80, hit_ratio: 230, min_bravery: 100, exp_dice: 3700, attack_dice: 8, attack_sides: 8, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 70, magic_level: 5, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 300, magic_hit_ratio: 65, attack_range: 2} - - {name: Orge, sprite_id: 29, hit_dice: 115, defense_ratio: 150, hit_ratio: 230, min_bravery: 100, exp_dice: 4080, attack_dice: 8, attack_sides: 7, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 70, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 3} - - {name: Stalker, sprite_id: 48, hit_dice: 130, defense_ratio: 200, hit_ratio: 300, min_bravery: 100, exp_dice: 5080, attack_dice: 8, attack_sides: 9, size: 1, side: 10, action_limit: 0, action_time: 600, resist_magic: 80, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 20, max_mana: 0, magic_hit_ratio: 0, attack_range: 4} - - {name: WereWolf, sprite_id: 33, hit_dice: 140, defense_ratio: 180, hit_ratio: 300, min_bravery: 100, exp_dice: 5080, attack_dice: 8, attack_sides: 8, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 80, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 20, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Beholder, sprite_id: 53, hit_dice: 100, defense_ratio: 100, hit_ratio: 450, min_bravery: 100, exp_dice: 2800, attack_dice: 8, attack_sides: 8, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 70, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 2, abs_damage: 30, max_mana: 0, magic_hit_ratio: 200, attack_range: 4} - - {name: Dark-Elf, sprite_id: 54, hit_dice: 150, defense_ratio: 200, hit_ratio: 450, min_bravery: 100, exp_dice: 5580, attack_dice: 8, attack_sides: 4, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 60, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 10000, attribute: 1, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: Liche, sprite_id: 30, hit_dice: 130, defense_ratio: 300, hit_ratio: 230, min_bravery: 100, exp_dice: 4520, attack_dice: 8, attack_sides: 6, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 75, magic_level: 6, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 4, abs_damage: 10, max_mana: 1000, magic_hit_ratio: 80, attack_range: 5} - - {name: Demon, sprite_id: 31, hit_dice: 345, defense_ratio: 450, hit_ratio: 500, min_bravery: 100, exp_dice: 12200, attack_dice: 10, attack_sides: 10, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 75, max_mana: 2000, magic_hit_ratio: 250, attack_range: 4} - - {name: Unicorn, sprite_id: 32, hit_dice: 350, defense_ratio: 450, hit_ratio: 500, min_bravery: 100, exp_dice: 12560, attack_dice: 10, attack_sides: 10, size: 1, side: 0, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 8, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 2, abs_damage: 75, max_mana: 2000, magic_hit_ratio: 250, attack_range: 4} - - {name: Rabbit, sprite_id: 55, hit_dice: 10, defense_ratio: 100, hit_ratio: 100, min_bravery: 100, exp_dice: 50, attack_dice: 1, attack_sides: 5, size: 0, side: 0, action_limit: 0, action_time: 1500, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Cat, sprite_id: 56, hit_dice: 4, defense_ratio: 40, hit_ratio: 45, min_bravery: 3, exp_dice: 74, attack_dice: 2, attack_sides: 4, size: 0, side: 0, action_limit: 0, action_time: 1500, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 5, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Dummy, sprite_id: 34, hit_dice: 10, defense_ratio: 30, hit_ratio: 300, min_bravery: 100, exp_dice: 288, attack_dice: 0, attack_sides: 0, size: 1, side: 10, action_limit: 3, action_time: 2100, resist_magic: 5, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Mountain-Giant, sprite_id: 58, hit_dice: 100, defense_ratio: 250, hit_ratio: 230, min_bravery: 100, exp_dice: 4080, attack_dice: 8, attack_sides: 7, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 75, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 20, max_mana: 0, magic_hit_ratio: 0, attack_range: 2} - - {name: Ettin, sprite_id: 59, hit_dice: 180, defense_ratio: 350, hit_ratio: 350, min_bravery: 100, exp_dice: 6440, attack_dice: 8, attack_sides: 9, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 90, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 2, abs_damage: 65, max_mana: 0, magic_hit_ratio: 0, attack_range: 3} - - {name: Tigerworm, sprite_id: 50, hit_dice: 1700, defense_ratio: 550, hit_ratio: 1200, min_bravery: 100, exp_dice: 136000, attack_dice: 18, attack_sides: 17, size: 1, side: 10, action_limit: 0, action_time: 600, resist_magic: 100, magic_level: 9, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 70, max_mana: 16000, magic_hit_ratio: 250, attack_range: 5} - - {name: Hellclaw, sprite_id: 49, hit_dice: 1250, defense_ratio: 450, hit_ratio: 1000, min_bravery: 100, exp_dice: 63200, attack_dice: 15, attack_sides: 14, size: 1, side: 10, action_limit: 0, action_time: 600, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 5} - - {name: ShopKeeper-W, sprite_id: 15, hit_dice: 10, defense_ratio: 10, hit_ratio: 20, min_bravery: 5, exp_dice: 1, attack_dice: 1, attack_sides: 1, size: 0, side: 0, action_limit: 2, action_time: 15000, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 1, target_search_range: 0, regen_time: 10000, attribute: 0, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Gandlf, sprite_id: 19, hit_dice: 10, defense_ratio: 10, hit_ratio: 20, min_bravery: 5, exp_dice: 1, attack_dice: 1, attack_sides: 1, size: 0, side: 0, action_limit: 2, action_time: 5000, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 1, target_search_range: 0, regen_time: 10000, attribute: 0, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Howard, sprite_id: 20, hit_dice: 10, defense_ratio: 10, hit_ratio: 20, min_bravery: 5, exp_dice: 1, attack_dice: 1, attack_sides: 1, size: 0, side: 0, action_limit: 2, action_time: 30000, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 1, target_search_range: 0, regen_time: 10000, attribute: 0, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Tom, sprite_id: 24, hit_dice: 10, defense_ratio: 10, hit_ratio: 20, min_bravery: 1, exp_dice: 1, attack_dice: 1, attack_sides: 1, size: 0, side: 0, action_limit: 2, action_time: 5000, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 1, target_search_range: 0, regen_time: 10000, attribute: 0, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: William, sprite_id: 25, hit_dice: 10, defense_ratio: 10, hit_ratio: 20, min_bravery: 1, exp_dice: 1, attack_dice: 1, attack_sides: 1, size: 0, side: 0, action_limit: 2, action_time: 5000, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 1, target_search_range: 0, regen_time: 10000, attribute: 0, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Kennedy, sprite_id: 26, hit_dice: 10, defense_ratio: 10, hit_ratio: 20, min_bravery: 1, exp_dice: 1, attack_dice: 1, attack_sides: 1, size: 0, side: 0, action_limit: 2, action_time: 5000, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 1, target_search_range: 0, regen_time: 10000, attribute: 0, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Gagoyle, sprite_id: 52, hit_dice: 460, defense_ratio: 450, hit_ratio: 500, min_bravery: 100, exp_dice: 31120, attack_dice: 13, attack_sides: 10, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 70, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 80, max_mana: 2000, magic_hit_ratio: 250, attack_range: 5} - - {name: Rudolph, sprite_id: 61, hit_dice: 40, defense_ratio: 100, hit_ratio: 180, min_bravery: 10, exp_dice: 3060, attack_dice: 7, attack_sides: 5, size: 0, side: 10, action_limit: 0, action_time: 1000, resist_magic: 60, magic_level: 5, day_of_week: 10, chat_msg: 0, target_search_range: 5, regen_time: 10000, attribute: 3, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Frost, sprite_id: 63, hit_dice: 115, defense_ratio: 150, hit_ratio: 230, min_bravery: 100, exp_dice: 2100, attack_dice: 8, attack_sides: 7, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 70, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 3} - - {name: DireBoar, sprite_id: 62, hit_dice: 60, defense_ratio: 100, hit_ratio: 180, min_bravery: 100, exp_dice: 1020, attack_dice: 8, attack_sides: 6, size: 1, side: 10, action_limit: 0, action_time: 1200, resist_magic: 65, magic_level: 5, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 3, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 3} - - {name: Ice, sprite_id: 65, hit_dice: 155, defense_ratio: 180, hit_ratio: 335, min_bravery: 100, exp_dice: 4000, attack_dice: 9, attack_sides: 5, size: 1, side: 10, action_limit: 0, action_time: 1000, resist_magic: 60, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 10000, attribute: 1, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Wyvern, sprite_id: 66, hit_dice: 2200, defense_ratio: 1200, hit_ratio: 2000, min_bravery: 100, exp_dice: 20000, attack_dice: 25, attack_sides: 20, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 150, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} - - {name: Barlog, sprite_id: 70, hit_dice: 140, defense_ratio: 280, hit_ratio: 350, min_bravery: 100, exp_dice: 3000, attack_dice: 9, attack_sides: 3, size: 1, side: 10, action_limit: 0, action_time: 900, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} - - {name: Centaurus, sprite_id: 71, hit_dice: 200, defense_ratio: 300, hit_ratio: 260, min_bravery: 100, exp_dice: 2500, attack_dice: 8, attack_sides: 5, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} - - {name: Claw-Turtle, sprite_id: 72, hit_dice: 100, defense_ratio: 180, hit_ratio: 230, min_bravery: 100, exp_dice: 1900, attack_dice: 6, attack_sides: 6, size: 1, side: 10, action_limit: 0, action_time: 1000, resist_magic: 20, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} - - {name: Fire-Wyvern, sprite_id: 73, hit_dice: 3000, defense_ratio: 500, hit_ratio: 2000, min_bravery: 100, exp_dice: 30000, attack_dice: 24, attack_sides: 20, size: 1, side: 10, action_limit: 0, action_time: 500, resist_magic: 100, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} - - {name: Giant-Cray-Fish, sprite_id: 74, hit_dice: 130, defense_ratio: 200, hit_ratio: 200, min_bravery: 100, exp_dice: 2300, attack_dice: 7, attack_sides: 5, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 40, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} - - {name: Lizard, sprite_id: 75, hit_dice: 220, defense_ratio: 320, hit_ratio: 180, min_bravery: 100, exp_dice: 3400, attack_dice: 8, attack_sides: 3, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 50, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} - - {name: Giant-Plant, sprite_id: 76, hit_dice: 120, defense_ratio: 110, hit_ratio: 220, min_bravery: 100, exp_dice: 2200, attack_dice: 5, attack_sides: 2, size: 1, side: 10, action_limit: 0, action_time: 1100, resist_magic: 10, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} - - {name: Master-Mage-Orc, sprite_id: 77, hit_dice: 200, defense_ratio: 300, hit_ratio: 300, min_bravery: 100, exp_dice: 3700, attack_dice: 9, attack_sides: 4, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} - - {name: Minotaurus, sprite_id: 78, hit_dice: 300, defense_ratio: 270, hit_ratio: 300, min_bravery: 100, exp_dice: 4000, attack_dice: 9, attack_sides: 5, size: 1, side: 10, action_limit: 0, action_time: 600, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} - - {name: Nizie, sprite_id: 79, hit_dice: 210, defense_ratio: 240, hit_ratio: 290, min_bravery: 100, exp_dice: 3100, attack_dice: 9, attack_sides: 7, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 40, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} - - {name: Tentocle, sprite_id: 80, hit_dice: 70, defense_ratio: 110, hit_ratio: 150, min_bravery: 100, exp_dice: 1700, attack_dice: 6, attack_sides: 2, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 20, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} - - {name: Abaddon, sprite_id: 81, hit_dice: 5000, defense_ratio: 700, hit_ratio: 3000, min_bravery: 5, exp_dice: 45000, attack_dice: 25, attack_sides: 20, size: 1, side: 10, action_limit: 0, action_time: 400, resist_magic: 100, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} - - {name: Energy-Sphere, sprite_id: 35, hit_dice: 10, defense_ratio: 10, hit_ratio: 10, min_bravery: 100, exp_dice: 0, attack_dice: 0, attack_sides: 0, size: 1, side: 10, action_limit: 4, action_time: 60000, resist_magic: 5, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: Crops, sprite_id: 64, hit_dice: 10, defense_ratio: 10, hit_ratio: 10, min_bravery: 20, exp_dice: 0, attack_dice: 0, attack_sides: 0, size: 1, side: 10, action_limit: 4, action_time: 60000, resist_magic: 5, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} - - {name: AGT-Aresden, sprite_id: 36, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 80, exp_dice: 600, attack_dice: 5, attack_sides: 8, size: 1, side: 1, action_limit: 5, action_time: 600, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: AGT-Elvine, sprite_id: 36, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 80, exp_dice: 600, attack_dice: 5, attack_sides: 8, size: 1, side: 2, action_limit: 5, action_time: 600, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: CGT-Aresden, sprite_id: 37, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 90, exp_dice: 600, attack_dice: 10, attack_sides: 14, size: 1, side: 1, action_limit: 5, action_time: 1800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: CGT-Elvine, sprite_id: 37, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 90, exp_dice: 600, attack_dice: 10, attack_sides: 14, size: 1, side: 2, action_limit: 5, action_time: 1800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: MS-Aresden, sprite_id: 38, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 40, exp_dice: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 1, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: MS-Elvine, sprite_id: 38, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 40, exp_dice: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 2, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: DT-Aresden, sprite_id: 39, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 50, exp_dice: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 1, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: DT-Elvine, sprite_id: 39, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 50, exp_dice: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 2, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: ESG-Aresden, sprite_id: 40, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 60, exp_dice: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 1, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: ESG-Elvine, sprite_id: 40, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 60, exp_dice: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 2, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: GMG-Aresden, sprite_id: 41, hit_dice: 50000, defense_ratio: 5000, hit_ratio: 500, min_bravery: 500, exp_dice: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 1, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 80, magic_hit_ratio: 0, attack_range: 8} - - {name: GMG-Elvine, sprite_id: 41, hit_dice: 50000, defense_ratio: 5000, hit_ratio: 500, min_bravery: 500, exp_dice: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 2, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 80, magic_hit_ratio: 0, attack_range: 8} - - {name: ManaStone, sprite_id: 42, hit_dice: 50000, defense_ratio: 5000, hit_ratio: 500, min_bravery: 500, exp_dice: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 0, action_limit: 5, action_time: 500, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: LWB-Aresden, sprite_id: 43, hit_dice: 250, defense_ratio: 450, hit_ratio: 500, min_bravery: 20, exp_dice: 400, attack_dice: 10, attack_sides: 5, size: 1, side: 1, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: LWB-Elvine, sprite_id: 43, hit_dice: 250, defense_ratio: 450, hit_ratio: 500, min_bravery: 20, exp_dice: 400, attack_dice: 10, attack_sides: 5, size: 1, side: 2, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} - - {name: GHK, sprite_id: 44, hit_dice: 350, defense_ratio: 450, hit_ratio: 500, min_bravery: 20, exp_dice: 600, attack_dice: 10, attack_sides: 9, size: 1, side: 2, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 4} - - {name: GHKABS, sprite_id: 45, hit_dice: 800, defense_ratio: 450, hit_ratio: 1000, min_bravery: 100, exp_dice: 1000, attack_dice: 13, attack_sides: 12, size: 1, side: 2, action_limit: 0, action_time: 600, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 5} - - {name: TK, sprite_id: 46, hit_dice: 350, defense_ratio: 450, hit_ratio: 500, min_bravery: 20, exp_dice: 600, attack_dice: 10, attack_sides: 9, size: 1, side: 1, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 4} - - {name: TempleKnight, sprite_id: 46, hit_dice: 350, defense_ratio: 450, hit_ratio: 500, min_bravery: 20, exp_dice: 600, attack_dice: 10, attack_sides: 9, size: 1, side: 1, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 4} - - {name: BG, sprite_id: 47, hit_dice: 800, defense_ratio: 450, hit_ratio: 1000, min_bravery: 100, exp_dice: 1000, attack_dice: 13, attack_sides: 12, size: 1, side: 1, action_limit: 0, action_time: 600, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 5} - - {name: YB-Aresden, sprite_id: 1, hit_dice: 85, defense_ratio: 150, hit_ratio: 200, min_bravery: 4, exp_dice: 180, attack_dice: 8, attack_sides: 5, size: 1, side: 1, action_limit: 0, action_time: 600, resist_magic: 70, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 3} - - {name: YB-Elvine, sprite_id: 1, hit_dice: 85, defense_ratio: 150, hit_ratio: 200, min_bravery: 4, exp_dice: 180, attack_dice: 8, attack_sides: 5, size: 1, side: 2, action_limit: 0, action_time: 600, resist_magic: 70, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 3} - - {name: CP-Aresden, sprite_id: 51, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 100, exp_dice: 500, attack_dice: 10, attack_sides: 14, size: 1, side: 1, action_limit: 0, action_time: 1600, resist_magic: 100, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 9, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 9} - - {name: CP-Elvine, sprite_id: 51, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 100, exp_dice: 500, attack_dice: 10, attack_sides: 14, size: 1, side: 2, action_limit: 0, action_time: 1600, resist_magic: 50, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 9, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 9} +npcs: + - {name: Attack-Dummy, sprite_id: 34, hit_dice: 10, defense_ratio: 30, hit_ratio: 300, min_bravery: 100, exp: 384, attack_dice: 0, attack_sides: 0, size: 1, side: 10, action_limit: 0, action_time: 2100, resist_magic: 5, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Guard-Aresden, sprite_id: 21, hit_dice: 115, defense_ratio: 150, hit_ratio: 230, min_bravery: 3, exp: 0, attack_dice: 3, attack_sides: 8, size: 0, side: 1, action_limit: 0, action_time: 1000, resist_magic: 100, magic_level: -10, day_of_week: 10, chat_msg: 1, target_search_range: 8, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 1000, magic_hit_ratio: 130, attack_range: 5} + - {name: Guard-Elvine, sprite_id: 21, hit_dice: 115, defense_ratio: 150, hit_ratio: 230, min_bravery: 3, exp: 0, attack_dice: 3, attack_sides: 8, size: 0, side: 2, action_limit: 0, action_time: 1000, resist_magic: 100, magic_level: -10, day_of_week: 10, chat_msg: 1, target_search_range: 8, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 1000, magic_hit_ratio: 130, attack_range: 5} + - {name: Guard-Neutral, sprite_id: 21, hit_dice: 115, defense_ratio: 150, hit_ratio: 230, min_bravery: 3, exp: 0, attack_dice: 3, attack_sides: 8, size: 0, side: 0, action_limit: 0, action_time: 1000, resist_magic: 100, magic_level: -10, day_of_week: 10, chat_msg: 1, target_search_range: 8, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 1000, magic_hit_ratio: 130, attack_range: 5} + - {name: Slime, sprite_id: 10, hit_dice: 2, defense_ratio: 20, hit_ratio: 30, min_bravery: 1, exp: 46, attack_dice: 1, attack_sides: 4, size: 0, side: 10, action_limit: 0, action_time: 2100, resist_magic: 5, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 2, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1, gold_min: 1, gold_max: 10} + - {name: Giant-Ant, sprite_id: 16, hit_dice: 3, defense_ratio: 30, hit_ratio: 40, min_bravery: 2, exp: 74, attack_dice: 2, attack_sides: 3, size: 0, side: 10, action_limit: 0, action_time: 1200, resist_magic: 10, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 2, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1, gold_min: 2, gold_max: 15} + - {name: Amphis, sprite_id: 22, hit_dice: 4, defense_ratio: 60, hit_ratio: 50, min_bravery: 1, exp: 75, attack_dice: 2, attack_sides: 4, size: 0, side: 10, action_limit: 0, action_time: 1000, resist_magic: 20, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 3, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1, gold_min: 3, gold_max: 15} + - {name: Orc, sprite_id: 14, hit_dice: 4, defense_ratio: 75, hit_ratio: 70, min_bravery: 2, exp: 106, attack_dice: 3, attack_sides: 3, size: 0, side: 10, action_limit: 0, action_time: 1200, resist_magic: 25, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 5, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1, gold_min: 5, gold_max: 30} + - {name: Orc-Mage, sprite_id: 14, hit_dice: 12, defense_ratio: 70, hit_ratio: 100, min_bravery: 3, exp: 686, attack_dice: 4, attack_sides: 3, size: 0, side: 10, action_limit: 0, action_time: 1500, resist_magic: 45, magic_level: 3, day_of_week: 10, chat_msg: 0, target_search_range: 6, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 150, magic_hit_ratio: 10, attack_range: 1} + - {name: Zombie, sprite_id: 18, hit_dice: 10, defense_ratio: 80, hit_ratio: 90, min_bravery: 100, exp: 282, attack_dice: 4, attack_sides: 4, size: 0, side: 10, action_limit: 0, action_time: 1500, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 6, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1, gold_min: 8, gold_max: 35} + - {name: Scorpion, sprite_id: 17, hit_dice: 6, defense_ratio: 70, hit_ratio: 80, min_bravery: 3, exp: 282, attack_dice: 5, attack_sides: 3, size: 0, side: 10, action_limit: 0, action_time: 1200, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 4, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1, gold_min: 8, gold_max: 30} + - {name: Skeleton, sprite_id: 11, hit_dice: 8, defense_ratio: 80, hit_ratio: 100, min_bravery: 100, exp: 378, attack_dice: 5, attack_sides: 4, size: 0, side: 10, action_limit: 0, action_time: 800, resist_magic: 40, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 5, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 2, gold_min: 10, gold_max: 40} + - {name: Giant-Frog, sprite_id: 57, hit_dice: 35, defense_ratio: 90, hit_ratio: 170, min_bravery: 4, exp: 1402, attack_dice: 7, attack_sides: 5, size: 0, side: 10, action_limit: 0, action_time: 900, resist_magic: 60, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 20, attack_range: 2} + - {name: Stone-Golem, sprite_id: 12, hit_dice: 25, defense_ratio: 110, hit_ratio: 150, min_bravery: 100, exp: 930, attack_dice: 7, attack_sides: 4, size: 1, side: 10, action_limit: 0, action_time: 1200, resist_magic: 50, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 5, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Clay-Golem, sprite_id: 23, hit_dice: 30, defense_ratio: 100, hit_ratio: 150, min_bravery: 100, exp: 852, attack_dice: 7, attack_sides: 4, size: 1, side: 10, action_limit: 0, action_time: 1200, resist_magic: 50, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 5, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Hellbound, sprite_id: 27, hit_dice: 35, defense_ratio: 90, hit_ratio: 170, min_bravery: 4, exp: 1202, attack_dice: 7, attack_sides: 5, size: 0, side: 10, action_limit: 0, action_time: 900, resist_magic: 60, magic_level: 5, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 3, abs_damage: 0, max_mana: 250, magic_hit_ratio: 20, attack_range: 1} + - {name: Cyclops, sprite_id: 13, hit_dice: 60, defense_ratio: 100, hit_ratio: 180, min_bravery: 100, exp: 2550, attack_dice: 8, attack_sides: 6, size: 1, side: 10, action_limit: 0, action_time: 1200, resist_magic: 65, magic_level: 5, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 3, abs_damage: 0, max_mana: 450, magic_hit_ratio: 50, attack_range: 3} + - {name: Troll, sprite_id: 28, hit_dice: 55, defense_ratio: 85, hit_ratio: 200, min_bravery: 100, exp: 2570, attack_dice: 8, attack_sides: 5, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 60, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Cannibal-Plant, sprite_id: 60, hit_dice: 90, defense_ratio: 80, hit_ratio: 230, min_bravery: 100, exp: 3700, attack_dice: 8, attack_sides: 8, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 70, magic_level: 5, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 300, magic_hit_ratio: 65, attack_range: 2} + - {name: Cannibal_Plant, sprite_id: 60, hit_dice: 90, defense_ratio: 80, hit_ratio: 230, min_bravery: 100, exp: 3700, attack_dice: 8, attack_sides: 8, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 70, magic_level: 5, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 300, magic_hit_ratio: 65, attack_range: 2} + - {name: Orge, sprite_id: 29, hit_dice: 115, defense_ratio: 150, hit_ratio: 230, min_bravery: 100, exp: 4080, attack_dice: 8, attack_sides: 7, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 70, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 3} + - {name: Stalker, sprite_id: 48, hit_dice: 130, defense_ratio: 200, hit_ratio: 300, min_bravery: 100, exp: 5080, attack_dice: 8, attack_sides: 9, size: 1, side: 10, action_limit: 0, action_time: 600, resist_magic: 80, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 20, max_mana: 0, magic_hit_ratio: 0, attack_range: 4} + - {name: WereWolf, sprite_id: 33, hit_dice: 140, defense_ratio: 180, hit_ratio: 300, min_bravery: 100, exp: 5080, attack_dice: 8, attack_sides: 8, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 80, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 20, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Beholder, sprite_id: 53, hit_dice: 100, defense_ratio: 100, hit_ratio: 450, min_bravery: 100, exp: 2800, attack_dice: 8, attack_sides: 8, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 70, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 2, abs_damage: 30, max_mana: 0, magic_hit_ratio: 200, attack_range: 4} + - {name: Dark-Elf, sprite_id: 54, hit_dice: 150, defense_ratio: 200, hit_ratio: 450, min_bravery: 100, exp: 5580, attack_dice: 8, attack_sides: 4, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 60, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 10000, attribute: 1, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: Liche, sprite_id: 30, hit_dice: 130, defense_ratio: 300, hit_ratio: 230, min_bravery: 100, exp: 4520, attack_dice: 8, attack_sides: 6, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 75, magic_level: 6, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 4, abs_damage: 10, max_mana: 1000, magic_hit_ratio: 80, attack_range: 5} + - {name: Demon, sprite_id: 31, hit_dice: 345, defense_ratio: 450, hit_ratio: 500, min_bravery: 100, exp: 12200, attack_dice: 10, attack_sides: 10, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 75, max_mana: 2000, magic_hit_ratio: 250, attack_range: 4} + - {name: Unicorn, sprite_id: 32, hit_dice: 350, defense_ratio: 450, hit_ratio: 500, min_bravery: 100, exp: 12560, attack_dice: 10, attack_sides: 10, size: 1, side: 0, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 8, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 2, abs_damage: 75, max_mana: 2000, magic_hit_ratio: 250, attack_range: 4} + - {name: Rabbit, sprite_id: 55, hit_dice: 10, defense_ratio: 100, hit_ratio: 100, min_bravery: 100, exp: 50, attack_dice: 1, attack_sides: 5, size: 0, side: 0, action_limit: 0, action_time: 1500, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1, gold_min: 1, gold_max: 5} + - {name: Cat, sprite_id: 56, hit_dice: 4, defense_ratio: 40, hit_ratio: 45, min_bravery: 3, exp: 74, attack_dice: 2, attack_sides: 4, size: 0, side: 0, action_limit: 0, action_time: 1500, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 5, regen_time: 5000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1, gold_min: 1, gold_max: 8} + - {name: Dummy, sprite_id: 34, hit_dice: 10, defense_ratio: 30, hit_ratio: 300, min_bravery: 100, exp: 288, attack_dice: 0, attack_sides: 0, size: 1, side: 10, action_limit: 3, action_time: 2100, resist_magic: 5, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Mountain-Giant, sprite_id: 58, hit_dice: 100, defense_ratio: 250, hit_ratio: 230, min_bravery: 100, exp: 4080, attack_dice: 8, attack_sides: 7, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 75, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 1, abs_damage: 20, max_mana: 0, magic_hit_ratio: 0, attack_range: 2} + - {name: Ettin, sprite_id: 59, hit_dice: 180, defense_ratio: 350, hit_ratio: 350, min_bravery: 100, exp: 6440, attack_dice: 8, attack_sides: 9, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 90, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 2, abs_damage: 65, max_mana: 0, magic_hit_ratio: 0, attack_range: 3} + - {name: Tigerworm, sprite_id: 50, hit_dice: 1700, defense_ratio: 550, hit_ratio: 1200, min_bravery: 100, exp: 136000, attack_dice: 18, attack_sides: 17, size: 1, side: 10, action_limit: 0, action_time: 600, resist_magic: 100, magic_level: 9, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 70, max_mana: 16000, magic_hit_ratio: 250, attack_range: 5} + - {name: Hellclaw, sprite_id: 49, hit_dice: 1250, defense_ratio: 450, hit_ratio: 1000, min_bravery: 100, exp: 63200, attack_dice: 15, attack_sides: 14, size: 1, side: 10, action_limit: 0, action_time: 600, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 5} + - {name: ShopKeeper-W, sprite_id: 15, hit_dice: 10, defense_ratio: 10, hit_ratio: 20, min_bravery: 5, exp: 1, attack_dice: 1, attack_sides: 1, size: 0, side: 0, action_limit: 2, action_time: 15000, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 1, target_search_range: 0, regen_time: 10000, attribute: 0, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Gandlf, sprite_id: 19, hit_dice: 10, defense_ratio: 10, hit_ratio: 20, min_bravery: 5, exp: 1, attack_dice: 1, attack_sides: 1, size: 0, side: 0, action_limit: 2, action_time: 5000, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 1, target_search_range: 0, regen_time: 10000, attribute: 0, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Howard, sprite_id: 20, hit_dice: 10, defense_ratio: 10, hit_ratio: 20, min_bravery: 5, exp: 1, attack_dice: 1, attack_sides: 1, size: 0, side: 0, action_limit: 2, action_time: 30000, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 1, target_search_range: 0, regen_time: 10000, attribute: 0, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Tom, sprite_id: 24, hit_dice: 10, defense_ratio: 10, hit_ratio: 20, min_bravery: 1, exp: 1, attack_dice: 1, attack_sides: 1, size: 0, side: 0, action_limit: 2, action_time: 5000, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 1, target_search_range: 0, regen_time: 10000, attribute: 0, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: William, sprite_id: 25, hit_dice: 10, defense_ratio: 10, hit_ratio: 20, min_bravery: 1, exp: 1, attack_dice: 1, attack_sides: 1, size: 0, side: 0, action_limit: 2, action_time: 5000, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 1, target_search_range: 0, regen_time: 10000, attribute: 0, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Kennedy, sprite_id: 26, hit_dice: 10, defense_ratio: 10, hit_ratio: 20, min_bravery: 1, exp: 1, attack_dice: 1, attack_sides: 1, size: 0, side: 0, action_limit: 2, action_time: 5000, resist_magic: 0, magic_level: 0, day_of_week: 10, chat_msg: 1, target_search_range: 0, regen_time: 10000, attribute: 0, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Gagoyle, sprite_id: 52, hit_dice: 460, defense_ratio: 450, hit_ratio: 500, min_bravery: 100, exp: 31120, attack_dice: 13, attack_sides: 10, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 70, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 80, max_mana: 2000, magic_hit_ratio: 250, attack_range: 5} + - {name: Rudolph, sprite_id: 61, hit_dice: 40, defense_ratio: 100, hit_ratio: 180, min_bravery: 10, exp: 3060, attack_dice: 7, attack_sides: 5, size: 0, side: 10, action_limit: 0, action_time: 1000, resist_magic: 60, magic_level: 5, day_of_week: 10, chat_msg: 0, target_search_range: 5, regen_time: 10000, attribute: 3, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Frost, sprite_id: 63, hit_dice: 115, defense_ratio: 150, hit_ratio: 230, min_bravery: 100, exp: 2100, attack_dice: 8, attack_sides: 7, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 70, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 3} + - {name: DireBoar, sprite_id: 62, hit_dice: 60, defense_ratio: 100, hit_ratio: 180, min_bravery: 100, exp: 1020, attack_dice: 8, attack_sides: 6, size: 1, side: 10, action_limit: 0, action_time: 1200, resist_magic: 65, magic_level: 5, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 3, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 3} + - {name: Ice, sprite_id: 65, hit_dice: 155, defense_ratio: 180, hit_ratio: 335, min_bravery: 100, exp: 4000, attack_dice: 9, attack_sides: 5, size: 1, side: 10, action_limit: 0, action_time: 1000, resist_magic: 60, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 10000, attribute: 1, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Wyvern, sprite_id: 66, hit_dice: 2200, defense_ratio: 1200, hit_ratio: 2000, min_bravery: 100, exp: 20000, attack_dice: 25, attack_sides: 20, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 150, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} + - {name: Barlog, sprite_id: 70, hit_dice: 140, defense_ratio: 280, hit_ratio: 350, min_bravery: 100, exp: 3000, attack_dice: 9, attack_sides: 3, size: 1, side: 10, action_limit: 0, action_time: 900, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} + - {name: Centaurus, sprite_id: 71, hit_dice: 200, defense_ratio: 300, hit_ratio: 260, min_bravery: 100, exp: 2500, attack_dice: 8, attack_sides: 5, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} + - {name: Claw-Turtle, sprite_id: 72, hit_dice: 100, defense_ratio: 180, hit_ratio: 230, min_bravery: 100, exp: 1900, attack_dice: 6, attack_sides: 6, size: 1, side: 10, action_limit: 0, action_time: 1000, resist_magic: 20, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} + - {name: Fire-Wyvern, sprite_id: 73, hit_dice: 3000, defense_ratio: 500, hit_ratio: 2000, min_bravery: 100, exp: 30000, attack_dice: 24, attack_sides: 20, size: 1, side: 10, action_limit: 0, action_time: 500, resist_magic: 100, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} + - {name: Giant-Cray-Fish, sprite_id: 74, hit_dice: 130, defense_ratio: 200, hit_ratio: 200, min_bravery: 100, exp: 2300, attack_dice: 7, attack_sides: 5, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 40, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} + - {name: Lizard, sprite_id: 75, hit_dice: 220, defense_ratio: 320, hit_ratio: 180, min_bravery: 100, exp: 3400, attack_dice: 8, attack_sides: 3, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 50, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} + - {name: Giant-Plant, sprite_id: 76, hit_dice: 120, defense_ratio: 110, hit_ratio: 220, min_bravery: 100, exp: 2200, attack_dice: 5, attack_sides: 2, size: 1, side: 10, action_limit: 0, action_time: 1100, resist_magic: 10, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} + - {name: Master-Mage-Orc, sprite_id: 77, hit_dice: 200, defense_ratio: 300, hit_ratio: 300, min_bravery: 100, exp: 3700, attack_dice: 9, attack_sides: 4, size: 1, side: 10, action_limit: 0, action_time: 700, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} + - {name: Minotaurus, sprite_id: 78, hit_dice: 300, defense_ratio: 270, hit_ratio: 300, min_bravery: 100, exp: 4000, attack_dice: 9, attack_sides: 5, size: 1, side: 10, action_limit: 0, action_time: 600, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} + - {name: Nizie, sprite_id: 79, hit_dice: 210, defense_ratio: 240, hit_ratio: 290, min_bravery: 100, exp: 3100, attack_dice: 9, attack_sides: 7, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 40, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} + - {name: Tentocle, sprite_id: 80, hit_dice: 70, defense_ratio: 110, hit_ratio: 150, min_bravery: 100, exp: 1700, attack_dice: 6, attack_sides: 2, size: 1, side: 10, action_limit: 0, action_time: 800, resist_magic: 20, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} + - {name: Abaddon, sprite_id: 81, hit_dice: 5000, defense_ratio: 700, hit_ratio: 3000, min_bravery: 5, exp: 45000, attack_dice: 25, attack_sides: 20, size: 1, side: 10, action_limit: 0, action_time: 400, resist_magic: 100, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 100000, attribute: 2, abs_damage: 87, max_mana: 9000, magic_hit_ratio: 250, attack_range: 6} + - {name: Energy-Sphere, sprite_id: 35, hit_dice: 10, defense_ratio: 10, hit_ratio: 10, min_bravery: 100, exp: 0, attack_dice: 0, attack_sides: 0, size: 1, side: 10, action_limit: 4, action_time: 60000, resist_magic: 5, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: Crops, sprite_id: 64, hit_dice: 10, defense_ratio: 10, hit_ratio: 10, min_bravery: 20, exp: 0, attack_dice: 0, attack_sides: 0, size: 1, side: 10, action_limit: 4, action_time: 60000, resist_magic: 5, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 1} + - {name: AGT-Aresden, sprite_id: 36, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 80, exp: 600, attack_dice: 5, attack_sides: 8, size: 1, side: 1, action_limit: 5, action_time: 600, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: AGT-Elvine, sprite_id: 36, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 80, exp: 600, attack_dice: 5, attack_sides: 8, size: 1, side: 2, action_limit: 5, action_time: 600, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: CGT-Aresden, sprite_id: 37, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 90, exp: 600, attack_dice: 10, attack_sides: 14, size: 1, side: 1, action_limit: 5, action_time: 1800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: CGT-Elvine, sprite_id: 37, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 90, exp: 600, attack_dice: 10, attack_sides: 14, size: 1, side: 2, action_limit: 5, action_time: 1800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: MS-Aresden, sprite_id: 38, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 40, exp: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 1, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: MS-Elvine, sprite_id: 38, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 40, exp: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 2, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: DT-Aresden, sprite_id: 39, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 50, exp: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 1, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: DT-Elvine, sprite_id: 39, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 50, exp: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 2, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: ESG-Aresden, sprite_id: 40, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 60, exp: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 1, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: ESG-Elvine, sprite_id: 40, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 60, exp: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 2, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: GMG-Aresden, sprite_id: 41, hit_dice: 50000, defense_ratio: 5000, hit_ratio: 500, min_bravery: 500, exp: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 1, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 80, magic_hit_ratio: 0, attack_range: 8} + - {name: GMG-Elvine, sprite_id: 41, hit_dice: 50000, defense_ratio: 5000, hit_ratio: 500, min_bravery: 500, exp: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 2, action_limit: 5, action_time: 800, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 80, magic_hit_ratio: 0, attack_range: 8} + - {name: ManaStone, sprite_id: 42, hit_dice: 50000, defense_ratio: 5000, hit_ratio: 500, min_bravery: 500, exp: 600, attack_dice: 0, attack_sides: 0, size: 1, side: 0, action_limit: 5, action_time: 500, resist_magic: 30, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 8, regen_time: 3000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: LWB-Aresden, sprite_id: 43, hit_dice: 250, defense_ratio: 450, hit_ratio: 500, min_bravery: 20, exp: 400, attack_dice: 10, attack_sides: 5, size: 1, side: 1, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: LWB-Elvine, sprite_id: 43, hit_dice: 250, defense_ratio: 450, hit_ratio: 500, min_bravery: 20, exp: 400, attack_dice: 10, attack_sides: 5, size: 1, side: 2, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 8} + - {name: GHK, sprite_id: 44, hit_dice: 350, defense_ratio: 450, hit_ratio: 500, min_bravery: 20, exp: 600, attack_dice: 10, attack_sides: 9, size: 1, side: 2, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 4} + - {name: GHKABS, sprite_id: 45, hit_dice: 800, defense_ratio: 450, hit_ratio: 1000, min_bravery: 100, exp: 1000, attack_dice: 13, attack_sides: 12, size: 1, side: 2, action_limit: 0, action_time: 600, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 5} + - {name: TK, sprite_id: 46, hit_dice: 350, defense_ratio: 450, hit_ratio: 500, min_bravery: 20, exp: 600, attack_dice: 10, attack_sides: 9, size: 1, side: 1, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 4} + - {name: TempleKnight, sprite_id: 46, hit_dice: 350, defense_ratio: 450, hit_ratio: 500, min_bravery: 20, exp: 600, attack_dice: 10, attack_sides: 9, size: 1, side: 1, action_limit: 0, action_time: 700, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 4} + - {name: BG, sprite_id: 47, hit_dice: 800, defense_ratio: 450, hit_ratio: 1000, min_bravery: 100, exp: 1000, attack_dice: 13, attack_sides: 12, size: 1, side: 1, action_limit: 0, action_time: 600, resist_magic: 100, magic_level: 7, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 60000, attribute: 3, abs_damage: 50, max_mana: 0, magic_hit_ratio: 0, attack_range: 5} + - {name: YB-Aresden, sprite_id: 1, hit_dice: 85, defense_ratio: 150, hit_ratio: 200, min_bravery: 4, exp: 180, attack_dice: 8, attack_sides: 5, size: 1, side: 1, action_limit: 0, action_time: 600, resist_magic: 70, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 3} + - {name: YB-Elvine, sprite_id: 1, hit_dice: 85, defense_ratio: 150, hit_ratio: 200, min_bravery: 4, exp: 180, attack_dice: 8, attack_sides: 5, size: 1, side: 2, action_limit: 0, action_time: 600, resist_magic: 70, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 7, regen_time: 10000, attribute: 2, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 3} + - {name: CP-Aresden, sprite_id: 51, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 100, exp: 500, attack_dice: 10, attack_sides: 14, size: 1, side: 1, action_limit: 0, action_time: 1600, resist_magic: 100, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 9, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 9} + - {name: CP-Elvine, sprite_id: 51, hit_dice: 500, defense_ratio: 50, hit_ratio: 500, min_bravery: 100, exp: 500, attack_dice: 10, attack_sides: 14, size: 1, side: 2, action_limit: 0, action_time: 1600, resist_magic: 50, magic_level: 0, day_of_week: 10, chat_msg: 0, target_search_range: 9, regen_time: 10000, attribute: 1, abs_damage: 0, max_mana: 0, magic_hit_ratio: 0, attack_range: 9} diff --git a/bin/game_configs/shops.yaml b/bin/game_configs/shops.yaml index 4128caa..876fd14 100644 --- a/bin/game_configs/shops.yaml +++ b/bin/game_configs/shops.yaml @@ -1,4 +1,4 @@ -# shops.yaml +# shops.yaml # NPC shop configurations # buy_categories: item_category values the shop will purchase from players # repair_categories: item_category values the shop can repair @@ -19,18 +19,18 @@ shops: buy_categories: [10, 11, 12, 13, 14, 30, 31, 32, 99] repair_categories: [11, 12, 13, 14] items: - - { item_id: 308, count: 1 } # HealthPotion - - { item_id: 309, count: 1 } # ManaPotion - - { item_id: 310, count: 1 } # StaminaPotion + - { item_id: 91, count: 1 } # RedPotion (HP) + - { item_id: 93, count: 1 } # BluePotion (MP) + - { item_id: 95, count: 1 } # GreenPotion (SP) ShopKeeper-E: type: general buy_categories: [10, 11, 12, 13, 14, 30, 31, 32, 99] repair_categories: [11, 12, 13, 14] items: - - { item_id: 308, count: 1 } # HealthPotion - - { item_id: 309, count: 1 } # ManaPotion - - { item_id: 310, count: 1 } # StaminaPotion + - { item_id: 91, count: 1 } # RedPotion (HP) + - { item_id: 93, count: 1 } # BluePotion (MP) + - { item_id: 95, count: 1 } # GreenPotion (SP) # Blacksmiths - buy/sell/repair weapons and armor Gandlf: @@ -39,8 +39,8 @@ shops: repair_categories: [1, 2, 3, 4, 5, 6, 10, 11, 12, 13, 14, 15] items: - { item_id: 1 } # Dagger - - { item_id: 2 } # ShortSword - - { item_id: 3 } # MainGauche + - { item_id: 8 } # ShortSword + - { item_id: 12 } # MainGauche William: type: blacksmith @@ -48,5 +48,5 @@ shops: repair_categories: [1, 2, 3, 4, 5, 6, 10, 11, 12, 13, 14, 15] items: - { item_id: 1 } # Dagger - - { item_id: 2 } # ShortSword - - { item_id: 3 } # MainGauche + - { item_id: 8 } # ShortSword + - { item_id: 12 } # MainGauche diff --git a/bin/mapdata/aresden.yaml b/bin/mapdata/aresden.yaml new file mode 100644 index 0000000..5c1e2da --- /dev/null +++ b/bin/mapdata/aresden.yaml @@ -0,0 +1,19 @@ +# Map config for 'aresden' — cidade de Aresden habitável para bots +# Área validada por tools/bot/scan-map.mjs: 200-260 x 180-260 (>99% andável) +# npc_type: 10 = Slime, 14 = Orc, 16 = Giant-Ant, 15 = ShopKeeper-W, 19 = Gandlf + +name: "Aresden" + +initial_points: + - { id: 0, x: 240, y: 220 } + - { id: 1, x: 235, y: 225 } + - { id: 2, x: 245, y: 225 } + +spawners: + # Densidade para ~25 bots + - { id: 1, type: 0, x1: 205, y1: 185, x2: 255, y2: 255, npc_type: 10, max_count: 60 } + - { id: 2, type: 0, x1: 205, y1: 185, x2: 255, y2: 255, npc_type: 16, max_count: 30 } + - { id: 3, type: 0, x1: 210, y1: 190, x2: 250, y2: 250, npc_type: 14, max_count: 20 } + # Mercadores fixos perto do ponto inicial + - { id: 4, type: 0, x1: 236, y1: 216, x2: 236, y2: 216, npc_type: 15, max_count: 1 } + - { id: 5, type: 0, x1: 244, y1: 216, x2: 244, y2: 216, npc_type: 19, max_count: 1 } diff --git a/bin/mapdata/default.yaml b/bin/mapdata/default.yaml new file mode 100644 index 0000000..fd7c586 --- /dev/null +++ b/bin/mapdata/default.yaml @@ -0,0 +1,17 @@ +# Map config for 'default' — dev/bot testing map +# npc_type uses legacy spot-mob codes (see src/npc/spot_mob_mapping.h): +# 10 = Slime, 14 = Orc, 16 = Giant-Ant, 15 = ShopKeeper-W, 19 = Gandlf + +name: "Default" + +initial_points: + - { id: 0, x: 85, y: 85 } + +spawners: + # Densidade para ~10 bots caçando simultaneamente (faixa 55-115 testada como andável) + - { id: 1, type: 0, x1: 60, y1: 60, x2: 110, y2: 110, npc_type: 10, max_count: 30 } + - { id: 2, type: 0, x1: 55, y1: 55, x2: 115, y2: 115, npc_type: 16, max_count: 15 } + - { id: 3, type: 0, x1: 60, y1: 60, x2: 110, y2: 110, npc_type: 14, max_count: 10 } + # Mercadores (fixos, 1 de cada): loja geral (poções) e ferreiro (armas/reparo) + - { id: 4, type: 0, x1: 82, y1: 80, x2: 82, y2: 80, npc_type: 15, max_count: 1 } + - { id: 5, type: 0, x1: 88, y1: 80, x2: 88, y2: 80, npc_type: 19, max_count: 1 } diff --git a/bin/mapdata/elvine.yaml b/bin/mapdata/elvine.yaml new file mode 100644 index 0000000..45925ef --- /dev/null +++ b/bin/mapdata/elvine.yaml @@ -0,0 +1,19 @@ +# Map config for 'elvine' — cidade de Elvine habitável para bots +# Área validada por tools/bot/scan-map.mjs: 40-140 x 180-260 (>98% andável) +# npc_type: 10 = Slime, 14 = Orc, 16 = Giant-Ant, 15 = ShopKeeper-W, 19 = Gandlf + +name: "Elvine" + +initial_points: + - { id: 0, x: 60, y: 200 } + - { id: 1, x: 55, y: 205 } + - { id: 2, x: 65, y: 205 } + +spawners: + # Densidade para ~25 bots + - { id: 1, type: 0, x1: 45, y1: 185, x2: 115, y2: 255, npc_type: 10, max_count: 60 } + - { id: 2, type: 0, x1: 45, y1: 185, x2: 115, y2: 255, npc_type: 16, max_count: 30 } + - { id: 3, type: 0, x1: 50, y1: 190, x2: 110, y2: 250, npc_type: 14, max_count: 20 } + # Mercadores fixos perto do ponto inicial + - { id: 4, type: 0, x1: 56, y1: 196, x2: 56, y2: 196, npc_type: 15, max_count: 1 } + - { id: 5, type: 0, x1: 64, y1: 196, x2: 64, y2: 196, npc_type: 19, max_count: 1 } diff --git a/docs/BOT_SYSTEM_PLAN.md b/docs/BOT_SYSTEM_PLAN.md new file mode 100644 index 0000000..8c87425 --- /dev/null +++ b/docs/BOT_SYSTEM_PLAN.md @@ -0,0 +1,150 @@ +# Plano: Sistema de Bots Autônomos (bot_system) + +> **Status:** em implementação. Arquitetura escolhida: **cliente headless** (`tools/bot/bot.mjs`, Node, +> protocolo WebSocket JSON) em vez do subsistema C++ — zero mudança de arquitetura no servidor e +> visibilidade de graça. `node bot.mjs all` roda todos os bots de `bots.json` num único processo. +> +> **Concluído:** Fase 1 (login/conta/personagem), Fase 3 núcleo (caça, combate, fuga/recuperação, +> respawn, loot, level-up com XP real), Fase 4 (party via protocolo JSON novo — invite/accept/leave/ +> update, XP compartilhado equal_split; líder convida, membros aceitam e seguem o líder), Fase 5 +> (ouro de loot, lógica de loja: comprar poções/arma, vender sobras, reparar, equipar). +> +> **Correções de servidor feitas no caminho:** party no protocolo JSON (não existia); ouro de loot +> creditado ao entity id do ECS em vez do player_id (inventário fantasma); `npc_registry` YAML não +> lia `gold_min/gold_max`; `npcs.yaml` usava `exp_dice:`/loja com item ids errados; spot codes de +> mercadores (15/19) para spawners de mapa. Ver PROGRESS.md 2026-08-30. +> +> **Pendências:** magias (Fase 3), auto-alocação de stat points no level-up, chaves ainda divergentes +> em npcs.yaml (`defense_ratio` vs `defense`, `size` vs `body_size`), equip responde ack v2 sem eco de +> seq (bot usa fire-and-forget), remover LOG_INFO temporário "loot roll" em game_handlers_npc.cpp +> antes de escalar, Fase 6 (escala 10 → 50 → 200 + correções O(n) da Fase 0). +> **Objetivo:** ~200 bots 100% autônomos no mapa: upam, tomam poção automática, usam magias, formam party, matam inimigos, compram itens e reparam equipamentos. + +## Premissas verificadas no código (2026-08-30) + +1. **O servidor real é a árvore moderna `src/`** (C++20, namespace `hb`). O código legado + (`Game.cpp`, `Client.h`, `Portion.cpp`, `XSocket`) está desabilitado no `CMakeLists.txt` + (linhas ~294–317) e não compila. Todo o plano é contra `src/`. +2. **Jogador sem conexão é um estado válido:** + - `player_system::create_player()` não toca em rede; `bind_connection()` é passo separado e opcional. + - Broadcast ignora `connection == 0` — `src/bridge/handlers/broadcast_util.cpp:27`. + - Auto-save ignora `connection == 0` — `src/bridge/handlers/auth_handlers.cpp:1955`. + - Logo: **bot = `player` real sem `bind_connection()`**. Nada quebra, nada vaza para o banco. +3. **Todas as ações necessárias já existem como API pura, sem rede** — os testes + (`tests/test_melee_pve.cpp`, `tests/test_party_exp_share.cpp`) já criam players e rodam + combate/party inteiros sem nenhuma conexão. + +## Arquitetura + +**Modelo mental: o bot é um NPC com corpo de `player`.** + +- Novo subsistema `src/bot/bot_system.{h,cpp}` herdando `hb::subsystem`, registrado via + `subsystems().create_subsystem()` em `application.cpp` → recebe + `update(delta_time)` no `on_tick()` a 50 Hz (tick de 20 ms). +- Máquina de estados por bot, copiando o padrão de `npc_system` / `ai_behavior.h` + (`ai_state`: idle → hunt → combat → flee → town → regroup). +- "Think interval" de 100–250 ms **com offset aleatório por bot** (não pensar todos no mesmo + tick). Precedente: `ai_config::think_interval_ms{500}` e o acumulador de + `npc_system::update_all_ai()` (`npc_system.cpp:951`, intervalo de 100 ms). + +### Mapa requisito → API existente + +| Requisito | Chamada direta (sem handler, sem `ws_connection`) | +|---|---| +| Andar / caçar | `player_system::try_move(pid, pos, dir)` — `src/player/player_system.h:157` (colisão completa, retorna `move_result` com teleport) | +| Matar inimigos | `combat_system::process_attack(attack_event{...})` — `src/combat/combat_system.h:59` | +| Usar magias | `magic_system::instant_cast(ecs_entity, spell, target)` — `src/magic/magic_system.h:65`; custos via `calculate_mana_cost()`, cooldowns via `get_cooldown_remaining()` | +| Poção automática | `inventory->remove_item()` + `plr->heal_hp/mp/sp()` (lógica de referência: `game_handlers_equipment.cpp:483+`) | +| Party | `social_system::create_party / invite_to_party / accept_party_invite / leave_party` — `src/social/social_system.h:242-253` (100% por `player_id`, zero rede) | +| Comprar itens | `item_ops::shop_buy(entity_id, template_id, price, items, inv)` — `src/item/item_ops.h:114`; preços em `src/npc/shop_pricing.h`, catálogo em `src/registry/shop_registry.cpp` | +| Reparar equipamento | `item_ops::shop_repair(...)` — `src/item/item_ops.h:151` (custo proporcional, deduz ouro, restaura durabilidade) | +| Upar / XP | `player_system::add_experience(pid, amount)` (level-up e stat points automáticos, `player_system.cpp:308-325`); recompensas via `combat_system::calculate_kill_rewards()` | +| Alvos próximos | `world->get_all_entities_in_range(map, pos, r)` (índice espacial, `spatial_index.h`, célula 32) | + +### Ciclo de vida do bot + +1. `players_->create_player(create_info)` — **nunca** chamar `bind_connection`. +2. Registrar `ecs_entity` no entity manager e no `spatial()` do mapa — sem isso NPCs não + agridem o bot (`find_aggro_target` só enxerga `entity_type::player` no índice espacial) + e players reais não o veem. +3. Despawn: remover do índice espacial/occupant antes de destruir (ordem de remoção — ver + padrão de memory safety no CLAUDE.md). + +## Fases + +### Fase 0 — Correções de performance (ANTES de escalar) + +Dois gargalos O(n) que 200 bots amplificam: + +1. `get_players_who_can_see()` — `src/player/player_system.cpp:1196-1199`: varre **todos** os + players a cada broadcast só para achar admins com `sees_all`. + Correção: manter `std::vector sees_all_players_` incremental. +2. `find_aggro_target()` — `src/npc/npc_system.cpp:1319-1355`: `for_each_player(...)` (varredura + linear) **dentro do laço** de entidades no raio, a cada 100 ms. + Correção: usar `player_system::get_player_by_entity(entity)` que é O(1) + (índice `ecs_index_to_id_`). + +Ligar o `perf_stats_system` (`src/perf/`, macro `PERF_TIMER`) em `tick_total`, +`spatial_query_visibility`, `npc_ai_update` para medir antes/depois. + +### Fase 1 — Esqueleto do bot_system + spawn + +- `src/bot/bot_system.{h,cpp}` registrado em `application.cpp`. +- Spawn/despawn de bots (ciclo de vida acima). +- Config YAML: quantidade, mapas, distribuição de classes (guerreiro/mago), faixa de level, + nomes gerados. +- Comando de GM para spawn/despawn em runtime (via `admin_system`). + +### Fase 2 — Visibilidade (o principal trabalho de "cola") + +Bots pulam os handlers, logo pulam os broadcasts que os handlers emitem. Para jogadores +reais **verem** os bots se movendo/atacando, o `bot_system` deve emitir +`broadcast_to_visible(...)` / `broadcast_player_action(...)` (`broadcast_util.h`) após cada +ação — ou, mais limpo, publicar eventos no `event_bus` e deixar o bridge traduzir. +Sem esta fase os bots funcionam mas ficam invisíveis para clientes reais. + +### Fase 3 — IA de sobrevivência e combate + +- Perfis por classe (melee / mago / híbrido). +- Seleção de alvo via índice espacial; replicar as validações do handler humano que fizerem + sentido: cooldown de ataque de 100 ms, `combat_mode` ligado, alvo vivo. +- Rotação de magia com checagem de mana e cooldown. +- **Auto-pot por threshold:** HP < 40% → poção; HP < 15% → estado flee. MP baixo → pot de mana. +- Anti-stuck: reaproveitar a ideia de `stuck_count` de `ai_runtime_state`. + +### Fase 4 — Party + +- Líder cria party, convida bots próximos de level compatível; XP share já funciona. +- Comportamento de grupo: seguir líder, focar mesmo alvo, healer prioriza aliados. +- Modos via `set_loot_mode` / `set_exp_mode`. + +### Fase 5 — Economia (comprar / reparar) + +- Gatilhos para estado "town": poções < N no inventário, ou equipamento danificado + (o sinal natural vem de `item_ops::damage_equipment()`). +- Rotina: caminhar até NPC da loja → `shop_buy` (pots) → `shop_repair` (itens danificados) + → voltar ao grind. Ouro vem dos kill rewards normais. + +### Fase 6 — Escala até 200 + +- Subir 10 → 50 → 200 medindo tick com `perf_stats_system`. +- Headroom teórico: `max_players{2000}`, `max_npcs{10000}` — o risco real é custo de + broadcast quando bots e players reais compartilham tela (mitigado pela Fase 0 e pelo + escalonamento de think). + +## Pontos de decisão em aberto + +- **Pathfinding:** não existe A* no servidor — movimento é guloso de 1 passo + (`move_towards()` / `direction_to()` + `move_in_direction()`). Para grind em campo aberto + basta; para a rotina de cidade (achar NPC da loja) recomenda-se A* simples sobre + `map::is_walkable()` + waypoints (`map::get_waypoint`). Único componente genuinamente novo. +- **Persistência dos bots:** com `connection == 0` os bots nunca são salvos (padrão efêmero, + de graça). Se os bots devem manter progresso entre restarts, será preciso save opt-in + reaproveitando o fluxo de `save_all_players()` — decidir na Fase 1. +- **Equilibrium:** se qualquer comportamento de bot tocar no sistema legado "Equilibrium", + perguntar antes (regra do CLAUDE.md — não portar por padrão). + +## Estimativa + +Fases 0–3 são o núcleo (~70% do esforço; Fase 2 é a mais delicada). Fases 4–5 são rápidas +porque as APIs já existem. A* é opcional e isolado. diff --git a/docs/JSON_PROTOCOL.md b/docs/JSON_PROTOCOL.md index 4e4ede9..19a5dc0 100644 --- a/docs/JSON_PROTOCOL.md +++ b/docs/JSON_PROTOCOL.md @@ -68,7 +68,7 @@ All messages follow a common envelope structure: |----------|------|-------------| | System | [protocol/system.md](protocol/system.md) | ping, pong, error | | Authentication | [protocol/auth.md](protocol/auth.md) | Login, accounts, characters, game entry | -| Player State | [protocol/player.md](protocol/player.md) | Game state objects, entity visibility, stat updates, view mode | +| Player State | [protocol/player.md](protocol/player.md) | Game state objects, entity visibility, stat updates, experience/level-up updates, view mode | | Movement | [protocol/movement.md](protocol/movement.md) | Movement, teleportation | | Combat | [protocol/combat.md](protocol/combat.md) | Attacks, damage, death, magic, skills, combat mode, action broadcasts | | Items (v2) | [protocol/items-v2.md](protocol/items-v2.md) | Inventory, equipment, ground items, trade, shop, bank, loot | @@ -77,7 +77,7 @@ All messages follow a common envelope structure: | Chat | [protocol/chat.md](protocol/chat.md) | Chat messages, commands | | Crafting | [protocol/crafting.md](protocol/crafting.md) | Manufacturing, alchemy, mining, fishing | | Guild | [protocol/guild.md](protocol/guild.md) | Guild management, ranks, invites | -| Social | [protocol/social.md](protocol/social.md) | Friend system | +| Social | [protocol/social.md](protocol/social.md) | Friend system, party (invite/accept/leave/update) | | War | [protocol/war.md](protocol/war.md) | Crusade, Heldenian, Apocalypse, force recall, rewards | | Commands | [protocol/commands.md](protocol/commands.md) | Command list, availability updates | | Admin | [ADMIN_PROTOCOL.md](ADMIN_PROTOCOL.md) | Admin web tool (32 request/response pairs) | diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 665e959..17dd9dc 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -430,6 +430,8 @@ Priority order for remaining work toward a playable game: 8. ~~**Crafting System**~~ - ✅ Manufacturing (83 recipes) + alchemy (80+38 recipes) with YAML configs 9. ~~**War Mechanics**~~ - ✅ Crusade, Heldenian, Apocalypse battle logic with DB persistence and admin API 10. ~~**Guild Persistence**~~ - ✅ Guilds and members persist to PostgreSQL, guild info on login +11. **Dynamic ground-field spells** - `create_dynamic` (type 14: Spike-Field, Ice-Storm, Cloud-Kill) needs a dynamic object subsystem (spawn/tick/expiry + client protocol) +12. ~~**Missing item IDs in configs**~~ - ✅ Loot/shop references audited: remapped to real IDs where the intended item exists, removed entries for items absent from this item set --- @@ -450,6 +452,56 @@ Priority order for remaining work toward a playable game: ## Recent Changes +### 2026-08-30: Sweep of ECS-entity vs player_id confusion (`get_player(player_id{entity.id})`) +- Full `src/` sweep for the recurring bug class where an ECS entity id (from the shared `entity_manager`) was cast to `player_id` (or vice versa). Entity ids and player ids are NOT interchangeable — the lookup returns nullptr (or the wrong player) and `if (auto* p = ...)`-guarded blocks are silently skipped. Standard fix: `player_system::get_player_by_entity(entity)` (O(1), const overload available) +- `magic_system.cpp`: 21 sites fixed — mana/HP/SP deduction, silenced/level/stat checks, range checks, safe-zone PvP checks, damage/heal scaling, SP drain, resurrection/heal targets, debuff resist rolls. `find_aoe_targets`/`find_line_targets` no longer fabricate `entity{player_id.value, 0}` for hit players — they push the real `p.ecs_entity` (fabricated handles broke downstream `combat_system::deal_damage` and the spell broadcast, which resolve via the real ECS index) +- `application.cpp`: effect ticks (heal / mana_drain / mana_restore) resolved players by entity id cast to `player_id` — periodic heal/mana effects on players never applied +- `game_handlers_combat.cpp`: `on_spell_cast` broadcast resolved caster/targets with the bad cast (spell visuals never showed target positions for players); respawn invulnerability was keyed on `entity{pid.value}` so it never matched the real defender entity — 3s respawn protection was silently inert +- `player_system::remove_player`: active spell effects were removed for `entity{p.id.value}` instead of `p.ecs_entity` — effects leaked in the effect system on logout +- `wave4_handlers.cpp` (legacy binary path): attack + spell cast fabricated caster/target entities from `ctx.player.value`; now resolve the player's real `ecs_entity` (their spell-knowledge/cooldown lookups could never match the JSON path's keys) +- Legitimate patterns confirmed and left untouched: admin API `get_player(player_id{req.player_id})` (wire value IS a player id); inventory/trade/crafting `entity_id{pid.value}` keying (consistent at creation and access); spatial-index `entity_id{ecs_entity.index()}` +- Tests updated to the real-entity convention (`test_magic.cpp`, `test_safe_zone.cpp`, `test_melee_pve.cpp`): fixtures now resolve `ecs_entity` instead of fabricating `entity(pid.value)` — the old form only passed because test fixtures spawn no NPCs, so both counters coincided + +### 2026-08-30: Bot scale phase (10 → 50), perf fixes, city habitability +- Perf: `find_aggro_target` now resolves players via O(1) `get_player_by_entity` (was a full player scan per entity in aggro range, per NPC, per 100ms); `get_players_who_can_see` skips the far-admin scan unless any player has `sees_all` (new `player_system::set_sees_all` + incremental counter; gm_commands updated — never write `player::sees_all` directly) +- Map loading: `.amd` extension check is now case-insensitive (`ARESDEN.AMD`/`ELVINE.AMD` were silently skipped — those maps never existed at runtime); map names normalized to lowercase at load so name lookups ("aresden") work +- New characters now start in their nation's town (aresden/elvine; neutral → default) — `map_name` added to the create-character INSERT +- Auth: registration rate limit configurable via `server.yaml` (`auth.max_registration_attempts`, `auth.registration_cooldown`) — the hardcoded 3/hour per IP blocked bulk bot account creation +- `mapdata/aresden.yaml` + `mapdata/elvine.yaml`: initial points, mob spawners and merchants in areas validated by the new `tools/bot/scan-map.mjs` (.amd walkability scanner); `default.yaml` densified +- `tools/bot/gen-bots.mjs` generates N bots split half Aresden / half Elvine, parties of 5 per nation; bot client retries account creation with backoff on rate_limited + +### 2026-08-30: Party protocol (JSON), loot gold fixes, bot infrastructure +- New JSON party messages: `party_invite_request/response`, `party_invite_notice`, `party_accept_request/response`, `party_leave_request/response`, `party_update` — handlers in `game_handlers_chat.cpp`, documented in `docs/protocol/social.md` (legacy binary `party_operation` remains unused by the WS protocol) +- Fixed loot gold being credited to the ECS entity id instead of the resolved `player_id` (phantom inventory; `gold_update` sent to wrong connection) — `game_handlers_npc.cpp` +- `npc_registry` YAML loader now parses `gold_min`/`gold_max`; added values for tier-1/2 mobs in `npcs.yaml`; fixed `npcs.yaml` `exp_dice:` → `exp:` key mismatch (mobs gave 0 XP). Remaining known key mismatches: `defense_ratio` vs `defense`, `size` vs `body_size` +- New spot-mob codes 15 (`ShopKeeper-W`) and 19 (`Gandlf`) for map spawners; `mapdata/default.yaml` created (mob spawners + merchants + initial point); `shops.yaml` potion item ids corrected (308/309/310 → 91/93/95) +- Headless bot client (`tools/bot/`): login, hunt/combat/flee/respawn, loot, shopping (buy/sell/repair/equip), party formation; runs N bots per process (`node bot.mjs all`) + +### 2026-08-30: Loot table and shop item reference audit +- Full audit of loot_tables.yaml/shops.yaml against items.yaml (the tables were authored against a different item numbering — comments named the intended items) +- Remapped 16 references whose intended item exists under another ID (BlackShadowSword→926, The_Devastator→923, BarbarianHammer→928, KlonessAxe→929, StormBringer→924, GiantSword→46, Flameberge+1→55, MagicWand(MS20)→256, KnecklaceOfStoneGolem→647, SapphireRing→336 ×3, MagicWand tiers, etc.) +- Removed 32 pool entries whose intended item does not exist in this item set (AncientTablets, CritCandy, SSS/E.S.W/I.M.C manuals, XelimaCap/Hat/Helm, NecklaceOfXelima, DragonWand MS40, HolyBlade, GiantBattleHammer) — including placeholder junk drops (Tomato/Hoe/Garlic/Carrot standing in for Ice/Merien gear in boss pools) +- Rewrote ~50 stale comments to the real item names (behavior unchanged) +- Fixed silent wrong-item bugs in shops.yaml: ShopKeeper-E/W now sell RedPotion/BluePotion/GreenPotion (91/93/95) instead of MagicNecklace(MS10) + 2 missing IDs; Gandlf/William now sell ShortSword(8)/MainGauche(12) instead of Dagger variants (2/3) +- Startup validation warnings (375+ per boot) eliminated; 2522 tests pass + +### 2026-08-30: Legacy magic types restored (12 spells re-enabled) +- Extended `magic_type` enum with legacy HBX Magic.cfg values: `create_dynamic` (14), `damage_linear` (19), `damage_area_no_center` (21), `damage_area_sp_down` (25), `armor_break` (26), `ice_linear` (27) +- Spells that previously failed to load now work: Bloody-Shock-Wave, Energy-Strike, Lightning-Strike, Meteor-Strike, Mass-Magic-Missile, Earthworm-Strike, Armor-Break, Blizzard (+ corrected Cancellation, Illusion-Movement, Mass-Illusion-Movement, Resurrection) +- New line-targeting (`find_line_targets`): Bresenham trace from caster toward target, up to 12 tiles, with faction/safe-zone filters (types 19/27) +- `damage_area_sp_down` drains SP from player targets (amount from effect2 dice, parsed into `sp_drain`) +- `armor_break` deals pure damage via `deal_pure_damage` (ignores defense) — `ignores_defense` flag now honored in the attack path +- Cancellation remapped from utility stub to offensive dispel (debuff category, removes target's active effects) +- Fixed 4 placeholder rows in magic.yaml (copied from Lightning-Strike in the original Magic.cfg): Cancellation→type 28, Resurrection→32, Illusion-Movement/Mass-Illusion-Movement→16 +- `create_dynamic` (Spike-Field, Ice-Storm, Cloud-Kill) accepted in the enum but spells skipped at load with an info message — pending dynamic ground-object subsystem +- New test `magic_registry_legacy_damage_types`; 2522 tests pass + +### 2026-08-30: Experience Gain Notification (`experience_update`) +- New server→client message `experience_update` sent whenever a player gains XP (solo/party NPC kill XP, crusade rewards, login reward delivery) +- Carries `experience_gained`, new total `experience`, and `level`; on level-up also `levels_gained`, new `max_hp`/`max_mp`/`max_sp`, and unspent `stat_points` +- Added `experience_gain_callback` to `player_system` (fired from `add_experience` only when XP actually changes; no-op at max level), wired in `game_handlers` — covers all `add_experience` call sites automatically +- Documented in `docs/protocol/player.md` and `docs/JSON_PROTOCOL.md`; updated stale "no explicit message" note in `docs/protocol/items.md` + ### 2026-02-22: Item System v2 Redesign - Rewrote item_template struct to match legacy .cfg format 1:1 (raw effect_type + effect_value1-6) - Rewrote item_template YAML loader for 1:1 field mapping diff --git a/docs/protocol/items.md b/docs/protocol/items.md index 7c221b9..e2ebe74 100644 --- a/docs/protocol/items.md +++ b/docs/protocol/items.md @@ -1196,11 +1196,11 @@ Gold is added directly to the killer's inventory via `add_gold()`. **No message Each loot item that drops on the ground generates a [Ground Item Object](#ground-item-object) broadcast with `reason: "drop"`. -### 4. Experience award (NO explicit message) +### 4. Experience award: `experience_update` -Experience is added internally. If the player levels up, stat recalculation occurs and subsequent `stat_update` messages may include `level` and `experience`. +Each player credited with kill XP (the killer, or each eligible party member) receives an [`experience_update`](player.md#experience_update) message with the amount gained, new total, and level. On level-up it also carries the new `max_hp`/`max_mp`/`max_sp` and unspent `stat_points`. -*Source: `game_handlers_npc.cpp` death callback and `game_handlers_combat.cpp` kill reward logic* +*Source: `game_handlers_npc.cpp` death callback and `game_handlers_combat.cpp` kill reward logic; message sent via `player_system` experience-gain callback in `game_handlers.cpp`* --- diff --git a/docs/protocol/player.md b/docs/protocol/player.md index 8e17911..51f2362 100644 --- a/docs/protocol/player.md +++ b/docs/protocol/player.md @@ -242,6 +242,58 @@ Sent to a player after equipment changes to reflect updated computed stats. --- +### `experience_update` + +Sent to a player whenever they gain experience (NPC kill XP — solo or party share — crusade rewards, login reward delivery, etc.). Level-up fields are present only when the gain caused one or more level-ups. + +**Server Message (no level-up):** +```json +{ + "type": "experience_update", + "seq": 0, + "data": { + "experience_gained": 250, + "experience": 45780, + "level": 12 + } +} +``` + +**Server Message (level-up):** +```json +{ + "type": "experience_update", + "seq": 0, + "data": { + "experience_gained": 250, + "experience": 45780, + "level": 13, + "levels_gained": 1, + "max_hp": 380, + "max_mp": 215, + "max_sp": 260, + "stat_points": 3 + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `experience_gained` | int64 | Experience credited by this gain | +| `experience` | int64 | New total experience | +| `level` | uint8 | Current level (already reflects any level-up) | +| `levels_gained` | int32? | Levels gained by this XP award (present only on level-up) | +| `max_hp` | int32? | New maximum HP after recalculation (present only on level-up) | +| `max_mp` | int32? | New maximum MP after recalculation (present only on level-up) | +| `max_sp` | int32? | New maximum SP after recalculation (present only on level-up) | +| `stat_points` | int16? | Total unspent stat points after the award of 3 per level (present only on level-up) | + +**Notes:** +- Not sent when the player is at max level (no experience is credited). +- Experience *loss* (death penalty) is not reported by this message; the client learns of it via full `stat_update` messages. + +--- + ### `spell_list_update` Sent to a player when their known spell list changes (e.g., after learning a new spell or equipment changes that affect available spells). Contains the full list of known spells. diff --git a/docs/protocol/social.md b/docs/protocol/social.md index 61967d3..b52aa36 100644 --- a/docs/protocol/social.md +++ b/docs/protocol/social.md @@ -1,7 +1,103 @@ -# Social (Friends) +# Social (Friends & Party) [← Back to Protocol Index](../JSON_PROTOCOL.md) +## Party System + +Parties share kill experience among eligible members on the same map (default mode: `equal_split`, +see `social::exp_mode`). Max 8 members; invites expire after 60 seconds. + +### `party_invite_request` + +**Direction:** Client → Server + +Invite a player (by character name) to the sender's party. If the sender is not in a party, one is +created implicitly and the sender becomes its leader. + +```json +{ + "type": "party_invite_request", + "seq": 300, + "data": { "target_name": "OtherPlayer" } +} +``` + +### `party_invite_response` + +**Direction:** Server → Client (same `seq`) + +```json +{ "type": "party_invite_response", "seq": 300, "data": { "success": true, "party_id": 1 } } +``` + +On failure, `data` is `{ "success": false, "error": "" }` with codes: +`player_not_found`, `already_in_party`, `party_full`, `not_leader`, `party_not_found`. + +### `party_invite_notice` + +**Direction:** Server → Client (broadcast to invitee, `seq: 0`) + +Sent to the invited player when an invite is issued. + +```json +{ "type": "party_invite_notice", "seq": 0, "data": { "party_id": 1, "inviter_name": "Leader" } } +``` + +### `party_accept_request` + +**Direction:** Client → Server + +Accept (or decline, with `"accept": false`) a pending invite. + +```json +{ + "type": "party_accept_request", + "seq": 301, + "data": { "party_id": 1, "accept": true } +} +``` + +### `party_accept_response` + +**Direction:** Server → Client (same `seq`) + +```json +{ "type": "party_accept_response", "seq": 301, "data": { "success": true, "party_id": 1 } } +``` + +Failure codes: `no_pending_invite`, `invite_expired`, `party_full`, `already_in_party`, +`party_not_found`. + +### `party_leave_request` + +**Direction:** Client → Server + +```json +{ "type": "party_leave_request", "seq": 302, "data": {} } +``` + +Response: `party_leave_response` with `{ "success": bool }`. + +### `party_update` + +**Direction:** Server → Client (broadcast to all party members, `seq: 0`) + +Sent whenever party membership changes (member joined or left). + +```json +{ + "type": "party_update", + "seq": 0, + "data": { + "party_id": 1, + "leader_name": "Leader", + "members": ["Leader", "Member2", "Member3"] + } +} +``` + +--- + ## Friend System ### `friend_request_send_request` diff --git a/server.yaml.example b/server.yaml.example index 2aeaf20..b4380a3 100644 --- a/server.yaml.example +++ b/server.yaml.example @@ -31,6 +31,10 @@ auth: max_characters: 4 allow_registration: true session_timeout: 3600 + # Registration rate limit per IP (defaults: 3 attempts per 3600s). + # Raise for dev/bot environments that create accounts in bulk. + max_registration_attempts: 3 + registration_cooldown: 3600 # Logging Configuration # Levels: trace, debug, info, warn, error, critical, off diff --git a/src/admin/gm_commands.cpp b/src/admin/gm_commands.cpp index 2e59126..a2c2044 100644 --- a/src/admin/gm_commands.cpp +++ b/src/admin/gm_commands.cpp @@ -682,7 +682,7 @@ void register_gm_commands(admin_system& admin, const gm_command_context& ctx) if (value == "all") { - target->sees_all = true; + players->set_sees_all(target->id, true); target->gm_view_override = true; if (send) { @@ -699,7 +699,7 @@ void register_gm_commands(admin_system& admin, const gm_command_context& ctx) if (value == "reset") { - target->sees_all = false; + players->set_sees_all(target->id, false); target->gm_view_override = false; target->visibility_radius_x = network::min_visibility_radius; target->visibility_radius_y = network::min_visibility_radius; @@ -735,7 +735,7 @@ void register_gm_commands(admin_system& admin, const gm_command_context& ctx) return command_result::error("Radii must be between 1 and " + std::to_string(network::max_visibility_radius)); } - target->sees_all = false; + players->set_sees_all(target->id, false); target->gm_view_override = true; target->visibility_radius_x = static_cast(rx); target->visibility_radius_y = static_cast(ry); @@ -769,7 +769,7 @@ void register_gm_commands(admin_system& admin, const gm_command_context& ctx) std::to_string(network::max_visibility_radius)); } - target->sees_all = false; + players->set_sees_all(target->id, false); target->gm_view_override = true; target->visibility_radius_x = static_cast(radius); target->visibility_radius_y = static_cast(radius); diff --git a/src/application.cpp b/src/application.cpp index 9a8d180..576d774 100644 --- a/src/application.cpp +++ b/src/application.cpp @@ -318,7 +318,9 @@ void application::initialize() .session_max_duration = server_cfg.auth.session_max_duration, .allow_registration = server_cfg.auth.allow_registration, .max_login_attempts = server_cfg.auth.max_login_attempts, - .lockout_duration = server_cfg.auth.lockout_duration}; + .lockout_duration = server_cfg.auth.lockout_duration, + .max_registration_attempts = server_cfg.auth.max_registration_attempts, + .registration_cooldown = server_cfg.auth.registration_cooldown}; auth_sys.set_config(auth_cfg); auth_sys.set_database(&db_sys); auth_sys.set_forum_config(server_cfg.forum_auth); @@ -969,7 +971,10 @@ void application::load_maps() continue; auto path = entry.path(); - if (path.extension() != ".amd") + // Case-insensitive extension check — legacy files ship as both .amd and .AMD + auto ext = path.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); }); + if (ext != ".amd") continue; auto result = world->load_map(path); @@ -1100,13 +1105,16 @@ void application::wire_effect_system() case spell_effect_type::heal: if (player_sys) { - player_sys->apply_heal(player_id{target.id}, eff.magnitude); + if (auto* p = player_sys->get_player_by_entity(target)) + { + player_sys->apply_heal(p->id, eff.magnitude); + } } break; case spell_effect_type::mana_drain: if (player_sys) { - if (auto* p = player_sys->get_player(player_id{target.id})) + if (auto* p = player_sys->get_player_by_entity(target)) { p->spend_mp(eff.magnitude); } @@ -1115,7 +1123,7 @@ void application::wire_effect_system() case spell_effect_type::mana_restore: if (player_sys) { - if (auto* p = player_sys->get_player(player_id{target.id})) + if (auto* p = player_sys->get_player_by_entity(target)) { p->heal_mp(eff.magnitude); } @@ -1690,8 +1698,24 @@ void application::load_game_configs() ms.target_type = magic::spell_target::single_enemy; break; case magic_type::damage_area: + case magic_type::damage_area_no_center: + ms.category = magic::spell_category::attack; + ms.target_type = magic::spell_target::aoe_enemy; + break; + case magic_type::damage_area_sp_down: + ms.category = magic::spell_category::attack; + ms.target_type = magic::spell_target::aoe_enemy; + ms.sp_drain = reg_spell.sp_drain; + break; + case magic_type::armor_break: ms.category = magic::spell_category::attack; ms.target_type = magic::spell_target::aoe_enemy; + ms.ignores_defense = true; + break; + case magic_type::damage_linear: + case magic_type::ice_linear: + ms.category = magic::spell_category::attack; + ms.target_type = magic::spell_target::line; break; case magic_type::poison: case magic_type::ice: @@ -1726,10 +1750,14 @@ void application::load_game_configs() break; case magic_type::teleport: case magic_type::summon: - case magic_type::cancellation: ms.category = magic::spell_category::utility; ms.target_type = magic::spell_target::self; break; + case magic_type::cancellation: + // Offensive dispel: strips the target's active effects + ms.category = magic::spell_category::debuff; + ms.target_type = magic::spell_target::single_enemy; + break; case magic_type::sp_up_spot: ms.category = magic::spell_category::healing; ms.target_type = magic::spell_target::single_ally; diff --git a/src/auth/auth_system.cpp b/src/auth/auth_system.cpp index fcf87f9..c23d452 100644 --- a/src/auth/auth_system.cpp +++ b/src/auth/auth_system.cpp @@ -10,8 +10,11 @@ #include #include +#include "platform/posix_time_compat.h" + #include #include +#include #include #include @@ -30,7 +33,11 @@ auto parse_pg_timestamp(const std::string& ts) -> std::optional(-1)) { return std::nullopt; @@ -779,14 +786,21 @@ auto auth_system::create_character(account_id id, const character_create_info& i int32_t max_mp = initial_stats.max_mp(); int32_t max_sp = initial_stats.max_sp(); + // New characters start in their nation's town (neutral characters use 'default') + std::string start_map = "default"; + if (info.nation == 1) + start_map = "aresden"; + else if (info.nation == 2) + start_map = "elvine"; + // Insert character auto db_result = database_->execute_params( R"(INSERT INTO characters (account_id, name, class_type, nation, gender, hair_style, hair_color, skin_color, underwear_color, strength, dexterity, vitality, intelligence, magic, charisma, - hp, hp_max, mp, mp_max, sp, sp_max) + hp, hp_max, mp, mp_max, sp, sp_max, map_name) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, - $16, $17, $18, $19, $20, $21) + $16, $17, $18, $19, $20, $21, $22) RETURNING id)", static_cast(id.value), info.name, @@ -808,8 +822,8 @@ auto auth_system::create_character(account_id id, const character_create_info& i max_mp, max_mp, // mp = mp_max max_sp, - max_sp // sp = sp_max - ); + max_sp, // sp = sp_max + start_map); if (db_result.is_err()) { @@ -1340,7 +1354,11 @@ auto auth_system::ban_account(account_id id, { auto time_t_val = std::chrono::system_clock::to_time_t(*expires); std::tm tm_val{}; +#ifdef _WIN32 + gmtime_s(&tm_val, &time_t_val); +#else gmtime_r(&time_t_val, &tm_val); +#endif std::ostringstream oss; oss << std::put_time(&tm_val, "%Y-%m-%d %H:%M:%S+00"); diff --git a/src/bridge/handlers/admin_web_handlers.cpp b/src/bridge/handlers/admin_web_handlers.cpp index bf1b0ed..9c4900b 100644 --- a/src/bridge/handlers/admin_web_handlers.cpp +++ b/src/bridge/handlers/admin_web_handlers.cpp @@ -1,6 +1,9 @@ // admin_web_handlers.cpp // Admin web tool handler implementations +// Include platform header first to define NOMINMAX before Windows headers +#include "platform/platform.h" + #include "bridge/handlers/admin_web_handlers.h" #include "core/logger.h" #include diff --git a/src/bridge/handlers/broadcast_util.cpp b/src/bridge/handlers/broadcast_util.cpp index 52342f5..b15277a 100644 --- a/src/bridge/handlers/broadcast_util.cpp +++ b/src/bridge/handlers/broadcast_util.cpp @@ -1,3 +1,6 @@ +// Include platform header first to define NOMINMAX before Windows headers +#include "platform/platform.h" + #include "bridge/handlers/broadcast_util.h" #include "network/websocket_server.h" diff --git a/src/bridge/handlers/entity_builders.cpp b/src/bridge/handlers/entity_builders.cpp index 097e910..ed66527 100644 --- a/src/bridge/handlers/entity_builders.cpp +++ b/src/bridge/handlers/entity_builders.cpp @@ -2,6 +2,9 @@ // Helper functions to build visible_entity_msg for player and NPC spawns, // and shared functions for sending entity data on login/teleport. +// Include platform header first to define NOMINMAX before Windows headers +#include "platform/platform.h" + #include "bridge/handlers/entity_builders.h" #include "player/player.h" #include "player/player_system.h" diff --git a/src/bridge/handlers/game_handlers.cpp b/src/bridge/handlers/game_handlers.cpp index 8f80828..8dc3010 100644 --- a/src/bridge/handlers/game_handlers.cpp +++ b/src/bridge/handlers/game_handlers.cpp @@ -125,6 +125,8 @@ void game_handlers::initialize(network::websocket_server* ws_server, { send_vitals_update(pid, hp, mp, sp); }); players_->on_hunger_change([this](player_id pid, int8_t, int8_t new_level) { send_hunger_update(pid, new_level); }); + players_->on_experience_gain([this](player_id pid, int64_t exp_gained, int levels_gained) + { send_experience_update(pid, exp_gained, levels_gained); }); } // Register skill level-up callback — broadcast to visible players @@ -611,6 +613,17 @@ bool game_handlers::handle_message(connection_id conn_id, const network::json_me handle_command(conn_id, msg); return true; + // Party + case network::json_message_type::party_invite_request: + handle_party_invite(conn_id, msg); + return true; + case network::json_message_type::party_accept_request: + handle_party_accept(conn_id, msg); + return true; + case network::json_message_type::party_leave_request: + handle_party_leave(conn_id, msg); + return true; + // View range case network::json_message_type::set_view_range: handle_set_view_range(conn_id, msg); @@ -1021,6 +1034,40 @@ void game_handlers::send_vitals_update(player_id pid, int32_t hp, int32_t mp, in conn->send(network::make_stat_update(data)); } +// ========== Experience Update ========== + +void game_handlers::send_experience_update(player_id pid, int64_t exp_gained, int levels_gained) +{ + if (!players_ || !ws_server_) + return; + + auto* player = players_->get_player(pid); + if (!player || player->connection.value == 0) + return; + + auto* conn = ws_server_->get_connection(player->connection); + if (!conn || !conn->is_open()) + return; + + network::experience_update_data data + { + .experience_gained = exp_gained, + .experience = player->experience.experience, + .level = player->experience.level, + }; + + if (levels_gained > 0) + { + data.levels_gained = levels_gained; + data.max_hp = player->computed.max_hp; + data.max_mp = player->computed.max_mp; + data.max_sp = player->computed.max_sp; + data.stat_points = player->stats_pts.available; + } + + conn->send(network::make_experience_update(data)); +} + // ========== Hunger Update ========== void game_handlers::send_hunger_update(player_id pid, int8_t level) diff --git a/src/bridge/handlers/game_handlers.h b/src/bridge/handlers/game_handlers.h index 0ef75ae..cf9fc33 100644 --- a/src/bridge/handlers/game_handlers.h +++ b/src/bridge/handlers/game_handlers.h @@ -352,10 +352,19 @@ class game_handlers void send_stat_update(connection_id conn_id, const player::player& plr); void send_full_stat_update(connection_id conn_id, const player::player& plr); + // Experience + void send_experience_update(player_id pid, int64_t exp_gained, int levels_gained); + // Chat void handle_chat_message(connection_id conn_id, const network::json_message& msg); void handle_command(connection_id conn_id, const network::json_message& msg); + // Party + void handle_party_invite(connection_id conn_id, const network::json_message& msg); + void handle_party_accept(connection_id conn_id, const network::json_message& msg); + void handle_party_leave(connection_id conn_id, const network::json_message& msg); + void broadcast_party_update(uint32_t party_id_value); + // View range void handle_set_view_range(connection_id conn_id, const network::json_message& msg); diff --git a/src/bridge/handlers/game_handlers_chat.cpp b/src/bridge/handlers/game_handlers_chat.cpp index a4bd58a..7965705 100644 --- a/src/bridge/handlers/game_handlers_chat.cpp +++ b/src/bridge/handlers/game_handlers_chat.cpp @@ -3,6 +3,7 @@ // Include platform header first to define NOMINMAX before Windows headers #include "platform/platform.h" +#include "platform/posix_time_compat.h" #include "bridge/handlers/game_handlers.h" #include "bridge/handlers/broadcast_util.h" @@ -1094,4 +1095,199 @@ void game_handlers::send_guild_command_update(player_id pid) } } +// ========== Party ========== + +namespace +{ + +auto party_result_name(social::party_result r) -> std::string_view +{ + switch (r) + { + case social::party_result::success: + return "success"; + case social::party_result::party_not_found: + return "party_not_found"; + case social::party_result::player_not_member: + return "player_not_member"; + case social::party_result::not_leader: + return "not_leader"; + case social::party_result::party_full: + return "party_full"; + case social::party_result::already_in_party: + return "already_in_party"; + case social::party_result::no_pending_invite: + return "no_pending_invite"; + case social::party_result::cannot_kick_self: + return "cannot_kick_self"; + case social::party_result::invite_expired: + return "invite_expired"; + } + return "unknown"; +} + +} // namespace + +void game_handlers::handle_party_invite(connection_id conn_id, const network::json_message& msg) +{ + auto* conn = require_in_game(conn_id, msg.seq); + if (!conn) + return; + + if (!social_ || !players_) + { + send_error(conn_id, msg.seq, "internal_error", "Party system unavailable"); + return; + } + + auto data_result = network::party_invite_request_data::from_json(msg.data); + if (data_result.is_err()) + { + send_error(conn_id, msg.seq, "invalid_request", data_result.error()); + return; + } + + auto pid = conn->player(); + auto* target = players_->get_player_by_name(data_result.value().target_name); + if (!target) + { + conn->send(network::make_party_invite_response(msg.seq, false, 0, "player_not_found")); + return; + } + + // Cria a party implicitamente quando o convidante ainda não tem uma + auto party = social_->get_player_party(pid); + if (!party.is_valid()) + { + auto created = social_->create_party(pid); + if (created.is_err()) + { + conn->send(network::make_party_invite_response(msg.seq, false, 0, party_result_name(created.error()))); + return; + } + party = created.value(); + } + + auto invite_result = social_->invite_to_party(pid, target->id); + if (invite_result != social::party_result::success) + { + conn->send(network::make_party_invite_response(msg.seq, false, 0, party_result_name(invite_result))); + return; + } + + conn->send(network::make_party_invite_response(msg.seq, true, party.value, "")); + + // Notifica o convidado (se online) com o party_id para aceitar + if (auto* self = players_->get_player(pid)) + { + if (auto* target_conn = ws_server_ ? ws_server_->get_connection(target->connection) : nullptr) + { + if (target_conn->is_open()) + { + target_conn->send(network::make_party_invite_notice(party.value, self->name)); + } + } + } +} + +void game_handlers::handle_party_accept(connection_id conn_id, const network::json_message& msg) +{ + auto* conn = require_in_game(conn_id, msg.seq); + if (!conn) + return; + + if (!social_) + { + send_error(conn_id, msg.seq, "internal_error", "Party system unavailable"); + return; + } + + auto data_result = network::party_accept_request_data::from_json(msg.data); + if (data_result.is_err()) + { + send_error(conn_id, msg.seq, "invalid_request", data_result.error()); + return; + } + + auto& data = data_result.value(); + auto pid = conn->player(); + auto party = social::party_id{data.party_id}; + + auto result = data.accept ? social_->accept_party_invite(pid, party) : social_->decline_party_invite(pid, party); + if (result != social::party_result::success) + { + conn->send(network::make_party_accept_response(msg.seq, false, 0, party_result_name(result))); + return; + } + + conn->send(network::make_party_accept_response(msg.seq, true, data.party_id, "")); + if (data.accept) + { + broadcast_party_update(data.party_id); + } +} + +void game_handlers::handle_party_leave(connection_id conn_id, const network::json_message& msg) +{ + auto* conn = require_in_game(conn_id, msg.seq); + if (!conn) + return; + + if (!social_) + { + send_error(conn_id, msg.seq, "internal_error", "Party system unavailable"); + return; + } + + auto pid = conn->player(); + auto party = social_->get_player_party(pid); + auto result = social_->leave_party(pid); + conn->send(network::make_party_leave_response(msg.seq, result == social::party_result::success)); + + if (result == social::party_result::success && party.is_valid()) + { + broadcast_party_update(party.value); + } +} + +void game_handlers::broadcast_party_update(uint32_t party_id_value) +{ + if (!social_ || !players_ || !ws_server_) + return; + + auto* party = social_->get_party(social::party_id{party_id_value}); + if (!party) + return; + + std::string leader_name; + std::vector member_names; + member_names.reserve(party->members.size()); + for (const auto& member : party->members) + { + auto* plr = players_->get_player(member.player); + if (!plr) + continue; + member_names.push_back(plr->name); + if (member.player == party->leader) + { + leader_name = plr->name; + } + } + + auto update = network::make_party_update(party_id_value, leader_name, member_names); + for (const auto& member : party->members) + { + auto* plr = players_->get_player(member.player); + if (!plr) + continue; + if (auto* conn = ws_server_->get_connection(plr->connection)) + { + if (conn->is_open()) + { + conn->send(update); + } + } + } +} + } // namespace hb::bridge diff --git a/src/bridge/handlers/game_handlers_combat.cpp b/src/bridge/handlers/game_handlers_combat.cpp index 603a0e5..894880b 100644 --- a/src/bridge/handlers/game_handlers_combat.cpp +++ b/src/bridge/handlers/game_handlers_combat.cpp @@ -1044,8 +1044,7 @@ void game_handlers::execute_respawn(player_id pid, const std::string& map_name, player->mp = player->computed.max_mp / 2; // Set 3-second invulnerability - entity::entity player_entity{pid.value}; - combat_->set_invulnerable(player_entity, 3000); + combat_->set_invulnerable(player->ecs_entity, 3000); // Execute teleport to spawn execute_player_teleport(pid, player->connection, 0, map_name, pos, world::direction::south); @@ -1289,7 +1288,7 @@ void game_handlers::on_spell_cast(entity::entity caster, return; // Failed casts aren't visible // Find caster position - could be player or NPC - auto* caster_player = players_->get_player(player_id{caster.id}); + auto* caster_player = players_->get_player_by_entity(caster); if (!caster_player) return; // Only handle player casters for now @@ -1312,7 +1311,7 @@ void game_handlers::on_spell_cast(entity::entity caster, uint32_t target_eid = target_ent.id; // Try to get target position from player or NPC - if (auto* tp = players_->get_player(player_id{target_ent.id})) + if (auto* tp = players_->get_player_by_entity(target_ent)) { tx = tp->pos.x; ty = tp->pos.y; @@ -1320,7 +1319,7 @@ void game_handlers::on_spell_cast(entity::entity caster, } else if (npc_) { - if (auto* tn = npc_->get_npc(entity::entity{target_ent.id})) + if (auto* tn = npc_->get_npc(target_ent)) { tx = tn->pos.x; ty = tn->pos.y; @@ -1361,7 +1360,7 @@ void game_handlers::on_spell_cast(entity::entity caster, int16_t ty = caster_pos.y; uint32_t target_eid = target_ent.id; - if (auto* tp = players_->get_player(player_id{target_ent.id})) + if (auto* tp = players_->get_player_by_entity(target_ent)) { tx = tp->pos.x; ty = tp->pos.y; @@ -1391,7 +1390,7 @@ void game_handlers::on_spell_cast(entity::entity caster, int16_t ty = caster_pos.y; uint32_t target_eid = target_ent.id; - if (auto* tp = players_->get_player(player_id{target_ent.id})) + if (auto* tp = players_->get_player_by_entity(target_ent)) { tx = tp->pos.x; ty = tp->pos.y; diff --git a/src/bridge/handlers/game_handlers_npc.cpp b/src/bridge/handlers/game_handlers_npc.cpp index 5e459e1..2efa47e 100644 --- a/src/bridge/handlers/game_handlers_npc.cpp +++ b/src/bridge/handlers/game_handlers_npc.cpp @@ -342,15 +342,20 @@ void game_handlers::handle_npc_loot_drop(const npc::npc& n, entity::entity kille auto drop = npc::generate_kill_loot(*loot_registry_, n.sprite_id, n.gold_min, n.gold_max, n.has_owner()); // Award gold directly to killer - if (drop.gold > 0 && inventory_) + // killer is an ECS entity — resolve to player_id first (inventory buckets and + // connections are keyed by player_id, not by ecs entity id) + std::optional killer_pid; + if (players_) + killer_pid = players_->get_player_id_by_entity(killer); + if (drop.gold > 0 && inventory_ && killer_pid) { - auto killer_entity = entity_id{killer.id}; + auto killer_entity = entity_id{killer_pid->value}; inventory_->add_gold(killer_entity, drop.gold); // Send gold_update to killer if (ws_server_) { - if (auto* killer_conn = ws_server_->get_connection_by_player(player_id{killer.id})) + if (auto* killer_conn = ws_server_->get_connection_by_player(*killer_pid)) { killer_conn->send(network::make_gold_update({ .gold = static_cast(inventory_->get_gold(killer_entity)), @@ -362,7 +367,7 @@ void game_handlers::handle_npc_loot_drop(const npc::npc& n, entity::entity kille // Audit gold loot if (audit_) { - if (auto* plr = players_ ? players_->get_player(player_id{killer.id}) : nullptr) + if (auto* plr = players_ ? players_->get_player(*killer_pid) : nullptr) { std::string map_str; if (auto* map = world_->get_map(npc_map)) diff --git a/src/bridge/handlers/wave4_handlers.cpp b/src/bridge/handlers/wave4_handlers.cpp index 789ccc0..a9d3bf7 100644 --- a/src/bridge/handlers/wave4_handlers.cpp +++ b/src/bridge/handlers/wave4_handlers.cpp @@ -141,9 +141,10 @@ auto handle_attack_target(const handler_context& ctx, entity_id target_id) -> ha return handle_result::not_handled; } - // Check if we can attack - auto attacker_entity = entity::entity{ctx.player.value, 0}; // Player entity - auto target_entity = entity::entity{target_id.value, 0}; + // Check if we can attack — use the real ECS entity handles (ids on the wire + // are full entity ids; player_id values are NOT entity ids) + auto attacker_entity = attacker->ecs_entity; + auto target_entity = entity::entity{target_id.value}; if (!combat->can_attack(attacker_entity, target_entity)) { @@ -213,10 +214,23 @@ auto handle_cast_spell(const handler_context& ctx, spell_id spell, entity_id tar return handle_result::not_handled; } - auto caster = entity::entity{ctx.player.value, 0}; + auto* player_sys = subsystems().get(); + if (!player_sys) + { + return handle_result::not_handled; + } + + auto* caster_player = player_sys->get_player(ctx.player); + if (!caster_player) + { + return handle_result::not_handled; + } + + // Use the real ECS entity handle — player_id values are NOT entity ids + auto caster = caster_player->ecs_entity; // Build cast target - magic::cast_target cast_target{.target = entity::entity{target.value, 0}, .target_pos = world::position{0, 0}}; + magic::cast_target cast_target{.target = entity::entity{target.value}, .target_pos = world::position{0, 0}}; // Attempt instant cast auto result = magic->instant_cast(caster, spell, cast_target); diff --git a/src/config/config_system.cpp b/src/config/config_system.cpp index 7288266..0010bd6 100644 --- a/src/config/config_system.cpp +++ b/src/config/config_system.cpp @@ -205,6 +205,10 @@ auto config_system::load_yaml_config(const std::filesystem::path& path) -> resul server_config_.auth.max_characters_per_account = yaml_get(auth, "max_characters", 4); server_config_.auth.allow_registration = yaml_get(auth, "allow_registration", true); server_config_.auth.session_duration = std::chrono::seconds{yaml_get(auth, "session_timeout", 3600)}; + server_config_.auth.max_registration_attempts = + yaml_get(auth, "max_registration_attempts", 3); + server_config_.auth.registration_cooldown = + std::chrono::seconds{yaml_get(auth, "registration_cooldown", 3600)}; } // Forum auth section diff --git a/src/config/server_config.h b/src/config/server_config.h index 5c2a28d..2172bd9 100644 --- a/src/config/server_config.h +++ b/src/config/server_config.h @@ -48,6 +48,8 @@ struct auth_config bool allow_registration = true; uint32_t max_login_attempts = 5; std::chrono::seconds lockout_duration{300}; // 5 minutes + uint32_t max_registration_attempts = 3; + std::chrono::seconds registration_cooldown{3600}; // 1 hour }; // Forum authentication configuration (external PHP auth) diff --git a/src/core/enums.h b/src/core/enums.h index c2cd345..79fd500 100644 --- a/src/core/enums.h +++ b/src/core/enums.h @@ -179,11 +179,17 @@ enum class magic_type : uint8_t protection = 11, hold_paralyze = 12, invisibility = 13, + create_dynamic = 14, // Persistent ground fields (Spike-Field, Ice-Storm, Cloud-Kill) — pending implementation confusion = 16, poison = 17, berserk = 18, + damage_linear = 19, // Line damage from caster toward target (Bloody-Shock-Wave) polymorph = 20, + damage_area_no_center = 21, // Area damage without center-spot hit (Energy-Strike, Meteor-Strike) ice = 23, + damage_area_sp_down = 25, // Area damage + stamina drain (Earthworm-Strike) + armor_break = 26, // Area damage that ignores defense (Armor-Break); HBX Magic.cfg numbering + ice_linear = 27, // Line ice damage (Blizzard); HBX Magic.cfg numbering cancellation = 28, inhibition = 29, resurrection = 32 diff --git a/src/magic/magic_system.cpp b/src/magic/magic_system.cpp index 479a0ea..3092cfd 100644 --- a/src/magic/magic_system.cpp +++ b/src/magic/magic_system.cpp @@ -13,6 +13,7 @@ #include #include +#include #include namespace hb::magic @@ -169,7 +170,7 @@ auto magic_system::can_cast(hb::entity::entity caster, auto* player_sys = subsystems().get(); if (player_sys) { - if (auto* p = player_sys->get_player(player_id{caster.id})) + if (auto* p = player_sys->get_player_by_entity(caster)) { // Check if silenced if (p->has_status(player::player_status::silenced)) @@ -202,7 +203,7 @@ auto magic_system::can_cast(hb::entity::entity caster, // Original Helbreath: range is in screen units (1 unit = 12 tiles) if (spell->range > 0 && player_sys) { - auto* caster_player = player_sys->get_player(player_id{caster.id}); + auto* caster_player = player_sys->get_player_by_entity(caster); if (caster_player) { int16_t max_tile_range = spell->range * 12; @@ -217,7 +218,7 @@ auto magic_system::can_cast(hb::entity::entity caster, else if (target.has_entity()) { // Look up target position from player or NPC - if (auto* tp = player_sys->get_player(player_id{target.target.id})) + if (auto* tp = player_sys->get_player_by_entity(target.target)) { target_pos = tp->pos; has_target_pos = true; @@ -255,16 +256,16 @@ auto magic_system::can_cast(hb::entity::entity caster, // Safe zone blocks offensive PvP spells only if (spell->is_offensive() && target.has_entity()) { - bool caster_is_player = player_sys && player_sys->get_player(player_id{caster.id}) != nullptr; - bool target_is_player = player_sys && player_sys->get_player(player_id{target.target.id}) != nullptr; + bool caster_is_player = player_sys && player_sys->get_player_by_entity(caster) != nullptr; + bool target_is_player = player_sys && player_sys->get_player_by_entity(target.target) != nullptr; if (caster_is_player && target_is_player) { auto* world = subsystems().get(); if (world) { - auto* caster_p = player_sys->get_player(player_id{caster.id}); - auto* target_p = player_sys->get_player(player_id{target.target.id}); + auto* caster_p = player_sys->get_player_by_entity(caster); + auto* target_p = player_sys->get_player_by_entity(target.target); if (caster_p && target_p) { @@ -487,7 +488,7 @@ auto magic_system::calculate_damage(hb::entity::entity caster, spell_id spell_id auto* player_sys = subsystems().get(); if (player_sys) { - if (auto* p = player_sys->get_player(player_id{caster.id})) + if (auto* p = player_sys->get_player_by_entity(caster)) { // Apply INT scaling if (spell->int_scaling > 0) @@ -527,7 +528,7 @@ auto magic_system::calculate_heal(hb::entity::entity caster, spell_id spell_id) auto* player_sys = subsystems().get(); if (player_sys) { - if (auto* p = player_sys->get_player(player_id{caster.id})) + if (auto* p = player_sys->get_player_by_entity(caster)) { // Healing scales with INT and MAG if (spell->int_scaling > 0) @@ -595,7 +596,7 @@ auto magic_system::apply_spell_effect(hb::entity::entity caster, auto* player_sys = subsystems().get(); if (player_sys) { - if (auto* p = player_sys->get_player(player_id{caster.id})) + if (auto* p = player_sys->get_player_by_entity(caster)) { int32_t mana_cost = calculate_mana_cost(caster, spell.id); if (!p->spend_mp(mana_cost)) @@ -633,12 +634,16 @@ auto magic_system::apply_spell_effect(hb::entity::entity caster, } } - // Get targets for AOE spells + // Get targets for AOE and line spells std::vector targets; if (spell.is_aoe()) { targets = find_aoe_targets(caster, spell, target); } + else if (spell.target_type == spell_target::line) + { + targets = find_line_targets(caster, spell, target); + } else if (target.has_entity()) { targets.push_back(target.target); @@ -660,8 +665,25 @@ auto magic_system::apply_spell_effect(hb::entity::entity caster, { if (combat_sys) { - // Deal magic damage through combat system - combat_sys->deal_damage(t, result.damage_dealt, damage_type, caster); + if (spell.ignores_defense) + { + // Armor-break spells bypass target defense entirely + combat_sys->deal_pure_damage(t, result.damage_dealt, caster); + } + else + { + // Deal magic damage through combat system + combat_sys->deal_damage(t, result.damage_dealt, damage_type, caster); + } + } + + // Stamina drain (damage_area_sp_down spells, e.g. Earthworm-Strike) + if (spell.sp_drain > 0 && player_sys) + { + if (auto* tp = player_sys->get_player_by_entity(t)) + { + tp->sp = std::max(0, tp->sp - static_cast(spell.sp_drain)); + } } result.affected_targets.push_back(t); } @@ -679,7 +701,7 @@ auto magic_system::apply_spell_effect(hb::entity::entity caster, { if (player_sys) { - auto* target_player = player_sys->get_player(player_id{t.id}); + auto* target_player = player_sys->get_player_by_entity(t); if (target_player && is_resurrection && target_player->is_dead()) { // Resurrect: restore HP directly (apply_heal skips dead players) @@ -690,10 +712,10 @@ auto magic_system::apply_spell_effect(hb::entity::entity caster, LOG_INFO(general, "Entity {} resurrected player {} with {} HP", caster.id, t.id, target_player->hp); } - else + else if (target_player) { // Normal healing (skips dead players internally) - player_sys->apply_heal(player_id{t.id}, result.heal_applied); + player_sys->apply_heal(target_player->id, result.heal_applied); } } result.affected_targets.push_back(t); @@ -790,7 +812,7 @@ auto magic_system::find_aoe_targets(hb::entity::entity caster, // Look up target entity position from players or NPCs if (player_sys) { - if (auto* p = player_sys->get_player(player_id{target.target.id})) + if (auto* p = player_sys->get_player_by_entity(target.target)) { center = p->pos; has_center = true; @@ -815,7 +837,7 @@ auto magic_system::find_aoe_targets(hb::entity::entity caster, map_id caster_map{}; if (player_sys) { - if (auto* p = player_sys->get_player(player_id{caster.id})) + if (auto* p = player_sys->get_player_by_entity(caster)) { caster_map = p->current_map; } @@ -830,7 +852,7 @@ auto magic_system::find_aoe_targets(hb::entity::entity caster, int16_t radius = spell.aoe_radius > 0 ? spell.aoe_radius : 3; // Check if caster is a player (for safe zone PvP filtering) - bool caster_is_player = player_sys && player_sys->get_player(player_id{caster.id}) != nullptr; + bool caster_is_player = player_sys && player_sys->get_player_by_entity(caster) != nullptr; auto* world = subsystems().get(); // Get players in range @@ -839,10 +861,10 @@ auto magic_system::find_aoe_targets(hb::entity::entity caster, if (player_sys) { player_sys->for_each_player( - [&](player_id id, const player::player& p) + [&](player_id /*id*/, const player::player& p) { // Never hit the caster with their own offensive AOE - if (id.value == caster.id) + if (p.ecs_entity == caster) { return; } @@ -851,7 +873,7 @@ auto magic_system::find_aoe_targets(hb::entity::entity caster, // For enemy AOE, skip allies (same faction) if (spell.target_type == spell_target::aoe_enemy) { - auto* caster_player = player_sys->get_player(player_id{caster.id}); + auto* caster_player = player_sys->get_player_by_entity(caster); if (caster_player && p.faction == caster_player->faction) { return; // Skip allies @@ -868,7 +890,7 @@ auto magic_system::find_aoe_targets(hb::entity::entity caster, } } } - targets.push_back(hb::entity::entity{id.value, 0}); + targets.push_back(p.ecs_entity); } }); } @@ -879,14 +901,14 @@ auto magic_system::find_aoe_targets(hb::entity::entity caster, if (player_sys) { player_sys->for_each_player( - [&](player_id id, const player::player& p) + [&](player_id /*id*/, const player::player& p) { if (p.current_map == caster_map && p.pos.manhattan_distance(center) <= radius) { // For ally AOE, only include same faction if (spell.target_type == spell_target::aoe_ally) { - auto* caster_player = player_sys->get_player(player_id{caster.id}); + auto* caster_player = player_sys->get_player_by_entity(caster); if (caster_player && p.faction != caster_player->faction) { return; // Skip enemies @@ -896,7 +918,7 @@ auto magic_system::find_aoe_targets(hb::entity::entity caster, bool already_added = false; for (const auto& t : targets) { - if (t.id == id.value) + if (t == p.ecs_entity) { already_added = true; break; @@ -904,7 +926,7 @@ auto magic_system::find_aoe_targets(hb::entity::entity caster, } if (!already_added) { - targets.push_back(hb::entity::entity{id.value, 0}); + targets.push_back(p.ecs_entity); } } }); @@ -927,6 +949,136 @@ auto magic_system::find_aoe_targets(hb::entity::entity caster, return targets; } +auto magic_system::find_line_targets(hb::entity::entity caster, + const spell_template& /*spell*/, + const cast_target& target) const -> std::vector +{ + std::vector targets; + + auto* player_sys = subsystems().get(); + auto* npc_sys = subsystems().get(); + if (!player_sys) + return targets; + + const auto* caster_player = player_sys->get_player_by_entity(caster); + if (!caster_player) + return targets; + + hb::world::position start = caster_player->pos; + map_id caster_map = caster_player->current_map; + + // Resolve line endpoint: clicked position, or target entity position + hb::world::position end{}; + bool has_end = false; + if (target.has_position()) + { + end = target.target_pos; + has_end = true; + } + else if (target.has_entity()) + { + if (auto* tp = player_sys->get_player_by_entity(target.target)) + { + end = tp->pos; + has_end = true; + } + else if (npc_sys) + { + if (auto* n = npc_sys->get_npc(target.target)) + { + end = n->pos; + has_end = true; + } + } + } + + if (!has_end || !caster_map.is_valid() || (end.x == start.x && end.y == start.y)) + { + return targets; + } + + // Trace tiles along the line from caster (exclusive) toward the endpoint, + // continuing past it up to one screen unit (12 tiles) as legacy linear spells do + constexpr int max_line_tiles = 12; + std::vector line_tiles; + { + int x = start.x; + int y = start.y; + int dx = std::abs(end.x - x); + int dy = -std::abs(end.y - y); + int sx = x < end.x ? 1 : -1; + int sy = y < end.y ? 1 : -1; + int err = dx + dy; + while (static_cast(line_tiles.size()) < max_line_tiles) + { + if (x == end.x && y == end.y) + break; + int e2 = 2 * err; + if (e2 >= dy) + { + err += dy; + x += sx; + } + if (e2 <= dx) + { + err += dx; + y += sy; + } + line_tiles.push_back(hb::world::position{static_cast(x), static_cast(y)}); + } + } + + auto on_line = [&](const hb::world::position& p) + { + for (const auto& t : line_tiles) + { + if (t.x == p.x && t.y == p.y) + return true; + } + return false; + }; + + auto* world = subsystems().get(); + + // Players on the line (enemies only; safe zones protect PvP targets) + player_sys->for_each_player( + [&](player_id /*id*/, const player::player& p) + { + if (p.ecs_entity == caster) + return; + if (p.current_map != caster_map || !on_line(p.pos)) + return; + if (p.faction == caster_player->faction) + return; + if (world) + { + if (auto* m = world->get_map(p.current_map)) + { + if (m->is_safe_zone(p.pos)) + return; + } + } + targets.push_back(p.ecs_entity); + }); + + // NPCs on the line + if (npc_sys) + { + for (auto npc_entity : npc_sys->get_npcs_in_range(caster_map, start, max_line_tiles)) + { + if (auto* n = npc_sys->get_npc(npc_entity)) + { + if (on_line(n->pos)) + { + targets.push_back(npc_entity); + } + } + } + } + + return targets; +} + auto magic_system::element_to_damage_type(spell_element element) const -> combat::damage_type { switch (element) @@ -1008,11 +1160,19 @@ void magic_system::apply_debuff(hb::entity::entity caster, hb::entity::entity ta return; } + // Cancellation (type 28) dispels the target's active effects instead of applying one + if (spell.spell_type == magic_type::cancellation) + { + effect_sys->remove_all_effects(target); + LOG_DEBUG(magic, "Cancellation removed all effects from entity {}", target.id); + return; + } + // Check resist before applying auto* player_sys = subsystems().get(); if (player_sys) { - if (auto* p = player_sys->get_player(player_id{target.id})) + if (auto* p = player_sys->get_player_by_entity(target)) { thread_local std::mt19937 rng{std::random_device{}()}; std::uniform_int_distribution dist(0, 99); diff --git a/src/magic/magic_system.h b/src/magic/magic_system.h index 1bf5154..2956a1c 100644 --- a/src/magic/magic_system.h +++ b/src/magic/magic_system.h @@ -118,6 +118,9 @@ class magic_system : public subsystem auto find_aoe_targets(hb::entity::entity caster, const spell_template& spell, const cast_target& target) const -> std::vector; + auto find_line_targets(hb::entity::entity caster, + const spell_template& spell, + const cast_target& target) const -> std::vector; auto element_to_damage_type(spell_element element) const -> combat::damage_type; void apply_buff(hb::entity::entity caster, hb::entity::entity target, const spell_template& spell); void apply_debuff(hb::entity::entity caster, hb::entity::entity target, const spell_template& spell); diff --git a/src/magic/spell.h b/src/magic/spell.h index 2854143..2329826 100644 --- a/src/magic/spell.h +++ b/src/magic/spell.h @@ -104,6 +104,7 @@ struct spell_template int32_t base_damage{0}; int32_t base_heal{0}; int16_t effect_value{0}; // Generic effect magnitude + int16_t sp_drain{0}; // Stamina drained from each target hit (damage_area_sp_down) // Scaling int16_t int_scaling{0}; // % of INT added to damage/heal diff --git a/src/network/json_protocol.cpp b/src/network/json_protocol.cpp index 1c333a7..a457f19 100644 --- a/src/network/json_protocol.cpp +++ b/src/network/json_protocol.cpp @@ -137,6 +137,7 @@ const std::unordered_map type_map = { {"equipment_change_broadcast", json_message_type::equipment_change_broadcast}, {"stat_update", json_message_type::stat_update}, {"spell_list_update", json_message_type::spell_list_update}, + {"experience_update", json_message_type::experience_update}, {"shop_buy_request", json_message_type::shop_buy_request}, {"shop_buy_response", json_message_type::shop_buy_response}, {"shop_sell_request", json_message_type::shop_sell_request}, @@ -153,6 +154,14 @@ const std::unordered_map type_map = { {"bank_withdraw_response", json_message_type::bank_withdraw_response}, {"dialog_choice_request", json_message_type::dialog_choice_request}, {"dialog_choice_response", json_message_type::dialog_choice_response}, + {"party_invite_request", json_message_type::party_invite_request}, + {"party_invite_response", json_message_type::party_invite_response}, + {"party_invite_notice", json_message_type::party_invite_notice}, + {"party_accept_request", json_message_type::party_accept_request}, + {"party_accept_response", json_message_type::party_accept_response}, + {"party_leave_request", json_message_type::party_leave_request}, + {"party_leave_response", json_message_type::party_leave_response}, + {"party_update", json_message_type::party_update}, {"manufacture_list_request", json_message_type::manufacture_list_request}, {"manufacture_list_response", json_message_type::manufacture_list_response}, {"manufacture_request", json_message_type::manufacture_request}, @@ -2573,6 +2582,79 @@ auto dialog_choice_request_data::from_json(const nlohmann::json& j) -> result::ok(std::move(data)); } +// === Party: request parsing and builders === + +auto party_invite_request_data::from_json(const nlohmann::json& j) -> result +{ + party_invite_request_data data; + if (!j.contains("target_name") || !j["target_name"].is_string()) + { + return result::err("Missing target_name"); + } + data.target_name = j["target_name"].get(); + return result::ok(std::move(data)); +} + +auto party_accept_request_data::from_json(const nlohmann::json& j) -> result +{ + party_accept_request_data data; + if (!j.contains("party_id") || !j["party_id"].is_number()) + { + return result::err("Missing party_id"); + } + data.party_id = j["party_id"].get(); + if (j.contains("accept") && j["accept"].is_boolean()) + { + data.accept = j["accept"].get(); + } + return result::ok(std::move(data)); +} + +auto make_party_invite_response(uint32_t seq, bool success, uint32_t party_id, std::string_view error) -> json_message +{ + nlohmann::json data{{"success", success}}; + if (success) + data["party_id"] = party_id; + else + data["error"] = std::string(error); + return json_message{.type = json_message_type::party_invite_response, .seq = seq, .data = std::move(data)}; +} + +auto make_party_invite_notice(uint32_t party_id, std::string_view inviter_name) -> json_message +{ + return json_message{.type = json_message_type::party_invite_notice, + .seq = 0, + .data = nlohmann::json{{"party_id", party_id}, {"inviter_name", std::string(inviter_name)}}}; +} + +auto make_party_accept_response(uint32_t seq, bool success, uint32_t party_id, std::string_view error) -> json_message +{ + nlohmann::json data{{"success", success}}; + if (success) + data["party_id"] = party_id; + else + data["error"] = std::string(error); + return json_message{.type = json_message_type::party_accept_response, .seq = seq, .data = std::move(data)}; +} + +auto make_party_leave_response(uint32_t seq, bool success) -> json_message +{ + return json_message{.type = json_message_type::party_leave_response, + .seq = seq, + .data = nlohmann::json{{"success", success}}}; +} + +auto make_party_update(uint32_t party_id, + std::string_view leader_name, + const std::vector& member_names) -> json_message +{ + return json_message{.type = json_message_type::party_update, + .seq = 0, + .data = nlohmann::json{{"party_id", party_id}, + {"leader_name", std::string(leader_name)}, + {"members", member_names}}}; +} + // === NPC Interaction: builder implementations === auto make_shop_buy_response(uint32_t seq, @@ -2869,6 +2951,29 @@ auto make_stat_update(const stat_update_data& data) -> json_message return json_message{.type = json_message_type::stat_update, .seq = 0, .data = data.to_json()}; } +auto experience_update_data::to_json() const -> nlohmann::json +{ + auto j = nlohmann::json{{"experience_gained", experience_gained}, {"experience", experience}, {"level", level}}; + + if (levels_gained) + j["levels_gained"] = *levels_gained; + if (max_hp) + j["max_hp"] = *max_hp; + if (max_mp) + j["max_mp"] = *max_mp; + if (max_sp) + j["max_sp"] = *max_sp; + if (stat_points) + j["stat_points"] = *stat_points; + + return j; +} + +auto make_experience_update(const experience_update_data& data) -> json_message +{ + return json_message{.type = json_message_type::experience_update, .seq = 0, .data = data.to_json()}; +} + // === Inventory Reposition === auto inventory_reposition_request_data::from_json(const nlohmann::json& j) -> result diff --git a/src/network/json_protocol.h b/src/network/json_protocol.h index 73cdcdb..007a486 100644 --- a/src/network/json_protocol.h +++ b/src/network/json_protocol.h @@ -158,6 +158,7 @@ enum class json_message_type equipment_change_broadcast, stat_update, spell_list_update, + experience_update, // NPC interaction - shops shop_buy_request, @@ -181,6 +182,16 @@ enum class json_message_type dialog_choice_request, dialog_choice_response, + // Party + party_invite_request, + party_invite_response, + party_invite_notice, + party_accept_request, + party_accept_response, + party_leave_request, + party_leave_response, + party_update, + // Crafting - manufacturing manufacture_list_request, manufacture_list_response, @@ -722,6 +733,8 @@ enum class json_message_type return "stat_update"; case json_message_type::spell_list_update: return "spell_list_update"; + case json_message_type::experience_update: + return "experience_update"; case json_message_type::shop_buy_request: return "shop_buy_request"; case json_message_type::shop_buy_response: @@ -754,6 +767,22 @@ enum class json_message_type return "dialog_choice_request"; case json_message_type::dialog_choice_response: return "dialog_choice_response"; + case json_message_type::party_invite_request: + return "party_invite_request"; + case json_message_type::party_invite_response: + return "party_invite_response"; + case json_message_type::party_invite_notice: + return "party_invite_notice"; + case json_message_type::party_accept_request: + return "party_accept_request"; + case json_message_type::party_accept_response: + return "party_accept_response"; + case json_message_type::party_leave_request: + return "party_leave_request"; + case json_message_type::party_leave_response: + return "party_leave_response"; + case json_message_type::party_update: + return "party_update"; case json_message_type::manufacture_list_request: return "manufacture_list_request"; case json_message_type::manufacture_list_response: @@ -2364,9 +2393,28 @@ struct stat_update_data [[nodiscard]] auto to_json() const -> nlohmann::json; }; +// Experience update sent to a player whenever they gain experience. +// Level-up fields are present only when the gain caused one or more level-ups. +struct experience_update_data +{ + int64_t experience_gained{0}; + int64_t experience{0}; + uint8_t level{0}; + + // Present only on level-up + std::optional levels_gained; + std::optional max_hp; + std::optional max_mp; + std::optional max_sp; + std::optional stat_points; // Total unspent stat points after the award + + [[nodiscard]] auto to_json() const -> nlohmann::json; +}; + // Equipment message builders [[nodiscard]] auto make_equipment_change_broadcast(const equipment_change_broadcast_data& data) -> json_message; [[nodiscard]] auto make_stat_update(const stat_update_data& data) -> json_message; +[[nodiscard]] auto make_experience_update(const experience_update_data& data) -> json_message; // Spell list update - sends full known spell list to client [[nodiscard]] auto make_spell_list_update(const std::vector& spells) -> json_message; @@ -2489,6 +2537,36 @@ struct dialog_choice_request_data [[nodiscard]] static auto from_json(const nlohmann::json& j) -> result; }; +// === Party messages === + +// Invite a player (by character name) to the sender's party. +// Creates the party implicitly when the sender is not in one. +struct party_invite_request_data +{ + std::string target_name; + + [[nodiscard]] static auto from_json(const nlohmann::json& j) -> result; +}; + +// Accept (or decline) a pending party invite. +struct party_accept_request_data +{ + uint32_t party_id{0}; + bool accept{true}; + + [[nodiscard]] static auto from_json(const nlohmann::json& j) -> result; +}; + +[[nodiscard]] auto make_party_invite_response(uint32_t seq, bool success, uint32_t party_id, std::string_view error) + -> json_message; +[[nodiscard]] auto make_party_invite_notice(uint32_t party_id, std::string_view inviter_name) -> json_message; +[[nodiscard]] auto make_party_accept_response(uint32_t seq, bool success, uint32_t party_id, std::string_view error) + -> json_message; +[[nodiscard]] auto make_party_leave_response(uint32_t seq, bool success) -> json_message; +[[nodiscard]] auto make_party_update(uint32_t party_id, + std::string_view leader_name, + const std::vector& member_names) -> json_message; + // === NPC Interaction response builders === // Shop buy response diff --git a/src/npc/npc_system.cpp b/src/npc/npc_system.cpp index 1b1c748..b81b786 100644 --- a/src/npc/npc_system.cpp +++ b/src/npc/npc_system.cpp @@ -1343,17 +1343,9 @@ auto npc_system::find_aggro_target(const npc& npc_ref) -> entity::entity if (!player_sys) continue; - // Find player by ecs_entity index - player::player* p = nullptr; - player_sys->for_each_player( - [&](player_id, player::player& player) - { - if (player.ecs_entity.index() == entry.entity.index()) - { - p = &player; - } - }); - + // O(1) lookup via the ecs-index map (was a full for_each_player scan per entity, + // every 100ms per NPC — quadratic with many players) + player::player* p = player_sys->get_player_by_entity(entry.entity); if (!p) continue; diff --git a/src/npc/spot_mob_mapping.h b/src/npc/spot_mob_mapping.h index 3378faf..793461d 100644 --- a/src/npc/spot_mob_mapping.h +++ b/src/npc/spot_mob_mapping.h @@ -40,6 +40,10 @@ inline auto spot_mob_type_to_name(int type) -> std::optional return "Amphis"; case 23: return "Clay-Golem"; + case 15: + return "ShopKeeper-W"; + case 19: + return "Gandlf"; case 24: return "Guard-Aresden"; case 25: diff --git a/src/platform/posix_time_compat.h b/src/platform/posix_time_compat.h new file mode 100644 index 0000000..1b72eb3 --- /dev/null +++ b/src/platform/posix_time_compat.h @@ -0,0 +1,19 @@ +#pragma once + +// POSIX time-function shims for MSVC/Windows builds. +// timegm / gmtime_r / localtime_r are POSIX; MSVC provides _mkgmtime / +// gmtime_s / localtime_s with different (reversed) argument order. + +#include + +#ifdef _WIN32 +inline std::time_t timegm(std::tm* tm) { return _mkgmtime(tm); } +inline std::tm* gmtime_r(const std::time_t* t, std::tm* result) +{ + return ::gmtime_s(result, t) == 0 ? result : nullptr; +} +inline std::tm* localtime_r(const std::time_t* t, std::tm* result) +{ + return ::localtime_s(result, t) == 0 ? result : nullptr; +} +#endif diff --git a/src/player/player_system.cpp b/src/player/player_system.cpp index b791b2f..038c784 100644 --- a/src/player/player_system.cpp +++ b/src/player/player_system.cpp @@ -113,6 +113,15 @@ auto player_system::create_player(const player_create_info& info) -> result::ok(id); } +void player_system::set_sees_all(player_id id, bool enabled) +{ + auto* p = get_player(id); + if (!p || p->sees_all == enabled) + return; + p->sees_all = enabled; + sees_all_count_ += enabled ? 1 : -1; +} + void player_system::remove_player(player_id id) { auto it = players_.find(id); @@ -120,12 +129,14 @@ void player_system::remove_player(player_id id) return; auto& p = *it->second; + if (p.sees_all) + --sees_all_count_; // Remove active spell effects auto* effect_sys = subsystems().get(); if (effect_sys && p.ecs_entity.is_valid()) { - effect_sys->remove_all_effects(entity::entity{p.id.value}); + effect_sys->remove_all_effects(p.ecs_entity); } // Remove from spatial index @@ -311,6 +322,7 @@ void player_system::add_experience(player_id id, int64_t amount) if (!p) return; + int64_t previous_exp = p->experience.experience; int levels_gained = p->experience.add_experience(amount); if (levels_gained > 0) { @@ -322,6 +334,14 @@ void player_system::add_experience(player_id id, int64_t amount) LOG_INFO(general, "Player '{}' leveled up to {}", p->name, p->experience.level); } + + // Notify listeners (e.g., bridge sends experience_update to the client). + // No-op at max level, where add_experience leaves experience unchanged. + int64_t gained = p->experience.experience - previous_exp; + if (gained > 0 && experience_callback_) + { + experience_callback_(id, gained, levels_gained); + } } void player_system::add_stat_point(player_id id, int16_t stat_index) @@ -1192,11 +1212,16 @@ auto player_system::get_players_who_can_see(map_id map, const hb::world::positio } } - // Find sees_all admins on this map who are beyond the spatial query range - auto far_admins = find_players_if( - [&](const player& p) - { return p.sees_all && p.current_map == map && pos.chebyshev_distance(p.pos) > max_visibility; }); - result.insert(result.end(), far_admins.begin(), far_admins.end()); + // Find sees_all admins on this map who are beyond the spatial query range. + // Guarded by the incremental counter: in the common case (no admins in view mode) + // this skips a full scan of every player on every broadcast. + if (sees_all_count_ > 0) + { + auto far_admins = find_players_if( + [&](const player& p) + { return p.sees_all && p.current_map == map && pos.chebyshev_distance(p.pos) > max_visibility; }); + result.insert(result.end(), far_admins.begin(), far_admins.end()); + } return result; } diff --git a/src/player/player_system.h b/src/player/player_system.h index b054e71..0326c3a 100644 --- a/src/player/player_system.h +++ b/src/player/player_system.h @@ -70,6 +70,11 @@ class player_system : public subsystem auto create_player(const player_create_info& info) -> result; void remove_player(player_id id); + // Toggle admin "sees everything on map" mode; keeps the internal sees_all counter + // consistent so broadcast visibility queries can skip the far-admin scan when zero. + // Always use this instead of writing player::sees_all directly. + void set_sees_all(player_id id, bool enabled); + // Player access [[nodiscard]] auto get_player(player_id id) -> player*; [[nodiscard]] auto get_player(player_id id) const -> const player*; @@ -204,6 +209,10 @@ class player_system : public subsystem void on_hunger_change(hunger_change_callback callback) { hunger_callback_ = std::move(callback); } void restore_hunger(player_id id, int8_t amount); + // Experience gain notification (fired from add_experience when experience actually changes) + using experience_gain_callback = std::function; + void on_experience_gain(experience_gain_callback callback) { experience_callback_ = std::move(callback); } + // Iteration template requires std::invocable @@ -252,6 +261,7 @@ class player_system : public subsystem uint32_t next_id_{1}; std::unordered_map> players_; + int sees_all_count_{0}; // players with sees_all enabled (see set_sees_all) std::unordered_map name_to_id_; std::unordered_map connection_to_id_; std::unordered_map session_to_id_; @@ -265,6 +275,7 @@ class player_system : public subsystem // Callbacks regen_callback regen_callback_; hunger_change_callback hunger_callback_; + experience_gain_callback experience_callback_; }; } // namespace hb::player diff --git a/src/registry/magic_registry.cpp b/src/registry/magic_registry.cpp index f03547b..1b92b73 100644 --- a/src/registry/magic_registry.cpp +++ b/src/registry/magic_registry.cpp @@ -34,11 +34,17 @@ auto is_valid_magic_type(int val) -> bool case 11: case 12: case 13: + case 14: case 16: case 17: case 18: + case 19: case 20: + case 21: case 23: + case 25: + case 26: + case 27: case 28: case 29: case 32: @@ -124,6 +130,14 @@ auto magic_registry::load_from_file(const std::filesystem::path& path) -> result } spell.type = static_cast(type_val); } + + // Ground-field spells (create_dynamic) need the dynamic object subsystem, which + // is not implemented yet — skip them without counting as data errors. + if (spell.type == magic_type::create_dynamic) + { + LOG_INFO(magic, "Spell {}: create_dynamic (ground fields) pending implementation, skipping", spell.name); + continue; + } spell.mana_cost = static_cast(node["mana_cost"].as(0)); spell.cast_time_ms = static_cast(node["delay"].as(0)); spell.int_req = static_cast(node["int_req"].as(0)); @@ -150,6 +164,16 @@ auto magic_registry::load_from_file(const std::filesystem::path& path) -> result spell.effects.push_back(se); } + // effect2 carries the stamina-drain dice for damage_area_sp_down spells + if (spell.type == magic_type::damage_area_sp_down && node["effect2"]) + { + auto e = node["effect2"]; + int dice = e["dice"].as(0); + int sides = e["sides"].as(0); + int bonus = e["bonus"].as(0); + spell.sp_drain = static_cast(dice * (sides + 1) / 2 + bonus); + } + // Determine offensive/targeting from type switch (spell.type) { @@ -157,6 +181,11 @@ auto magic_registry::load_from_file(const std::filesystem::path& path) -> result case magic_type::damage_area: case magic_type::poison: case magic_type::ice: + case magic_type::damage_linear: + case magic_type::damage_area_no_center: + case magic_type::damage_area_sp_down: + case magic_type::armor_break: + case magic_type::ice_linear: spell.is_offensive = true; break; case magic_type::hp_up_spot: diff --git a/src/registry/npc_registry.cpp b/src/registry/npc_registry.cpp index 8838004..27639c6 100644 --- a/src/registry/npc_registry.cpp +++ b/src/registry/npc_registry.cpp @@ -215,6 +215,10 @@ auto npc_registry::load_from_yaml(const std::filesystem::path& path) -> result(node["min_bravery"].as()); if (node["exp"]) npc.exp_reward = node["exp"].as(); + if (node["gold_min"]) + npc.gold_min = node["gold_min"].as(); + if (node["gold_max"]) + npc.gold_max = node["gold_max"].as(); if (node["attack_dice"]) npc.attack_dice = static_cast(node["attack_dice"].as()); if (node["attack_sides"]) diff --git a/src/registry/spell_template.h b/src/registry/spell_template.h index eab9a64..95631cb 100644 --- a/src/registry/spell_template.h +++ b/src/registry/spell_template.h @@ -92,6 +92,7 @@ struct spell_template int16_t base_damage{0}; int16_t int_scaling{0}; // Per 10 INT int16_t mag_scaling{0}; // Per 10 MAG + int16_t sp_drain{0}; // Stamina drained from targets (damage_area_sp_down, from effect2 dice) // Effect duration duration_ms effect_duration{0}; diff --git a/src/war/apocalypse/apocalypse_system.cpp b/src/war/apocalypse/apocalypse_system.cpp index f2dafce..43879e9 100644 --- a/src/war/apocalypse/apocalypse_system.cpp +++ b/src/war/apocalypse/apocalypse_system.cpp @@ -2,6 +2,7 @@ // Apocalypse gate-toggle event implementation #include "war/apocalypse/apocalypse_system.h" +#include "platform/posix_time_compat.h" #include "network/json_protocol.h" #include "core/logger.h" diff --git a/src/war/crusade/crusade_system.cpp b/src/war/crusade/crusade_system.cpp index a59a650..1f58a72 100644 --- a/src/war/crusade/crusade_system.cpp +++ b/src/war/crusade/crusade_system.cpp @@ -2,6 +2,7 @@ // Crusade warfare subsystem implementation #include "war/crusade/crusade_system.h" +#include "platform/posix_time_compat.h" #include "war/war_system.h" #include "war/war_persistence.h" #include "war/force_recall/force_recall_system.h" diff --git a/src/war/force_recall/force_recall_system.cpp b/src/war/force_recall/force_recall_system.cpp index 687eee4..210c2b8 100644 --- a/src/war/force_recall/force_recall_system.cpp +++ b/src/war/force_recall/force_recall_system.cpp @@ -2,6 +2,7 @@ // Force recall system implementation #include "war/force_recall/force_recall_system.h" +#include "platform/posix_time_compat.h" #include "network/json_protocol.h" #include "core/logger.h" diff --git a/src/war/heldenian/heldenian_system.cpp b/src/war/heldenian/heldenian_system.cpp index b6e4f28..94c4b54 100644 --- a/src/war/heldenian/heldenian_system.cpp +++ b/src/war/heldenian/heldenian_system.cpp @@ -2,6 +2,7 @@ // Heldenian warfare subsystem implementation #include "war/heldenian/heldenian_system.h" +#include "platform/posix_time_compat.h" #include "war/war_system.h" #include "player/player_system.h" #include "player/player.h" diff --git a/src/war/war_persistence.cpp b/src/war/war_persistence.cpp index 4ecd4f2..91acdd2 100644 --- a/src/war/war_persistence.cpp +++ b/src/war/war_persistence.cpp @@ -2,6 +2,7 @@ // Database operations for war history and participant rewards #include "war/war_persistence.h" +#include "platform/posix_time_compat.h" #include "database/database_system.h" #include "core/logger.h" diff --git a/src/world/world_subsystem.cpp b/src/world/world_subsystem.cpp index 31e9d5c..d718d5a 100644 --- a/src/world/world_subsystem.cpp +++ b/src/world/world_subsystem.cpp @@ -86,9 +86,12 @@ auto world_subsystem::load_map(const std::filesystem::path& path) -> result(); // TODO: Parse map file header to get config - // For now, use filename as map name + // For now, use filename as map name — normalized to lowercase so lookups by + // name ("aresden", "elvine") work regardless of the .amd file's casing map_config config; - config.name = path.stem().string(); + auto stem = path.stem().string(); + std::transform(stem.begin(), stem.end(), stem.begin(), [](unsigned char c) { return std::tolower(c); }); + config.name = std::move(stem); config.width = 700; // Default Helbreath map size config.height = 550; diff --git a/tests/test_crusade_system.cpp b/tests/test_crusade_system.cpp index 4121ca8..1b9ad3b 100644 --- a/tests/test_crusade_system.cpp +++ b/tests/test_crusade_system.cpp @@ -3282,7 +3282,11 @@ TEST_F(crusade_system_test, different_day_allows_start) auto now = std::chrono::system_clock::now(); auto time_t_now = std::chrono::system_clock::to_time_t(now); std::tm local_tm{}; +#ifdef _WIN32 + localtime_s(&local_tm, &time_t_now); +#else localtime_r(&time_t_now, &local_tm); +#endif if (local_tm.tm_wday == 6) { diff --git a/tests/test_magic.cpp b/tests/test_magic.cpp index 73f4ca6..9348a1c 100644 --- a/tests/test_magic.cpp +++ b/tests/test_magic.cpp @@ -703,6 +703,9 @@ class magic_aoe_test : public ::testing::Test return pid; } + // Resolve the real ECS entity handle for a player (player_id values are NOT entity ids) + auto ecs(player_id pid) -> hb::entity::entity { return player_sys_->get_player(pid)->ecs_entity; } + hb::player::player_system* player_sys_{}; hb::world::world_subsystem* world_{}; hb::magic::magic_system* magic_{}; @@ -715,8 +718,8 @@ TEST_F(magic_aoe_test, aoe_spell_excludes_caster) auto caster_pid = create_player_at({50, 50}, hb::faction::aresden); auto enemy_pid = create_player_at({52, 50}, hb::faction::elvine); - entity caster_e(caster_pid.value); - entity enemy_e(enemy_pid.value); + auto caster_e = ecs(caster_pid); + auto enemy_e = ecs(enemy_pid); magic_->learn_spell(caster_e, hb::spell_id(1)); @@ -735,9 +738,9 @@ TEST_F(magic_aoe_test, aoe_spell_excludes_caster) bool caster_hit = false; for (auto& t : effect.affected_targets) { - if (t.id == enemy_pid.value) + if (t == enemy_e) enemy_hit = true; - if (t.id == caster_pid.value) + if (t == caster_e) caster_hit = true; } EXPECT_TRUE(enemy_hit); @@ -750,7 +753,7 @@ TEST_F(magic_aoe_test, aoe_spell_skips_same_faction) auto ally_pid = create_player_at({51, 50}, hb::faction::aresden); auto enemy_pid = create_player_at({52, 50}, hb::faction::elvine); - entity caster_e(caster_pid.value); + auto caster_e = ecs(caster_pid); magic_->learn_spell(caster_e, hb::spell_id(1)); @@ -765,9 +768,9 @@ TEST_F(magic_aoe_test, aoe_spell_skips_same_faction) bool enemy_hit = false; for (auto& t : effect.affected_targets) { - if (t.id == ally_pid.value) + if (t == ecs(ally_pid)) ally_hit = true; - if (t.id == enemy_pid.value) + if (t == ecs(enemy_pid)) enemy_hit = true; } EXPECT_FALSE(ally_hit) << "Same-faction ally should not be hit by enemy AOE"; @@ -780,7 +783,7 @@ TEST_F(magic_aoe_test, aoe_spell_center_from_position) auto far_enemy = create_player_at({50, 50}, hb::faction::elvine); auto near_enemy = create_player_at({22, 10}, hb::faction::elvine); - entity caster_e(caster_pid.value); + auto caster_e = ecs(caster_pid); magic_->learn_spell(caster_e, hb::spell_id(1)); @@ -796,9 +799,9 @@ TEST_F(magic_aoe_test, aoe_spell_center_from_position) bool far_hit = false; for (auto& t : effect.affected_targets) { - if (t.id == near_enemy.value) + if (t == ecs(near_enemy)) near_hit = true; - if (t.id == far_enemy.value) + if (t == ecs(far_enemy)) far_hit = true; } EXPECT_TRUE(near_hit) << "Enemy within AOE radius should be hit"; @@ -810,8 +813,8 @@ TEST_F(magic_aoe_test, range_check_blocks_distant_target) auto caster_pid = create_player_at({10, 10}); auto enemy_pid = create_player_at({80, 80}); // ~140 manhattan distance - entity caster_e(caster_pid.value); - entity enemy_e(enemy_pid.value); + auto caster_e = ecs(caster_pid); + auto enemy_e = ecs(enemy_pid); magic_->learn_spell(caster_e, hb::spell_id(2)); // range=1 (12 tiles) @@ -827,8 +830,8 @@ TEST_F(magic_aoe_test, range_check_allows_close_target) auto caster_pid = create_player_at({50, 50}); auto enemy_pid = create_player_at({55, 50}); // 5 tiles manhattan - entity caster_e(caster_pid.value); - entity enemy_e(enemy_pid.value); + auto caster_e = ecs(caster_pid); + auto enemy_e = ecs(enemy_pid); magic_->learn_spell(caster_e, hb::spell_id(2)); // range=1 (12 tiles) @@ -843,7 +846,7 @@ TEST_F(magic_aoe_test, range_check_with_position_target) { auto caster_pid = create_player_at({50, 50}); - entity caster_e(caster_pid.value); + auto caster_e = ecs(caster_pid); magic_->learn_spell(caster_e, hb::spell_id(1)); // range=2 (24 tiles) @@ -861,7 +864,7 @@ TEST_F(magic_aoe_test, range_check_with_position_target) TEST_F(magic_aoe_test, self_target_blocked_for_offensive_spell) { auto caster_pid = create_player_at({50, 50}); - entity caster_e(caster_pid.value); + auto caster_e = ecs(caster_pid); magic_->learn_spell(caster_e, hb::spell_id(2)); @@ -877,8 +880,8 @@ TEST_F(magic_aoe_test, mana_deducted_on_instant_cast) auto caster_pid = create_player_at({50, 50}, hb::faction::aresden); auto enemy_pid = create_player_at({52, 50}, hb::faction::elvine); - entity caster_e(caster_pid.value); - entity enemy_e(enemy_pid.value); + auto caster_e = ecs(caster_pid); + auto enemy_e = ecs(enemy_pid); magic_->learn_spell(caster_e, hb::spell_id(1)); // mana_cost = 20 diff --git a/tests/test_melee_pve.cpp b/tests/test_melee_pve.cpp index ed69c66..1d564ed 100644 --- a/tests/test_melee_pve.cpp +++ b/tests/test_melee_pve.cpp @@ -280,7 +280,7 @@ TEST_F(melee_pve_combat_test, player_can_attack_npc_at_melee_range) int32_t initial_hp = n->hp; hb::combat::attack_event attack; - attack.attacker = hb::entity::entity(pid.value); + attack.attacker = player_sys_->get_player(pid)->ecs_entity; attack.defender = npc_eid; attack.type = hb::combat::damage_type::physical; attack.base_damage = 50; @@ -302,7 +302,7 @@ TEST_F(melee_pve_combat_test, npc_death_yields_rewards) ASSERT_NE(n, nullptr); hb::combat::attack_event attack; - attack.attacker = hb::entity::entity(pid.value); + attack.attacker = player_sys_->get_player(pid)->ecs_entity; attack.defender = npc_eid; attack.type = hb::combat::damage_type::physical; attack.base_damage = 10000; @@ -328,7 +328,7 @@ TEST_F(melee_pve_combat_test, npc_not_killed_by_zero_damage) ASSERT_NE(n, nullptr); hb::combat::attack_event attack; - attack.attacker = hb::entity::entity(pid.value); + attack.attacker = player_sys_->get_player(pid)->ecs_entity; attack.defender = npc_eid; attack.type = hb::combat::damage_type::physical; attack.base_damage = 0; diff --git a/tests/test_player.cpp b/tests/test_player.cpp index 5a06905..3b99f10 100644 --- a/tests/test_player.cpp +++ b/tests/test_player.cpp @@ -318,6 +318,48 @@ TEST_F(player_system_test, add_experience) EXPECT_GT(p->experience.level, 1); } +TEST_F(player_system_test, add_experience_fires_gain_callback) +{ + player_create_info info; + info.name = "ExpCallbackPlayer"; + + auto result = system_.create_player(info); + auto id = result.value(); + + hb::player_id cb_id{}; + int64_t cb_gained = 0; + int cb_levels = 0; + int cb_calls = 0; + system_.on_experience_gain( + [&](hb::player_id pid, int64_t gained, int levels) + { + cb_id = pid; + cb_gained = gained; + cb_levels = levels; + ++cb_calls; + }); + + // Small gain, no level-up + system_.add_experience(id, 10); + EXPECT_EQ(cb_calls, 1); + EXPECT_EQ(cb_id, id); + EXPECT_EQ(cb_gained, 10); + EXPECT_EQ(cb_levels, 0); + + // Gain enough to level up + int64_t to_level_5 = exp_table.exp_for_level(5) - system_.get_player(id)->experience.experience; + system_.add_experience(id, to_level_5); + EXPECT_EQ(cb_calls, 2); + EXPECT_EQ(cb_gained, to_level_5); + EXPECT_GT(cb_levels, 0); + + // At max level, no experience is credited and the callback must not fire + auto* p = system_.get_player(id); + p->experience.level = max_level; + system_.add_experience(id, 1000); + EXPECT_EQ(cb_calls, 2); +} + TEST_F(player_system_test, status_effects) { player_create_info info; diff --git a/tests/test_registry.cpp b/tests/test_registry.cpp index ea3a59e..1bebab4 100644 --- a/tests/test_registry.cpp +++ b/tests/test_registry.cpp @@ -543,6 +543,86 @@ TEST_F(registry_test, magic_registry_by_type) EXPECT_EQ(heal_spells.size(), 1); } +TEST_F(registry_test, magic_registry_legacy_damage_types) +{ + auto path = create_test_file("magic.yaml", + "magic:\n" + " - id: 70\n" + " name: Bloody-Shock-Wave\n" + " type: 19\n" + " mana_cost: 120\n" + " effect1: { dice: 8, sides: 8, bonus: 50 }\n" + " int_req: 105\n" + " - id: 60\n" + " name: Energy-Strike\n" + " type: 21\n" + " mana_cost: 65\n" + " range2: 2\n" + " effect1: { dice: 7, sides: 6, bonus: 17 }\n" + " int_req: 67\n" + " - id: 64\n" + " name: Earthworm-Strike\n" + " type: 25\n" + " mana_cost: 80\n" + " range2: 2\n" + " effect1: { dice: 7, sides: 6, bonus: 17 }\n" + " effect2: { dice: 7, sides: 6, bonus: 17 }\n" + " int_req: 97\n" + " - id: 66\n" + " name: Armor-Break\n" + " type: 26\n" + " mana_cost: 90\n" + " effect1: { dice: 7, sides: 6, bonus: 17 }\n" + " int_req: 97\n" + " - id: 91\n" + " name: Blizzard\n" + " type: 27\n" + " mana_cost: 170\n" + " effect1: { dice: 10, sides: 10, bonus: 16 }\n" + " int_req: 195\n" + " - id: 54\n" + " name: Spike-Field\n" + " type: 14\n" + " mana_cost: 56\n" + " effect1: { dice: 2, sides: 8, bonus: 0 }\n" + " int_req: 56\n"); + + magic_registry registry; + registry.initialize(); + + auto result = registry.load_from_file(path); + ASSERT_TRUE(result.is_ok()) << result.error(); + // Type 14 (create_dynamic) is skipped until the dynamic object subsystem exists + EXPECT_EQ(result.value(), 5); + EXPECT_EQ(registry.get(spell_id{54}), nullptr); + + const auto* linear = registry.get(spell_id{70}); + ASSERT_NE(linear, nullptr); + EXPECT_EQ(linear->type, magic_type::damage_linear); + EXPECT_TRUE(linear->is_offensive); + + const auto* nospot = registry.get(spell_id{60}); + ASSERT_NE(nospot, nullptr); + EXPECT_EQ(nospot->type, magic_type::damage_area_no_center); + EXPECT_TRUE(nospot->is_offensive); + + const auto* spdown = registry.get(spell_id{64}); + ASSERT_NE(spdown, nullptr); + EXPECT_EQ(spdown->type, magic_type::damage_area_sp_down); + EXPECT_TRUE(spdown->is_offensive); + EXPECT_EQ(spdown->sp_drain, 7 * 7 / 2 + 17); // 7d6+17 average = 41 + + const auto* armor = registry.get(spell_id{66}); + ASSERT_NE(armor, nullptr); + EXPECT_EQ(armor->type, magic_type::armor_break); + EXPECT_TRUE(armor->is_offensive); + + const auto* ice_line = registry.get(spell_id{91}); + ASSERT_NE(ice_line, nullptr); + EXPECT_EQ(ice_line->type, magic_type::ice_linear); + EXPECT_TRUE(ice_line->is_offensive); +} + TEST_F(registry_test, magic_registry_damage_calculation) { auto path = create_test_file("magic.yaml", diff --git a/tests/test_safe_zone.cpp b/tests/test_safe_zone.cpp index a2345d4..076391a 100644 --- a/tests/test_safe_zone.cpp +++ b/tests/test_safe_zone.cpp @@ -92,6 +92,9 @@ class safe_zone_combat_test : public ::testing::Test return pid; } + // Resolve the real ECS entity handle for a player (player_id values are NOT entity ids) + auto ecs(player_id pid) -> entity::entity { return player_sys_->get_player(pid)->ecs_entity; } + player::player_system* player_sys_{}; world::world_subsystem* world_{}; combat::combat_system* combat_{}; @@ -104,8 +107,8 @@ TEST_F(safe_zone_combat_test, pvp_blocked_when_attacker_in_safe_zone) auto p1 = create_player_at({15, 15}); // In safe zone auto p2 = create_player_at({50, 50}); // Outside safe zone - entity::entity e1(p1.value); - entity::entity e2(p2.value); + auto e1 = ecs(p1); + auto e2 = ecs(p2); EXPECT_FALSE(combat_->can_attack(e1, e2)); } @@ -115,8 +118,8 @@ TEST_F(safe_zone_combat_test, pvp_blocked_when_defender_in_safe_zone) auto p1 = create_player_at({50, 50}); // Outside safe zone auto p2 = create_player_at({15, 15}); // In safe zone - entity::entity e1(p1.value); - entity::entity e2(p2.value); + auto e1 = ecs(p1); + auto e2 = ecs(p2); EXPECT_FALSE(combat_->can_attack(e1, e2)); } @@ -126,8 +129,8 @@ TEST_F(safe_zone_combat_test, pvp_blocked_when_both_in_safe_zone) auto p1 = create_player_at({12, 12}); // In safe zone auto p2 = create_player_at({18, 18}); // In safe zone - entity::entity e1(p1.value); - entity::entity e2(p2.value); + auto e1 = ecs(p1); + auto e2 = ecs(p2); EXPECT_FALSE(combat_->can_attack(e1, e2)); } @@ -137,8 +140,8 @@ TEST_F(safe_zone_combat_test, pvp_allowed_when_neither_in_safe_zone) auto p1 = create_player_at({50, 50}); // Outside safe zone auto p2 = create_player_at({60, 60}); // Outside safe zone - entity::entity e1(p1.value); - entity::entity e2(p2.value); + auto e1 = ecs(p1); + auto e2 = ecs(p2); EXPECT_TRUE(combat_->can_attack(e1, e2)); } @@ -148,7 +151,7 @@ TEST_F(safe_zone_combat_test, player_vs_npc_allowed_in_safe_zone) // PvE should always be allowed even in safe zones auto p1 = create_player_at({15, 15}); // In safe zone - entity::entity player_e(p1.value); + auto player_e = ecs(p1); entity::entity npc_e(9999u); // Not a player ID EXPECT_TRUE(combat_->can_attack(player_e, npc_e)); @@ -159,7 +162,7 @@ TEST_F(safe_zone_combat_test, npc_vs_player_allowed_in_safe_zone) // NPC attacking a player in safe zone should be allowed auto p1 = create_player_at({15, 15}); // In safe zone - entity::entity player_e(p1.value); + auto player_e = ecs(p1); entity::entity npc_e(9999u); // Not a player ID EXPECT_TRUE(combat_->can_attack(npc_e, player_e)); @@ -252,6 +255,9 @@ class safe_zone_magic_test : public ::testing::Test return pid; } + // Resolve the real ECS entity handle for a player (player_id values are NOT entity ids) + auto ecs(player_id pid) -> entity::entity { return player_sys_->get_player(pid)->ecs_entity; } + player::player_system* player_sys_{}; world::world_subsystem* world_{}; magic::magic_system* magic_{}; @@ -264,8 +270,8 @@ TEST_F(safe_zone_magic_test, offensive_spell_blocked_on_player_in_safe_zone) auto caster = create_player_at({50, 50}); // Outside safe zone auto target = create_player_at({15, 15}); // In safe zone - entity::entity caster_e(caster.value); - entity::entity target_e(target.value); + auto caster_e = ecs(caster); + auto target_e = ecs(target); // Learn the spell magic_->learn_spell(caster_e, spell_id(1)); @@ -282,8 +288,8 @@ TEST_F(safe_zone_magic_test, offensive_spell_blocked_when_caster_in_safe_zone) auto caster = create_player_at({15, 15}); // In safe zone auto target = create_player_at({50, 50}); // Outside - entity::entity caster_e(caster.value); - entity::entity target_e(target.value); + auto caster_e = ecs(caster); + auto target_e = ecs(target); magic_->learn_spell(caster_e, spell_id(1)); @@ -299,8 +305,8 @@ TEST_F(safe_zone_magic_test, buff_spell_allowed_in_safe_zone) auto caster = create_player_at({15, 15}); // In safe zone auto target = create_player_at({16, 16}); // Also in safe zone - entity::entity caster_e(caster.value); - entity::entity target_e(target.value); + auto caster_e = ecs(caster); + auto target_e = ecs(target); magic_->learn_spell(caster_e, spell_id(2)); @@ -314,7 +320,7 @@ TEST_F(safe_zone_magic_test, buff_spell_allowed_in_safe_zone) TEST_F(safe_zone_magic_test, offensive_spell_on_npc_allowed_in_safe_zone) { auto caster = create_player_at({15, 15}); // In safe zone - entity::entity caster_e(caster.value); + auto caster_e = ecs(caster); entity::entity npc_e(9999u); // Not a player magic_->learn_spell(caster_e, spell_id(1)); diff --git a/tools/bot/bot.mjs b/tools/bot/bot.mjs new file mode 100644 index 0000000..154af68 --- /dev/null +++ b/tools/bot/bot.mjs @@ -0,0 +1,843 @@ +// HelbreathX headless bot client - Fase 3: caça, combate, auto-pot e loot. +// Fala o protocolo WebSocket JSON documentado em docs/JSON_PROTOCOL.md. +// Uso: node bot.mjs [indice-do-bot] (indice em bots.json, default 0) + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const config = JSON.parse(readFileSync(join(here, "bots.json"), "utf8")); + +const makeLog = (name) => (...a) => console.log(`[${new Date().toISOString()}] [${name}]`, ...a); + +// direções 0-7: N, NE, E, SE, S, SW, W, NW +const DIR_DELTA = [ + [0, -1], [1, -1], [1, 0], [1, 1], [0, 1], [-1, 1], [-1, 0], [-1, -1], +]; +const dirTo = (from, to) => { + const dx = Math.sign(to.x - from.x); + const dy = Math.sign(to.y - from.y); + return DIR_DELTA.findIndex(([x, y]) => x === dx && y === dy); +}; +const chebyshev = (a, b) => Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y)); + +// Parâmetros da IA +const AI = { + tickMs: 200, + moveCooldownMs: 350, + attackCooldownMs: 1000, + wanderCooldownMs: 1200, + potionHpThreshold: 0.4, + fleeHpThreshold: 0.25, + recoverHpThreshold: 0.6, + lootRange: 10, + statusReportMs: 30000, + // economia (Fase 5) + shopRange: 2, + minPotions: 3, + potionBuyCount: 5, + repairThreshold: 0.5, + shopCooldownMs: 30000, + safeShoppingDist: 5, +}; + +class BotClient { + constructor(cfg, serverUrl) { + this.cfg = cfg; + this.serverUrl = serverUrl; + this.log = makeLog(cfg.username); + this.seq = 0; + this.pending = new Map(); + this.state = "connecting"; + + // estado próprio + this.me = { entityId: 0, x: 0, y: 0, dir: 4, hp: 0, maxHp: 1, level: 1, gold: 0, exp: 0, map: "" }; + this.alive = true; + this.combatMode = false; + this.inventory = new Map(); // item_id -> item + this.equipment = {}; // slot name -> item_id (ex.: { weapon: 123 }) + this.groundItems = new Map(); // item_id -> {x, y, item} + this.entities = new Map(); // entity_id -> {type, name, x, y, hp, maxHp, category, dead} + + // controle da IA + this.targetId = 0; + this.fleeing = false; + this.lastMove = 0; + this.lastAttack = 0; + this.lastWander = 0; + this.stuck = 0; + this.kills = 0; + this.busy = false; + this.shopCooldownUntil = 0; + + // party (Fase 4) + this.partyId = 0; + this.partyMembers = []; + this.lastPartyAction = 0; + } + + // ---------- rede ---------- + + connect() { + this.ws = new WebSocket(this.serverUrl); + this.ws.addEventListener("open", () => this.onOpen().catch((e) => this.fail(e))); + this.ws.addEventListener("message", (ev) => this.onMessage(ev)); + this.ws.addEventListener("close", (ev) => { + this.log(`conexao fechada (${ev.code})`); + clearInterval(this.aiTimer); + clearInterval(this.statusTimer); + this.state = "closed"; + }); + this.ws.addEventListener("error", () => this.fail(new Error("erro de WebSocket"))); + } + + fail(err) { + this.log("ERRO:", err.message); + clearInterval(this.aiTimer); + clearInterval(this.statusTimer); + this.state = "closed"; + try { + this.ws.close(1000); + } catch { + // ja fechado + } + } + + request(type, data = {}) { + const seq = ++this.seq; + this.ws.send(JSON.stringify({ type, seq, data })); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(seq); + reject(new Error(`timeout esperando resposta de ${type}`)); + }, 10000); + this.pending.set(seq, { resolve, timeout }); + }); + } + + // Para mensagens cujo resultado chega sem eco confiável de seq (use_item, pickup). + send(type, data = {}) { + this.ws.send(JSON.stringify({ type, seq: ++this.seq, data })); + } + + onMessage(ev) { + let msg; + try { + msg = JSON.parse(ev.data); + } catch { + return; + } + const waiter = this.pending.get(msg.seq); + if (waiter) { + this.pending.delete(msg.seq); + clearTimeout(waiter.timeout); + waiter.resolve(msg); + return; + } + try { + this.onBroadcast(msg); + } catch (e) { + this.log(`erro tratando broadcast ${msg.type}: ${e.message}`); + } + } + + // ---------- broadcasts / estado do mundo ---------- + + onBroadcast(msg) { + const d = msg.data ?? {}; + switch (msg.type) { + case "npc_spawn": + this.entities.set(d.entity_id, { + type: "npc", name: d.name, x: d.x, y: d.y, + hp: d.hp, maxHp: d.max_hp, category: d.category, dead: false, + }); + break; + case "entity_spawn": + this.entities.set(d.entity_id, { + type: d.type ?? "player", name: d.name, x: d.x, y: d.y, + hp: 1, maxHp: 1, category: d.category ?? "player", dead: false, + }); + break; + case "npc_despawn": + case "entity_despawn": + this.entities.delete(d.entity_id); + if (d.entity_id === this.targetId) this.targetId = 0; + break; + case "npc_move": { + const e = this.entities.get(d.entity_id); + if (e) { e.x = d.x; e.y = d.y; } + break; + } + case "player_position_update": { + const e = this.entities.get(d.entity_id); + if (e) { e.x = d.x; e.y = d.y; } + break; + } + case "entity_hp_update": + if (d.entity_id === this.me.entityId) { + this.me.hp = d.hp; + this.me.maxHp = d.hp_max; + } else { + const e = this.entities.get(d.entity_id); + if (e) { e.hp = d.hp; e.maxHp = d.hp_max; } + } + break; + case "entity_death": + if (d.victim_id === this.me.entityId) { + this.alive = false; + this.targetId = 0; + this.log("MORREU - aguardando respawn"); + } else { + if (d.killer_id === this.me.entityId) { + this.kills++; + const e = this.entities.get(d.victim_id); + this.log(`matou ${e?.name ?? d.victim_id} (kill #${this.kills})`); + } + const e = this.entities.get(d.victim_id); + if (e) e.dead = true; + if (d.victim_id === this.targetId) this.targetId = 0; + } + break; + case "player_death_info": + this.alive = false; + this.targetId = 0; + this.log(`morte: perdeu ${d.xp_lost} XP - pedindo respawn em ${d.respawn_map}`); + setTimeout(() => this.respawn().catch((e) => this.log("erro no respawn:", e.message)), 2000); + break; + case "player_teleport": + this.me.map = d.dest_map; + this.me.x = d.dest_x; + this.me.y = d.dest_y; + this.entities.clear(); + this.groundItems.clear(); + for (const e of d.entities ?? []) { + this.entities.set(e.entity_id, { + type: e.type, name: e.name, x: e.x, y: e.y, + hp: e.hp ?? 1, maxHp: e.max_hp ?? 1, category: e.category ?? e.type, dead: false, + }); + } + this.alive = true; + this.combatMode = false; // servidor pode resetar; re-liga no próximo tick + this.log(`teleportado para ${d.dest_map} (${d.dest_x},${d.dest_y})`); + break; + case "stat_update": + if (d.max_hp) this.me.maxHp = d.max_hp; + if (d.hp !== undefined) this.me.hp = d.hp; + if (d.gold !== undefined) this.me.gold = d.gold; + if (d.experience !== undefined) this.me.exp = d.experience; + if (d.level !== undefined && d.level !== this.me.level) { + this.me.level = d.level; + this.log(`LEVEL UP! agora level ${d.level}`); + } + break; + case "inventory_data": + this.inventory.clear(); + for (const entry of d.items ?? []) this.inventory.set(entry.item.item_id, entry.item); + this.equipment = { ...(d.equipment_slots ?? {}) }; + if (d.gold !== undefined) this.me.gold = d.gold; + break; + case "inventory_item_add": + this.inventory.set(d.item.item_id, d.item); + this.log(`loot: ${d.item.name} x${d.item.count}`); + break; + case "inventory_item_update": + this.inventory.set(d.item.item_id, d.item); + break; + case "inventory_item_removed": + this.inventory.delete(d.item_id); + break; + case "inventory_item_delta": { + const it = this.inventory.get(d.item_id); + if (it) { + if (d.count !== undefined) it.count = d.count; + if (d.durability !== undefined) it.durability = d.durability; + } + break; + } + case "inventory_gold_update": + case "gold_update": + this.me.gold = d.gold; + if (d.change > 0) this.log(`+${d.change} de ouro (${d.reason ?? "?"}) — total ${d.gold}`); + break; + case "experience_update": + this.me.exp = d.experience; + if (d.level && d.level !== this.me.level) { + this.me.level = d.level; + this.log(`LEVEL UP! agora level ${d.level}`); + } + break; + case "inventory_weight_update": + break; // silencioso + case "equip_result": + if (d.success && this.pendingEquipId) { + this.equipment.weapon = this.pendingEquipId; + const it = this.inventory.get(this.pendingEquipId); + this.log(`equipou ${it?.name ?? this.pendingEquipId} (dano ${it?.damage_min ?? "?"}-${it?.damage_max ?? "?"})`); + } + this.pendingEquipId = 0; + break; + case "ground_item_spawn": { + // duas variantes no servidor: v1 flat {item_id, item_name, ...} e v2 {item: {...}} + const it = d.item ?? d; + const id = it.item_id; + if (id) { + this.groundItems.set(id, { id, x: d.x, y: d.y, name: it.name ?? it.item_name ?? "?" }); + this.log(`drop no chão: ${it.name ?? it.item_name} em (${d.x},${d.y})`); + } + break; + } + case "ground_item_removed": + this.groundItems.delete(d.item_id); + break; + case "combat_mode_change_broadcast": + if (d.entity_id === this.me.entityId) this.combatMode = d.combat_mode; + break; + case "party_invite_notice": + if (this.cfg.party?.leader && d.inviter_name === this.cfg.party.leader) { + this.log(`convite de party de ${d.inviter_name} - aceitando`); + this.request("party_accept_request", { party_id: d.party_id, accept: true }).catch(() => {}); + } else { + this.log(`convite de party de ${d.inviter_name ?? "?"} ignorado`); + } + break; + case "party_update": + this.partyId = d.party_id; + this.partyMembers = d.members ?? []; + this.log(`party #${d.party_id}: lider ${d.leader_name}, membros: ${this.partyMembers.join(", ")}`); + break; + case "use_item_result": + case "pickup_result": + case "skill_update": + case "skill_progress": + case "environment_update": + case "hunger_update": + case "combat_effect": + case "combat_attack_broadcast": + case "npc_attack": + case "player_action_broadcast": + case "map_teleporters": + case "available_commands": + case "get_characters_response": + break; // silenciosos + default: + this.log(`broadcast: ${msg.type}`); + } + } + + // ---------- login / entrada ---------- + + async onOpen() { + this.log(`conectado a ${this.serverUrl}`); + await this.login(); + const charId = await this.ensureCharacter(); + await this.enterGame(charId); + this.state = "in_game"; + this.aiTimer = setInterval(() => { + this.aiTick().catch((e) => this.log("erro no tick de IA:", e.message)); + }, AI.tickMs); + this.statusTimer = setInterval(() => this.reportStatus(), AI.statusReportMs); + this.log("IA ativa: caçando"); + } + + async login() { + const { username, password } = this.cfg; + let res = await this.request("login_request", { username, password }); + if (!res.data.success) { + this.log(`login falhou (${res.data.error}) - tentando criar a conta...`); + for (let attempt = 1; ; attempt++) { + const created = await this.request("create_account_request", { username, password }); + if (created.data.success) { + this.log(`conta criada (account_id=${created.data.account_id})`); + break; + } + if (created.data.error === "rate_limited" && attempt < 12) { + this.log(`registro rate-limited - nova tentativa em ${attempt * 5}s`); + await new Promise((r) => setTimeout(r, attempt * 5000)); + continue; + } + throw new Error(`create_account: ${created.data.error}`); + } + res = await this.request("login_request", { username, password }); + if (!res.data.success) throw new Error(`login: ${res.data.error}`); + } + this.log("autenticado"); + } + + async ensureCharacter() { + const want = this.cfg.character; + const list = await this.request("get_characters_request"); + if (!list.data.success) throw new Error("get_characters falhou"); + const existing = (list.data.characters ?? []).find((c) => c.name === want.name); + if (existing && existing.nation === want.nation) { + this.log(`personagem '${existing.name}' já existe (id=${existing.id}, level=${existing.level})`); + return existing.id; + } + if (existing) { + // nation divergente (define o mapa de respawn) - recria o personagem + this.log(`personagem '${existing.name}' tem nation ${existing.nation}, recriando com nation ${want.nation}`); + await this.request("delete_character_request", { character_id: existing.id }); + } + const created = await this.request("create_character_request", { ...want }); + if (!created.data.success) throw new Error(`create_character: ${created.data.error}`); + this.log(`personagem '${want.name}' criado (id=${created.data.character_id})`); + return created.data.character_id; + } + + async enterGame(characterId) { + const res = await this.request("enter_game_request", { + character_id: characterId, + force_disconnect: true, + screen_width: 800, + screen_height: 600, + }); + if (!res.data.success) throw new Error(`enter_game: ${res.data.error}`); + const c = res.data.character; + // character.id no enter_game_response é o entity id (ecs_entity.id) - ver auth_handlers.cpp:1263 + this.me = { + entityId: c.id, x: c.pos_x, y: c.pos_y, dir: 4, + hp: c.hp, maxHp: c.hp_max, level: c.level, gold: c.gold, exp: c.experience, map: c.map_name, + }; + for (const e of res.data.world?.entities ?? []) { + this.entities.set(e.entity_id, { + type: e.type, name: e.name, x: e.x, y: e.y, + hp: e.hp ?? 1, maxHp: e.max_hp ?? 1, category: e.category ?? e.type, dead: false, + }); + } + this.log( + `ENTROU NO JOGO: '${c.name}' level ${c.level} em ${c.map_name} (${c.pos_x},${c.pos_y}) ` + + `HP ${c.hp}/${c.hp_max} - ${this.entities.size} entidades visíveis` + ); + } + + async respawn() { + const res = await this.request("respawn_request"); + if (res.data.success) { + this.me.map = res.data.map ?? this.me.map; + if (res.data.x) { this.me.x = res.data.x; this.me.y = res.data.y; } + this.alive = true; + this.log(`respawnou em ${this.me.map} (${this.me.x},${this.me.y})`); + } else { + this.log(`respawn negado (${res.data.error}) - tentando de novo em 3s`); + setTimeout(() => this.respawn().catch(() => {}), 3000); + } + } + + // ---------- IA ---------- + + async aiTick() { + if (this.busy || this.state !== "in_game" || !this.alive) return; + this.busy = true; + try { + this.autoPotion(); + if (!this.combatMode) { + const res = await this.request("combat_mode_change_request"); + this.combatMode = !!res.data.combat_mode; + return; + } + if (this.fleeing || this.me.hp / this.me.maxHp < AI.fleeHpThreshold) { + await this.fleeAndRecover(); + return; + } + await this.equipBestWeapon(); + await this.partyTick(); + if (await this.shoppingTick()) return; + if (await this.lootNearby(2)) return; // drop na porta: pega antes de continuar a luta + const target = this.pickTarget(); + if (target) { + await this.engage(target); + } else if (await this.followLeader()) { + // reagrupando com o lider + } else if (!(await this.lootNearby(AI.lootRange))) { + await this.wander(); + } + } finally { + this.busy = false; + } + } + + autoPotion() { + if (this.me.hp / this.me.maxHp >= AI.potionHpThreshold) return; + const potion = [...this.inventory.values()].find( + (it) => (it.type === "potion" || /potion/i.test(it.name)) && /red|health|heal|hp/i.test(it.name) + ) ?? [...this.inventory.values()].find((it) => it.type === "potion"); + if (potion) { + this.log(`HP baixo (${this.me.hp}/${this.me.maxHp}) - usando ${potion.name}`); + this.send("use_item_request", { item_id: potion.item_id }); + } + } + + pickTarget() { + // persistência de alvo: mantém o atual enquanto vivo e a alcance + const current = this.entities.get(this.targetId); + if (current && !current.dead && chebyshev(this.me, current) <= 12) { + return { id: this.targetId, ...current, dist: chebyshev(this.me, current) }; + } + // prefere o mob mais fraco (menor HP máximo); distância desempata + let best = null; + for (const [id, e] of this.entities) { + if (e.type !== "npc" || e.dead || e.category !== "monster") continue; + const dist = chebyshev(this.me, e); + if (dist > 20) continue; + if (!best || e.maxHp < best.maxHp || (e.maxHp === best.maxHp && dist < best.dist)) { + best = { id, ...e, dist }; + } + } + return best; + } + + async fleeAndRecover() { + if (!this.fleeing) { + this.fleeing = true; + this.targetId = 0; + this.log(`HP crítico (${this.me.hp}/${this.me.maxHp}) - fugindo para recuperar`); + } + if (this.me.hp / this.me.maxHp >= AI.recoverHpThreshold) { + this.fleeing = false; + this.log(`recuperado (${this.me.hp}/${this.me.maxHp}) - voltando à caça`); + return; + } + // afasta-se do mob vivo mais próximo; se nenhum por perto, fica parado regenerando + let nearest = null; + let nearestDist = Infinity; + for (const e of this.entities.values()) { + if (e.type !== "npc" || e.dead || e.category !== "monster") continue; + const dist = chebyshev(this.me, e); + if (dist < nearestDist) { + nearestDist = dist; + nearest = e; + } + } + if (nearest && nearestDist < 8) { + const away = { x: this.me.x * 2 - nearest.x, y: this.me.y * 2 - nearest.y }; + await this.stepTowards(away); + } + } + + async engage(target) { + if (target.id !== this.targetId) { + this.targetId = target.id; + this.log(`alvo: ${target.name} (dist ${target.dist})`); + } + if (target.dist <= 1) { + if (Date.now() - this.lastAttack < AI.attackCooldownMs) return; + this.lastAttack = Date.now(); + const dir = dirTo(this.me, target); + const res = await this.request("player_attack_request", { + x: this.me.x, y: this.me.y, + direction: dir >= 0 ? dir : this.me.dir, + attack_type: "regular", + target_type: "npc", + target_id: target.id, + timestamp: Date.now(), + }); + if (res.data.success && res.data.result?.hit) { + const r = res.data.result; + this.log(`acertou ${target.name}: ${r.damage} de dano${r.critical ? " (CRÍTICO)" : ""} - HP alvo ${r.target_hp}/${r.target_hp_max}`); + } + } else { + await this.stepTowards(target); + } + } + + async lootNearby(maxDist) { + let best = null; + let bestDist = Infinity; + for (const g of this.groundItems.values()) { + const dist = chebyshev(this.me, g); + if (dist < bestDist && dist <= maxDist) { + bestDist = dist; + best = g; + } + } + if (!best) return false; + if (bestDist === 0) { + this.send("pickup_request", { map: this.me.map, x: this.me.x, y: this.me.y }); + this.groundItems.delete(best.id); // otimista; broadcast corrige se falhar + } else { + await this.stepTowards(best); + } + return true; + } + + async stepTowards(dest) { + if (Date.now() - this.lastMove < AI.moveCooldownMs) return; + this.lastMove = Date.now(); + let dir = dirTo(this.me, dest); + if (dir < 0) return; + // anti-stuck: alterna a direção quando bloqueado + if (this.stuck > 0) dir = (dir + (this.stuck % 2 === 0 ? this.stuck : -this.stuck) + 16) % 8; + const res = await this.request("player_move_request", { + x: this.me.x, y: this.me.y, direction: dir, is_running: false, timestamp: Date.now(), + }); + if (res.data.success) { + this.me.x = res.data.x; + this.me.y = res.data.y; + this.me.dir = res.data.direction; + this.stuck = 0; + } else { + this.stuck++; + if (this.stuck > 8) { + this.stuck = 0; + this.targetId = 0; // desiste do alvo atual + } + } + } + + async wander() { + if (Date.now() - this.lastWander < AI.wanderCooldownMs) return; + this.lastWander = Date.now(); + const dir = Math.floor(Math.random() * 8); + const res = await this.request("player_move_request", { + x: this.me.x, y: this.me.y, direction: dir, is_running: false, timestamp: Date.now(), + }); + if (res.data.success) { + this.me.x = res.data.x; + this.me.y = res.data.y; + this.me.dir = res.data.direction; + } + } + + // ---------- party (Fase 4) ---------- + + // Lider: convida os bots configurados que ainda nao estao na party (a cada 10s). + async partyTick() { + const p = this.cfg.party; + if (!p?.invites?.length || Date.now() - this.lastPartyAction < 10000) return; + const missing = p.invites.filter((n) => !this.partyMembers.includes(n)); + if (!missing.length) return; + this.lastPartyAction = Date.now(); + for (const name of missing) { + const res = await this.request("party_invite_request", { target_name: name }); + if (res.data.success) { + this.partyId = res.data.party_id; + this.log(`convidou ${name} para a party #${res.data.party_id}`); + } + } + } + + // Membro: sem alvo, aproxima-se do lider para cacar junto (e dividir XP). + async followLeader() { + const leaderName = this.cfg.party?.leader; + if (!leaderName) return false; + for (const e of this.entities.values()) { + if (e.type === "player" && e.name === leaderName) { + if (chebyshev(this.me, e) > 6) { + await this.stepTowards(e); + return true; + } + return false; + } + } + return false; + } + + // ---------- economia (Fase 5) ---------- + + hpPotions() { + return [...this.inventory.values()].filter((it) => /red.?potion/i.test(it.name)); + } + + weapons() { + return [...this.inventory.values()].filter((it) => it.type === "weapon"); + } + + equippedWeapon() { + return this.equipment.weapon ? this.inventory.get(this.equipment.weapon) : undefined; + } + + findMerchant(nameRe) { + for (const [id, e] of this.entities) { + if (e.type === "npc" && e.category === "merchant" && nameRe.test(e.name ?? "")) { + return { id, ...e, dist: chebyshev(this.me, e) }; + } + } + return null; + } + + nearestMonsterDist() { + let d = Infinity; + for (const e of this.entities.values()) { + if (e.type === "npc" && !e.dead && e.category === "monster") { + d = Math.min(d, chebyshev(this.me, e)); + } + } + return d; + } + + // Decide se há motivo para ir à loja. Retorna { merchantRe, reason }. + shoppingNeed() { + const potions = this.hpPotions().reduce((n, it) => n + (it.count ?? 1), 0); + if (potions < AI.minPotions && this.me.gold >= 100) { + return { merchantRe: /shopkeeper/i, reason: `comprar poções (tem ${potions}, ouro ${this.me.gold})` }; + } + const eq = this.equippedWeapon(); + if (eq && eq.max_durability > 0 && eq.durability / eq.max_durability < AI.repairThreshold && this.me.gold > 0) { + return { merchantRe: /gandlf|william|blacksmith/i, reason: `reparar ${eq.name} (${eq.durability}/${eq.max_durability})` }; + } + if (this.weapons().length === 0 && this.me.gold >= 200) { + return { merchantRe: /gandlf|william|blacksmith/i, reason: `comprar arma (ouro ${this.me.gold})` }; + } + const junk = this.sellableJunk(); + if (junk.length >= 2) { + return { merchantRe: /gandlf|william|blacksmith/i, reason: `vender ${junk.length} itens` }; + } + return null; + } + + // Armas extras (mantém a melhor por damage_max) - nunca vende a equipada. + sellableJunk() { + const equippedId = this.equipment.weapon ?? 0; + const spares = this.weapons().filter((it) => it.item_id !== equippedId); + spares.sort((a, b) => (b.damage_max ?? 0) - (a.damage_max ?? 0)); + // guarda a melhor reserva se não houver arma equipada; vende o resto + return equippedId ? spares : spares.slice(1); + } + + // O servidor responde equip com um ack "equip_result" sem eco de seq — + // enviamos fire-and-forget e confirmamos no broadcast. + async equipBestWeapon() { + if (this.equippedWeapon()) return; + if (Date.now() - (this.lastEquipTry ?? 0) < 5000) return; + const best = this.weapons().sort((a, b) => (b.damage_max ?? 0) - (a.damage_max ?? 0))[0]; + if (!best) return; + this.lastEquipTry = Date.now(); + this.pendingEquipId = best.item_id; + this.send("player_equip_request", { item_id: best.item_id, target_slot: 5 }); + } + + // Retorna true se o tick foi consumido indo à loja / comprando. + async shoppingTick() { + if (Date.now() < this.shopCooldownUntil) return false; + const need = this.shoppingNeed(); + if (!need) return false; + // só vai à loja sem mobs em cima (a não ser que esteja sem poção nenhuma) + const potions = this.hpPotions().length; + if (this.nearestMonsterDist() <= AI.safeShoppingDist && potions > 0) return false; + const merchant = this.findMerchant(need.merchantRe); + if (!merchant) return false; + if (merchant.dist > AI.shopRange) { + await this.stepTowards(merchant); + return true; + } + this.log(`na loja de ${merchant.name}: ${need.reason}`); + await this.doShopping(merchant); + this.shopCooldownUntil = Date.now() + AI.shopCooldownMs; + return true; + } + + async doShopping(merchant) { + // abre a loja para ler o catálogo real (nomes, template_ids e preços) + const res = await this.request("player_interact_request", { + x: this.me.x, y: this.me.y, target_type: "npc", target_id: merchant.id, timestamp: Date.now(), + }); + const result = res.data.result ?? {}; + if (!res.data.success || result.interaction_type !== "shop") { + this.log(`interação com ${merchant.name} não abriu loja (${result.interaction_type ?? res.data.error})`); + return; + } + const catalog = result.interaction_data?.items ?? []; + + // 1) vender armas sobressalentes (ferreiro compra) + for (const junk of this.sellableJunk()) { + const quote = await this.request("shop_sell_request", { + npc_entity_id: merchant.id, item_id: junk.item_id, count: junk.count ?? 1, + }); + if (!quote.data.success) continue; + const sold = await this.request("shop_sell_confirm_request", { + npc_entity_id: merchant.id, item_id: junk.item_id, count: junk.count ?? 1, + }); + if (sold.data.success) this.log(`vendeu ${junk.name} por ${quote.data.offered_price} de ouro`); + } + + // 2) reparar arma equipada + const eq = this.equippedWeapon(); + if (eq && eq.max_durability > 0 && eq.durability / eq.max_durability < AI.repairThreshold) { + const quote = await this.request("shop_repair_request", { + npc_entity_id: merchant.id, item_id: eq.item_id, + }); + if (quote.data.success && quote.data.repair_cost <= this.me.gold) { + const fixed = await this.request("shop_repair_confirm_request", { + npc_entity_id: merchant.id, item_id: eq.item_id, + }); + if (fixed.data.success) this.log(`reparou ${eq.name} por ${quote.data.repair_cost} de ouro`); + } + } + + // 3) comprar arma se não tem nenhuma (a mais barata que conseguir pagar) + if (this.weapons().length === 0) { + const affordable = catalog + .filter((c) => (c.category ?? "").includes("weapon") || /sword|dagger|axe|gauche/i.test(c.name)) + .filter((c) => c.price <= this.me.gold) + .sort((a, b) => a.price - b.price)[0]; + if (affordable) { + const buy = await this.request("shop_buy_request", { + npc_entity_id: merchant.id, item_template_id: affordable.template_id, count: 1, + }); + if (buy.data.success) { + this.me.gold = buy.data.gold_remaining; + this.log(`comprou ${buy.data.item_name} por ${buy.data.price_paid} de ouro`); + } + } + } + + // 4) comprar poções de HP + const potionEntry = catalog.find((c) => /red.?potion/i.test(c.name)); + if (potionEntry) { + const have = this.hpPotions().reduce((n, it) => n + (it.count ?? 1), 0); + const want = Math.max(0, AI.potionBuyCount - have); + const canAfford = potionEntry.price > 0 ? Math.floor(this.me.gold / potionEntry.price) : want; + const count = Math.min(want, canAfford); + if (count > 0) { + const buy = await this.request("shop_buy_request", { + npc_entity_id: merchant.id, item_template_id: potionEntry.template_id, count, + }); + if (buy.data.success) { + this.me.gold = buy.data.gold_remaining; + this.log(`comprou ${buy.data.count}x ${buy.data.item_name} por ${buy.data.price_paid} de ouro`); + } + } + } + } + + reportStatus() { + const mobs = [...this.entities.values()].filter((e) => e.type === "npc" && !e.dead && e.category === "monster").length; + const eq = this.equippedWeapon(); + const potions = this.hpPotions().reduce((n, it) => n + (it.count ?? 1), 0); + this.log( + `status: level ${this.me.level}, ${this.kills} kills, ` + + `HP ${this.me.hp}/${this.me.maxHp}, ouro ${this.me.gold}, pots ${potions}, ` + + `arma ${eq ? `${eq.name} ${eq.durability}/${eq.max_durability}` : "nenhuma"}, ` + + `pos (${this.me.x},${this.me.y}), ${mobs} mobs, ${this.inventory.size} itens` + ); + } + + async shutdown() { + clearInterval(this.aiTimer); + clearInterval(this.statusTimer); + try { + if (this.state === "in_game") await this.request("logout_request"); + } catch { + // servidor pode já ter fechado - sair mesmo assim + } + this.ws.close(1000); + } +} + +// Uso: node bot.mjs -> roda o bot 0 +// node bot.mjs 2 -> roda o bot de indice 2 +// node bot.mjs all -> roda todos os bots de bots.json no mesmo processo +const arg = process.argv[2] ?? "0"; +const selected = arg === "all" ? config.bots : [config.bots[Number(arg)]].filter(Boolean); +if (selected.length === 0) { + console.error("bot nao encontrado em bots.json"); + process.exit(1); +} + +const bots = selected.map((cfg) => new BotClient(cfg, config.server)); +process.on("SIGINT", () => { + console.log("encerrando bots..."); + Promise.allSettled(bots.map((b) => b.shutdown())).finally(() => process.exit(0)); +}); +// conexoes escalonadas para nao estourar rate limit de login por IP +bots.forEach((b, i) => setTimeout(() => b.connect(), i * 1500)); diff --git a/tools/bot/bots.json b/tools/bot/bots.json new file mode 100644 index 0000000..bc947af --- /dev/null +++ b/tools/bot/bots.json @@ -0,0 +1,1155 @@ +{ + "server": "ws://127.0.0.1:2848", + "bots": [ + { + "username": "bot01", + "password": "hbx_bot_dev_2026", + "party": { + "invites": [ + "Bot02", + "Bot03", + "Bot04", + "Bot05" + ] + }, + "character": { + "name": "Bot01", + "class_type": 0, + "nation": 1, + "gender": 2, + "hair_style": 1, + "hair_color": 1, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot02", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot01" + }, + "character": { + "name": "Bot02", + "class_type": 0, + "nation": 1, + "gender": 1, + "hair_style": 2, + "hair_color": 2, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot03", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot01" + }, + "character": { + "name": "Bot03", + "class_type": 0, + "nation": 1, + "gender": 2, + "hair_style": 3, + "hair_color": 3, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot04", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot01" + }, + "character": { + "name": "Bot04", + "class_type": 0, + "nation": 1, + "gender": 1, + "hair_style": 4, + "hair_color": 4, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot05", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot01" + }, + "character": { + "name": "Bot05", + "class_type": 0, + "nation": 1, + "gender": 2, + "hair_style": 5, + "hair_color": 5, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot06", + "password": "hbx_bot_dev_2026", + "party": { + "invites": [ + "Bot07", + "Bot08", + "Bot09", + "Bot10" + ] + }, + "character": { + "name": "Bot06", + "class_type": 0, + "nation": 1, + "gender": 1, + "hair_style": 6, + "hair_color": 6, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot07", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot06" + }, + "character": { + "name": "Bot07", + "class_type": 0, + "nation": 1, + "gender": 2, + "hair_style": 7, + "hair_color": 7, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot08", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot06" + }, + "character": { + "name": "Bot08", + "class_type": 0, + "nation": 1, + "gender": 1, + "hair_style": 0, + "hair_color": 8, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot09", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot06" + }, + "character": { + "name": "Bot09", + "class_type": 0, + "nation": 1, + "gender": 2, + "hair_style": 1, + "hair_color": 9, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot10", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot06" + }, + "character": { + "name": "Bot10", + "class_type": 0, + "nation": 1, + "gender": 1, + "hair_style": 2, + "hair_color": 10, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot11", + "password": "hbx_bot_dev_2026", + "party": { + "invites": [ + "Bot12", + "Bot13", + "Bot14", + "Bot15" + ] + }, + "character": { + "name": "Bot11", + "class_type": 0, + "nation": 1, + "gender": 2, + "hair_style": 3, + "hair_color": 11, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot12", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot11" + }, + "character": { + "name": "Bot12", + "class_type": 0, + "nation": 1, + "gender": 1, + "hair_style": 4, + "hair_color": 12, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot13", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot11" + }, + "character": { + "name": "Bot13", + "class_type": 0, + "nation": 1, + "gender": 2, + "hair_style": 5, + "hair_color": 13, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot14", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot11" + }, + "character": { + "name": "Bot14", + "class_type": 0, + "nation": 1, + "gender": 1, + "hair_style": 6, + "hair_color": 14, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot15", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot11" + }, + "character": { + "name": "Bot15", + "class_type": 0, + "nation": 1, + "gender": 2, + "hair_style": 7, + "hair_color": 15, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot16", + "password": "hbx_bot_dev_2026", + "party": { + "invites": [ + "Bot17", + "Bot18", + "Bot19", + "Bot20" + ] + }, + "character": { + "name": "Bot16", + "class_type": 0, + "nation": 1, + "gender": 1, + "hair_style": 0, + "hair_color": 0, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot17", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot16" + }, + "character": { + "name": "Bot17", + "class_type": 0, + "nation": 1, + "gender": 2, + "hair_style": 1, + "hair_color": 1, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot18", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot16" + }, + "character": { + "name": "Bot18", + "class_type": 0, + "nation": 1, + "gender": 1, + "hair_style": 2, + "hair_color": 2, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot19", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot16" + }, + "character": { + "name": "Bot19", + "class_type": 0, + "nation": 1, + "gender": 2, + "hair_style": 3, + "hair_color": 3, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot20", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot16" + }, + "character": { + "name": "Bot20", + "class_type": 0, + "nation": 1, + "gender": 1, + "hair_style": 4, + "hair_color": 4, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot21", + "password": "hbx_bot_dev_2026", + "party": { + "invites": [ + "Bot22", + "Bot23", + "Bot24", + "Bot25" + ] + }, + "character": { + "name": "Bot21", + "class_type": 0, + "nation": 1, + "gender": 2, + "hair_style": 5, + "hair_color": 5, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot22", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot21" + }, + "character": { + "name": "Bot22", + "class_type": 0, + "nation": 1, + "gender": 1, + "hair_style": 6, + "hair_color": 6, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot23", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot21" + }, + "character": { + "name": "Bot23", + "class_type": 0, + "nation": 1, + "gender": 2, + "hair_style": 7, + "hair_color": 7, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot24", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot21" + }, + "character": { + "name": "Bot24", + "class_type": 0, + "nation": 1, + "gender": 1, + "hair_style": 0, + "hair_color": 8, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot25", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot21" + }, + "character": { + "name": "Bot25", + "class_type": 0, + "nation": 1, + "gender": 2, + "hair_style": 1, + "hair_color": 9, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot26", + "password": "hbx_bot_dev_2026", + "party": { + "invites": [ + "Bot27", + "Bot28", + "Bot29", + "Bot30" + ] + }, + "character": { + "name": "Bot26", + "class_type": 0, + "nation": 2, + "gender": 1, + "hair_style": 2, + "hair_color": 10, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot27", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot26" + }, + "character": { + "name": "Bot27", + "class_type": 0, + "nation": 2, + "gender": 2, + "hair_style": 3, + "hair_color": 11, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot28", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot26" + }, + "character": { + "name": "Bot28", + "class_type": 0, + "nation": 2, + "gender": 1, + "hair_style": 4, + "hair_color": 12, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot29", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot26" + }, + "character": { + "name": "Bot29", + "class_type": 0, + "nation": 2, + "gender": 2, + "hair_style": 5, + "hair_color": 13, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot30", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot26" + }, + "character": { + "name": "Bot30", + "class_type": 0, + "nation": 2, + "gender": 1, + "hair_style": 6, + "hair_color": 14, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot31", + "password": "hbx_bot_dev_2026", + "party": { + "invites": [ + "Bot32", + "Bot33", + "Bot34", + "Bot35" + ] + }, + "character": { + "name": "Bot31", + "class_type": 0, + "nation": 2, + "gender": 2, + "hair_style": 7, + "hair_color": 15, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot32", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot31" + }, + "character": { + "name": "Bot32", + "class_type": 0, + "nation": 2, + "gender": 1, + "hair_style": 0, + "hair_color": 0, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot33", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot31" + }, + "character": { + "name": "Bot33", + "class_type": 0, + "nation": 2, + "gender": 2, + "hair_style": 1, + "hair_color": 1, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot34", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot31" + }, + "character": { + "name": "Bot34", + "class_type": 0, + "nation": 2, + "gender": 1, + "hair_style": 2, + "hair_color": 2, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot35", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot31" + }, + "character": { + "name": "Bot35", + "class_type": 0, + "nation": 2, + "gender": 2, + "hair_style": 3, + "hair_color": 3, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot36", + "password": "hbx_bot_dev_2026", + "party": { + "invites": [ + "Bot37", + "Bot38", + "Bot39", + "Bot40" + ] + }, + "character": { + "name": "Bot36", + "class_type": 0, + "nation": 2, + "gender": 1, + "hair_style": 4, + "hair_color": 4, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot37", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot36" + }, + "character": { + "name": "Bot37", + "class_type": 0, + "nation": 2, + "gender": 2, + "hair_style": 5, + "hair_color": 5, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot38", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot36" + }, + "character": { + "name": "Bot38", + "class_type": 0, + "nation": 2, + "gender": 1, + "hair_style": 6, + "hair_color": 6, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot39", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot36" + }, + "character": { + "name": "Bot39", + "class_type": 0, + "nation": 2, + "gender": 2, + "hair_style": 7, + "hair_color": 7, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot40", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot36" + }, + "character": { + "name": "Bot40", + "class_type": 0, + "nation": 2, + "gender": 1, + "hair_style": 0, + "hair_color": 8, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot41", + "password": "hbx_bot_dev_2026", + "party": { + "invites": [ + "Bot42", + "Bot43", + "Bot44", + "Bot45" + ] + }, + "character": { + "name": "Bot41", + "class_type": 0, + "nation": 2, + "gender": 2, + "hair_style": 1, + "hair_color": 9, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot42", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot41" + }, + "character": { + "name": "Bot42", + "class_type": 0, + "nation": 2, + "gender": 1, + "hair_style": 2, + "hair_color": 10, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot43", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot41" + }, + "character": { + "name": "Bot43", + "class_type": 0, + "nation": 2, + "gender": 2, + "hair_style": 3, + "hair_color": 11, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot44", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot41" + }, + "character": { + "name": "Bot44", + "class_type": 0, + "nation": 2, + "gender": 1, + "hair_style": 4, + "hair_color": 12, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot45", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot41" + }, + "character": { + "name": "Bot45", + "class_type": 0, + "nation": 2, + "gender": 2, + "hair_style": 5, + "hair_color": 13, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot46", + "password": "hbx_bot_dev_2026", + "party": { + "invites": [ + "Bot47", + "Bot48", + "Bot49", + "Bot50" + ] + }, + "character": { + "name": "Bot46", + "class_type": 0, + "nation": 2, + "gender": 1, + "hair_style": 6, + "hair_color": 14, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot47", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot46" + }, + "character": { + "name": "Bot47", + "class_type": 0, + "nation": 2, + "gender": 2, + "hair_style": 7, + "hair_color": 15, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot48", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot46" + }, + "character": { + "name": "Bot48", + "class_type": 0, + "nation": 2, + "gender": 1, + "hair_style": 0, + "hair_color": 0, + "skin_color": 0, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot49", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot46" + }, + "character": { + "name": "Bot49", + "class_type": 0, + "nation": 2, + "gender": 2, + "hair_style": 1, + "hair_color": 1, + "skin_color": 1, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + }, + { + "username": "bot50", + "password": "hbx_bot_dev_2026", + "party": { + "leader": "Bot46" + }, + "character": { + "name": "Bot50", + "class_type": 0, + "nation": 2, + "gender": 1, + "hair_style": 2, + "hair_color": 2, + "skin_color": 2, + "strength": 14, + "dexterity": 12, + "vitality": 14, + "intelligence": 10, + "magic": 10, + "charisma": 10 + } + } + ] +} diff --git a/tools/bot/gen-bots.mjs b/tools/bot/gen-bots.mjs new file mode 100644 index 0000000..7970c21 --- /dev/null +++ b/tools/bot/gen-bots.mjs @@ -0,0 +1,51 @@ +// Gera bots.json com N bots (default 10), organizados em parties de ate 5. +// Metade dos bots vai para Aresden (nation 1), metade para Elvine (nation 2) — +// personagens nascem e respawnam na cidade da sua nacao. +// Uso: node gen-bots.mjs [quantidade] +import { writeFileSync } from "node:fs"; + +const n = Number(process.argv[2] ?? 10); +const partySize = 5; +const half = Math.ceil(n / 2); +const pad = (i) => `Bot${String(i).padStart(2, "0")}`; + +const bots = []; +for (let i = 1; i <= n; i++) { + const name = pad(i); + const nation = i <= half ? 1 : 2; // 1 = Aresden, 2 = Elvine + const partyIndex = Math.floor((i - 1) / partySize); + const leaderIdx = partyIndex * partySize + 1; + // parties nao cruzam a fronteira de nacao + const sameNation = (a) => (a <= half) === (i <= half); + const isLeader = i === leaderIdx || !sameNation(leaderIdx); + const realLeader = isLeader ? i : leaderIdx; + const invites = []; + if (isLeader) { + for (let j = i + 1; j <= Math.min(partyIndex * partySize + partySize, n); j++) { + if ((j <= half) === (i <= half)) invites.push(pad(j)); + } + } + bots.push({ + username: name.toLowerCase(), + password: "hbx_bot_dev_2026", + party: isLeader ? { invites } : { leader: pad(realLeader) }, + character: { + name, + class_type: 0, + nation, + gender: 1 + (i % 2), + hair_style: i % 8, + hair_color: i % 16, + skin_color: i % 3, + strength: 14, + dexterity: 12, + vitality: 14, + intelligence: 10, + magic: 10, + charisma: 10, + }, + }); +} + +writeFileSync(new URL("./bots.json", import.meta.url), JSON.stringify({ server: "ws://127.0.0.1:2848", bots }, null, 2) + "\n"); +console.log(`bots.json gerado com ${n} bots (parties de ate ${partySize})`); diff --git a/tools/bot/scan-map.mjs b/tools/bot/scan-map.mjs new file mode 100644 index 0000000..dc0af48 --- /dev/null +++ b/tools/bot/scan-map.mjs @@ -0,0 +1,64 @@ +// Analisa um .amd e sugere areas andaveis para spawners/initial points. +// Uso: node scan-map.mjs [janela=40] +import { readFileSync } from "node:fs"; + +const path = process.argv[2]; +if (!path) { + console.error("uso: node scan-map.mjs [janela]"); + process.exit(1); +} +const win = Number(process.argv[3] ?? 40); + +const buf = readFileSync(path); +const header = buf.subarray(0, 256).toString("latin1").replaceAll("\0", " "); +const getVal = (key) => { + const m = header.match(new RegExp(`${key}[ =\t]+(\\d+)`)); + return m ? Number(m[1]) : 0; +}; +const w = getVal("MAPSIZEX"); +const h = getVal("MAPSIZEY"); +const ts = getVal("TILESIZE"); +if (!w || !h || !ts) { + console.error("header invalido:", header.slice(0, 120)); + process.exit(1); +} +console.log(`mapa ${w}x${h}, tile_size=${ts}`); + +// walk[y][x] = 1 se andavel (leitura na mesma ordem do servidor: y externo, x interno) +const walk = new Uint8Array(w * h); +let off = 256; +let total = 0; +for (let y = 0; y < h; y++) { + for (let x = 0; x < w; x++) { + const flags = buf[off + 8]; + const type = buf.readInt16LE(off); + const ok = (flags & 0x80) === 0 && type !== 19; // nao bloqueado e nao agua + if (ok) { + walk[y * w + x] = 1; + total++; + } + off += ts; + } +} +console.log(`tiles andaveis: ${total} de ${w * h} (${((100 * total) / (w * h)).toFixed(1)}%)`); + +// melhor janela win x win por soma de andaveis (amostragem de passo win/2) +const step = Math.max(1, Math.floor(win / 2)); +const best = []; +for (let y = 0; y + win <= h; y += step) { + for (let x = 0; x + win <= w; x += step) { + let s = 0; + for (let dy = 0; dy < win; dy++) { + for (let dx = 0; dx < win; dx++) s += walk[(y + dy) * w + (x + dx)]; + } + best.push({ x, y, ratio: s / (win * win) }); + } +} +best.sort((a, b) => b.ratio - a.ratio); +console.log(`top 5 janelas ${win}x${win} (x1,y1 -> x2,y2, % andavel):`); +for (const b of best.slice(0, 5)) { + const cx = b.x + Math.floor(win / 2); + const cy = b.y + Math.floor(win / 2); + const centerOk = walk[cy * w + cx] ? "centro andavel" : "CENTRO BLOQUEADO"; + console.log(` ${b.x},${b.y} -> ${b.x + win - 1},${b.y + win - 1} ${(100 * b.ratio).toFixed(1)}% (centro ${cx},${cy}: ${centerOk})`); +}