From 60ac12e71184c7aca9f26d737c5a8225a4bd2e04 Mon Sep 17 00:00:00 2001 From: "pozdn.r.a" Date: Sat, 9 May 2026 15:38:51 +0700 Subject: [PATCH 1/7] add recruit perk combat hooks --- ...nerModRecruitSkillTreeCombatGameTests.java | 115 ++++++++++++++++++ .../bannermod/compat/CrossbowWeapon.java | 8 +- .../military/AbstractRecruitEntity.java | 1 + .../military/RecruitRangedCombatService.java | 9 +- .../military/perks/PerkEffectService.java | 115 ++++++++++++++++++ .../entity/military/perks/PerkRegistry.java | 12 ++ .../assets/bannermod/lang/en_us.json | 12 ++ .../assets/bannermod/lang/ru_ru.json | 12 ++ .../perks/universal_iron_skin_i.json | 12 ++ .../perks/universal_marching_drill_i.json | 12 ++ .../perks/universal_quick_hands_i.json | 12 ++ .../perks/universal_steady_aim_i.json | 12 ++ .../perks/universal_strong_draw_i.json | 12 ++ .../perks/universal_weapon_training_i.json | 12 ++ .../perks/PerkCatalogContractTest.java | 61 ++++++++++ 15 files changed, 413 insertions(+), 4 deletions(-) create mode 100644 src/gametest/java/com/talhanation/bannermod/entity/military/perks/BannerModRecruitSkillTreeCombatGameTests.java create mode 100644 src/main/java/com/talhanation/bannermod/entity/military/perks/PerkEffectService.java create mode 100644 src/main/resources/data/bannermod/perks/universal_iron_skin_i.json create mode 100644 src/main/resources/data/bannermod/perks/universal_marching_drill_i.json create mode 100644 src/main/resources/data/bannermod/perks/universal_quick_hands_i.json create mode 100644 src/main/resources/data/bannermod/perks/universal_steady_aim_i.json create mode 100644 src/main/resources/data/bannermod/perks/universal_strong_draw_i.json create mode 100644 src/main/resources/data/bannermod/perks/universal_weapon_training_i.json create mode 100644 src/test/java/com/talhanation/bannermod/entity/military/perks/PerkCatalogContractTest.java diff --git a/src/gametest/java/com/talhanation/bannermod/entity/military/perks/BannerModRecruitSkillTreeCombatGameTests.java b/src/gametest/java/com/talhanation/bannermod/entity/military/perks/BannerModRecruitSkillTreeCombatGameTests.java new file mode 100644 index 00000000..81713f87 --- /dev/null +++ b/src/gametest/java/com/talhanation/bannermod/entity/military/perks/BannerModRecruitSkillTreeCombatGameTests.java @@ -0,0 +1,115 @@ +package com.talhanation.bannermod.entity.military.perks; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.compat.CrossbowWeapon; +import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; +import com.talhanation.bannermod.entity.military.BowmanEntity; +import com.talhanation.bannermod.entity.military.CrossBowmanEntity; +import com.talhanation.bannermod.entity.military.HorsemanEntity; +import com.talhanation.bannermod.entity.military.RecruitShieldmanEntity; +import com.talhanation.bannermod.gametest.support.RecruitsBattleGameTestSupport; +import com.talhanation.bannermod.registry.military.ModEntityTypes; +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.world.entity.ai.attributes.Attribute; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.projectile.AbstractArrow; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +import java.util.UUID; + +@GameTestHolder(BannerModMain.MOD_ID) +public class BannerModRecruitSkillTreeCombatGameTests { + private static final UUID OWNER_UUID = UUID.fromString("00000000-0000-0000-0000-000000002d01"); + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void trackedRecruitPerksModifyCombatStats(GameTestHelper helper) { + AbstractRecruitEntity swordsman = RecruitsBattleGameTestSupport.spawnConfiguredRecruit( + helper, ModEntityTypes.RECRUIT.get(), new BlockPos(1, 2, 1), "perk-swordsman", OWNER_UUID); + double swordsmanDamage = value(swordsman, Attributes.ATTACK_DAMAGE); + double swordsmanHealth = value(swordsman, Attributes.MAX_HEALTH); + unlock(swordsman, "swordsman/iron_grip_i"); + unlock(swordsman, "universal/toughness_i"); + PerkEffectService.applyRecruitAttributeBonuses(swordsman); + helper.assertTrue(value(swordsman, Attributes.ATTACK_DAMAGE) > swordsmanDamage, + "Expected swordsman attack-damage perk to raise melee damage"); + helper.assertTrue(value(swordsman, Attributes.MAX_HEALTH) > swordsmanHealth, + "Expected universal toughness perk to raise max health"); + + RecruitShieldmanEntity pikeman = RecruitsBattleGameTestSupport.spawnConfiguredRecruit( + helper, ModEntityTypes.RECRUIT_SHIELDMAN.get(), new BlockPos(2, 2, 1), "perk-pikeman", OWNER_UUID); + unlock(pikeman, "pikeman/braced_stance_i"); + assertAttributeIncreases(helper, pikeman, Attributes.KNOCKBACK_RESISTANCE, + "Expected pikeman perk to raise knockback resistance"); + + HorsemanEntity cavalry = RecruitsBattleGameTestSupport.spawnConfiguredRecruit( + helper, ModEntityTypes.HORSEMAN.get(), new BlockPos(3, 2, 1), "perk-cavalry", OWNER_UUID); + unlock(cavalry, "cavalry/swift_charge_i"); + assertAttributeIncreases(helper, cavalry, Attributes.MOVEMENT_SPEED, + "Expected cavalry perk to raise movement speed"); + + BowmanEntity bowman = RecruitsBattleGameTestSupport.spawnConfiguredRecruit( + helper, ModEntityTypes.BOWMAN.get(), new BlockPos(4, 2, 1), "perk-bowman", OWNER_UUID); + float bowBaseline = PerkEffectService.rangedInaccuracyFor(bowman, 4.0F); + unlock(bowman, "bowman/steady_aim_i"); + float bowUnlocked = PerkEffectService.rangedInaccuracyFor(bowman, 4.0F); + helper.assertTrue(bowUnlocked < bowBaseline, + "Expected bowman accuracy perk to reduce ranged inaccuracy"); + + CrossBowmanEntity crossbowman = RecruitsBattleGameTestSupport.spawnConfiguredRecruit( + helper, ModEntityTypes.CROSSBOWMAN.get(), new BlockPos(5, 2, 1), "perk-crossbowman", OWNER_UUID); + CrossbowWeapon crossbow = new CrossbowWeapon(); + AbstractArrow baselineBolt = crossbow.getProjectileArrow(crossbowman); + crossbow.shootArrow(crossbowman, baselineBolt, crossbowman.getX() + 8.0D, crossbowman.getY() + 1.5D, crossbowman.getZ()); + unlock(crossbowman, "crossbowman/heavy_bolts_i"); + AbstractArrow perkBolt = crossbow.getProjectileArrow(crossbowman); + crossbow.shootArrow(crossbowman, perkBolt, crossbowman.getX() + 8.0D, crossbowman.getY() + 1.5D, crossbowman.getZ()); + helper.assertTrue(perkBolt.getDeltaMovement().lengthSqr() > baselineBolt.getDeltaMovement().lengthSqr(), + "Expected crossbowman velocity perk to produce a faster captured projectile"); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void freshRecruitKeepsBaselineStatsAndNoUnlockedPerks(GameTestHelper helper) { + AbstractRecruitEntity recruit = RecruitsBattleGameTestSupport.spawnConfiguredRecruit( + helper, ModEntityTypes.RECRUIT.get(), new BlockPos(1, 2, 2), "perk-baseline", OWNER_UUID); + + double health = value(recruit, Attributes.MAX_HEALTH); + double damage = value(recruit, Attributes.ATTACK_DAMAGE); + double movement = value(recruit, Attributes.MOVEMENT_SPEED); + PerkEffectService.applyRecruitAttributeBonuses(recruit); + + helper.assertTrue(recruit.getPerkProgress().getOwnedPerks().isEmpty(), + "Expected freshly spawned recruit to have zero unlocked perks"); + helper.assertTrue(value(recruit, Attributes.MAX_HEALTH) == health, + "Expected no-perk max health to keep the baseline value"); + helper.assertTrue(value(recruit, Attributes.ATTACK_DAMAGE) == damage, + "Expected no-perk attack damage to keep the baseline value"); + helper.assertTrue(value(recruit, Attributes.MOVEMENT_SPEED) == movement, + "Expected no-perk movement speed to keep the baseline value"); + helper.succeed(); + } + + private static void unlock(AbstractRecruitEntity recruit, String perkId) { + recruit.getPerkProgress().grantPoints(1); + PerkNode node = PerkRegistry.get(perkId).orElseThrow(); + if (recruit.getPerkProgress().unlock(node) != PerkProgress.UnlockResult.OK) { + throw new IllegalStateException("Could not unlock test perk: " + perkId); + } + } + + private static void assertAttributeIncreases(GameTestHelper helper, AbstractRecruitEntity recruit, + net.minecraft.core.Holder attribute, String message) { + double baseline = value(recruit, attribute); + PerkEffectService.applyRecruitAttributeBonuses(recruit); + helper.assertTrue(value(recruit, attribute) > baseline, message); + } + + private static double value(AbstractRecruitEntity recruit, net.minecraft.core.Holder attribute) { + return recruit.getAttributeValue(attribute); + } +} diff --git a/src/main/java/com/talhanation/bannermod/compat/CrossbowWeapon.java b/src/main/java/com/talhanation/bannermod/compat/CrossbowWeapon.java index 4a508711..bbecbe20 100644 --- a/src/main/java/com/talhanation/bannermod/compat/CrossbowWeapon.java +++ b/src/main/java/com/talhanation/bannermod/compat/CrossbowWeapon.java @@ -3,6 +3,7 @@ import com.talhanation.bannermod.config.RecruitsServerConfig; import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; import com.talhanation.bannermod.entity.military.IRangedRecruit; +import com.talhanation.bannermod.entity.military.perks.PerkEffectService; import net.minecraft.core.registries.Registries; import net.minecraft.resources.ResourceKey; import net.minecraft.sounds.SoundEvent; @@ -91,10 +92,15 @@ public AbstractArrow shootArrow(LivingEntity shooter, AbstractArrow projectile, float force = 2.25F; double morale = 100; - if(shooter instanceof AbstractRecruitEntity recruit){ + AbstractRecruitEntity recruit = shooter instanceof AbstractRecruitEntity typedRecruit ? typedRecruit : null; + if(recruit != null){ morale = recruit.getMorale(); + force = PerkEffectService.rangedVelocityFor(recruit, force); } float accuracy = Math.max(6 - (float) (0.1F * morale), 0); + if (recruit != null) { + accuracy = PerkEffectService.rangedInaccuracyFor(recruit, accuracy); + } double angle = IRangedRecruit.getAngleDistanceModifier(distance, 85, 4) + IRangedRecruit.getCrossbowAngleHeightModifier(distance, heightDiff) / 100; diff --git a/src/main/java/com/talhanation/bannermod/entity/military/AbstractRecruitEntity.java b/src/main/java/com/talhanation/bannermod/entity/military/AbstractRecruitEntity.java index c1358f04..018350d1 100644 --- a/src/main/java/com/talhanation/bannermod/entity/military/AbstractRecruitEntity.java +++ b/src/main/java/com/talhanation/bannermod/entity/military/AbstractRecruitEntity.java @@ -295,6 +295,7 @@ public void aiStep(){ if (this.getCommandSenderWorld().isClientSide()) return; + com.talhanation.bannermod.entity.military.perks.PerkEffectService.applyRecruitAttributeBonuses(this); RecruitRuntimeLoop.aiStep(this); this.getCitizenRoleController().onServerAiStep(this); diff --git a/src/main/java/com/talhanation/bannermod/entity/military/RecruitRangedCombatService.java b/src/main/java/com/talhanation/bannermod/entity/military/RecruitRangedCombatService.java index ef059efc..161d9b8d 100644 --- a/src/main/java/com/talhanation/bannermod/entity/military/RecruitRangedCombatService.java +++ b/src/main/java/com/talhanation/bannermod/entity/military/RecruitRangedCombatService.java @@ -1,6 +1,7 @@ package com.talhanation.bannermod.entity.military; import com.talhanation.bannermod.config.RecruitsServerConfig; +import com.talhanation.bannermod.entity.military.perks.PerkEffectService; import com.talhanation.bannermod.util.AttackUtil; import net.minecraft.core.registries.Registries; import net.minecraft.resources.ResourceKey; @@ -65,7 +66,8 @@ public static void fireBowAtTarget(BowmanEntity shooter, LivingEntity target, fl double angle = IRangedRecruit.getAngleDistanceModifier(distance, 47, 4) + IRangedRecruit.getAngleHeightModifier(distance, heightDiff, 1.00D) / 100; float force = 1.90F + IRangedRecruit.getForceDistanceModifier(distance, 1.90F); double morale = shooter.getMorale(); - float accuracy = Math.max(6 - (float) (0.1F * morale), 0); + force = PerkEffectService.rangedVelocityFor(shooter, force); + float accuracy = PerkEffectService.rangedInaccuracyFor(shooter, Math.max(6 - (float) (0.1F * morale), 0)); arrow.shoot(d0, d1 + d3 * angle, d2, force, accuracy); finishBowShot(shooter, arrow, true); @@ -84,8 +86,9 @@ public static void fireBowAtPosition(BowmanEntity shooter, double x, double y, d double d2 = z - shooter.getZ(); double d3 = Mth.sqrt((float) (d0 * d0 + d2 * d2)); double morale = shooter.getMorale(); - float accuracy = 3F + Math.max(6 - (float) (0.1F * morale), 0); - arrow.shoot(d0, d1 + d3 + angle, d2, force + 1.95F, accuracy); + float adjustedForce = PerkEffectService.rangedVelocityFor(shooter, force + 1.95F); + float accuracy = PerkEffectService.rangedInaccuracyFor(shooter, 3F + Math.max(6 - (float) (0.1F * morale), 0)); + arrow.shoot(d0, d1 + d3 + angle, d2, adjustedForce, accuracy); finishBowShot(shooter, arrow, false); } diff --git a/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkEffectService.java b/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkEffectService.java new file mode 100644 index 00000000..ff099ebe --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkEffectService.java @@ -0,0 +1,115 @@ +package com.talhanation.bannermod.entity.military.perks; + +import com.talhanation.bannermod.ai.military.WeaponReach; +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; +import com.talhanation.bannermod.entity.military.BowmanEntity; +import com.talhanation.bannermod.entity.military.CrossBowmanEntity; +import com.talhanation.bannermod.entity.military.HorsemanEntity; +import com.talhanation.bannermod.entity.military.RecruitShieldmanEntity; +import net.minecraft.core.Holder; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.world.entity.ai.attributes.Attribute; +import net.minecraft.world.entity.ai.attributes.AttributeInstance; +import net.minecraft.world.entity.ai.attributes.AttributeModifier; +import net.minecraft.world.entity.ai.attributes.Attributes; + +public final class PerkEffectService { + private static final ResourceLocation MAX_HEALTH_ID = modifierId("perk_max_health"); + private static final ResourceLocation KNOCKBACK_RESIST_ID = modifierId("perk_knockback_resist"); + private static final ResourceLocation ATTACK_DAMAGE_ID = modifierId("perk_attack_damage"); + private static final ResourceLocation ATTACK_SPEED_ID = modifierId("perk_attack_speed"); + private static final ResourceLocation MOVEMENT_SPEED_ID = modifierId("perk_movement_speed"); + + private PerkEffectService() { + } + + public static void applyRecruitAttributeBonuses(AbstractRecruitEntity recruit) { + PerkArchetype archetype = recruitArchetype(recruit); + applyAttributeBonus(recruit, archetype, Attributes.MAX_HEALTH, PerkStat.MAX_HEALTH, MAX_HEALTH_ID); + applyAttributeBonus(recruit, archetype, Attributes.KNOCKBACK_RESISTANCE, PerkStat.KNOCKBACK_RESIST, KNOCKBACK_RESIST_ID); + applyAttributeBonus(recruit, archetype, Attributes.ATTACK_DAMAGE, PerkStat.ATTACK_DAMAGE, ATTACK_DAMAGE_ID); + applyAttributeBonus(recruit, archetype, Attributes.ATTACK_SPEED, PerkStat.ATTACK_SPEED, ATTACK_SPEED_ID); + applyAttributeBonus(recruit, archetype, Attributes.MOVEMENT_SPEED, PerkStat.MOVEMENT_SPEED, MOVEMENT_SPEED_ID); + } + + public static float rangedInaccuracyFor(AbstractRecruitEntity recruit, float baseInaccuracy) { + double bonus = recruitBonus(recruit, PerkStat.RANGED_ACCURACY); + if (bonus <= 0.0D || baseInaccuracy <= 0.0F) { + return baseInaccuracy; + } + return (float) Math.max(0.0D, baseInaccuracy * Math.max(0.0D, 1.0D - bonus)); + } + + public static float rangedVelocityFor(AbstractRecruitEntity recruit, float baseVelocity) { + double bonus = recruitBonus(recruit, PerkStat.RANGED_VELOCITY); + if (bonus <= 0.0D || baseVelocity <= 0.0F) { + return baseVelocity; + } + return (float) (baseVelocity * (1.0D + bonus)); + } + + public static double recruitBonus(AbstractRecruitEntity recruit, PerkStat stat) { + return bonus(recruit.getPerkProgress(), stat, recruitArchetype(recruit)); + } + + public static double bonus(PerkProgress progress, PerkStat stat, PerkArchetype archetype) { + double total = 0.0D; + for (String id : progress.getOwnedPerks()) { + PerkNode node = PerkRegistry.get(id).orElse(null); + if (node == null || (node.archetype() != PerkArchetype.UNIVERSAL && node.archetype() != archetype)) { + continue; + } + for (PerkBonus bonus : node.bonuses()) { + if (bonus.stat() == stat) { + total += bonus.amount(); + } + } + } + return total; + } + + public static PerkArchetype recruitArchetype(AbstractRecruitEntity recruit) { + if (recruit instanceof BowmanEntity) { + return PerkArchetype.BOWMAN; + } + if (recruit instanceof CrossBowmanEntity) { + return PerkArchetype.CROSSBOWMAN; + } + if (recruit instanceof HorsemanEntity) { + return PerkArchetype.CAVALRY; + } + if (recruit instanceof RecruitShieldmanEntity + || WeaponReach.effectiveReachFor(recruit.getMainHandItem()) >= WeaponReach.PIKE_EXTRA_REACH) { + return PerkArchetype.PIKEMAN; + } + return PerkArchetype.SWORDSMAN; + } + + private static void applyAttributeBonus(AbstractRecruitEntity recruit, PerkArchetype archetype, + Holder attribute, PerkStat stat, ResourceLocation modifierId) { + AttributeInstance instance = recruit.getAttribute(attribute); + if (instance == null) { + return; + } + double amount = bonus(recruit.getPerkProgress(), stat, archetype); + AttributeModifier existing = instance.getModifier(modifierId); + if (existing != null) { + if (amount != 0.0D && Double.compare(existing.amount(), amount) == 0) { + return; + } + instance.removeModifier(modifierId); + } + + if (amount != 0.0D) { + instance.addTransientModifier(new AttributeModifier( + modifierId, + amount, + AttributeModifier.Operation.ADD_VALUE)); + } + } + + private static ResourceLocation modifierId(String path) { + return ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, path); + } +} diff --git a/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkRegistry.java b/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkRegistry.java index 2bcae89c..db58ac52 100644 --- a/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkRegistry.java +++ b/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkRegistry.java @@ -66,6 +66,18 @@ private static List defaultNodes() { return List.of( PerkNode.leaf("universal/toughness_i", PerkArchetype.UNIVERSAL, 1, new PerkBonus(PerkStat.MAX_HEALTH, 2.0D)), + PerkNode.leaf("universal/iron_skin_i", PerkArchetype.UNIVERSAL, 1, + new PerkBonus(PerkStat.KNOCKBACK_RESIST, 0.05D)), + PerkNode.leaf("universal/weapon_training_i", PerkArchetype.UNIVERSAL, 1, + new PerkBonus(PerkStat.ATTACK_DAMAGE, 0.25D)), + PerkNode.leaf("universal/quick_hands_i", PerkArchetype.UNIVERSAL, 1, + new PerkBonus(PerkStat.ATTACK_SPEED, 0.10D)), + PerkNode.leaf("universal/marching_drill_i", PerkArchetype.UNIVERSAL, 1, + new PerkBonus(PerkStat.MOVEMENT_SPEED, 0.01D)), + PerkNode.leaf("universal/steady_aim_i", PerkArchetype.UNIVERSAL, 1, + new PerkBonus(PerkStat.RANGED_ACCURACY, 0.05D)), + PerkNode.leaf("universal/strong_draw_i", PerkArchetype.UNIVERSAL, 1, + new PerkBonus(PerkStat.RANGED_VELOCITY, 0.05D)), PerkNode.leaf("swordsman/iron_grip_i", PerkArchetype.SWORDSMAN, 1, new PerkBonus(PerkStat.ATTACK_DAMAGE, 0.5D)), PerkNode.leaf("bowman/steady_aim_i", PerkArchetype.BOWMAN, 1, diff --git a/src/main/resources/assets/bannermod/lang/en_us.json b/src/main/resources/assets/bannermod/lang/en_us.json index 14a6b8a0..79341013 100644 --- a/src/main/resources/assets/bannermod/lang/en_us.json +++ b/src/main/resources/assets/bannermod/lang/en_us.json @@ -2584,6 +2584,18 @@ "bannermod.assign_home.cancel.timeout": "Assign-Home timed out - try again.", "perk.bannermod.universal.toughness_i": "Toughness I", "perk.bannermod.universal.toughness_i.desc": "+2 max health.", + "perk.bannermod.universal.iron_skin_i": "Iron Skin I", + "perk.bannermod.universal.iron_skin_i.desc": "+5% knockback resistance.", + "perk.bannermod.universal.weapon_training_i": "Weapon Training I", + "perk.bannermod.universal.weapon_training_i.desc": "+0.25 melee attack damage.", + "perk.bannermod.universal.quick_hands_i": "Quick Hands I", + "perk.bannermod.universal.quick_hands_i.desc": "+0.10 attack speed.", + "perk.bannermod.universal.marching_drill_i": "Marching Drill I", + "perk.bannermod.universal.marching_drill_i.desc": "+0.01 movement speed.", + "perk.bannermod.universal.steady_aim_i": "Steady Aim Drill I", + "perk.bannermod.universal.steady_aim_i.desc": "Tightens ranged accuracy by 5%.", + "perk.bannermod.universal.strong_draw_i": "Strong Draw I", + "perk.bannermod.universal.strong_draw_i.desc": "+5% projectile velocity.", "perk.bannermod.swordsman.iron_grip_i": "Iron Grip I", "perk.bannermod.swordsman.iron_grip_i.desc": "+0.5 melee attack damage.", "perk.bannermod.bowman.steady_aim_i": "Steady Aim I", diff --git a/src/main/resources/assets/bannermod/lang/ru_ru.json b/src/main/resources/assets/bannermod/lang/ru_ru.json index 403d7df6..49f1aa73 100644 --- a/src/main/resources/assets/bannermod/lang/ru_ru.json +++ b/src/main/resources/assets/bannermod/lang/ru_ru.json @@ -2494,6 +2494,18 @@ "bannermod.assign_home.cancel.timeout": "Время на выбор дома истекло - повторите.", "perk.bannermod.universal.toughness_i": "Стойкость I", "perk.bannermod.universal.toughness_i.desc": "+2 к максимальному здоровью.", + "perk.bannermod.universal.iron_skin_i": "Железная кожа I", + "perk.bannermod.universal.iron_skin_i.desc": "+5% к сопротивлению отбрасыванию.", + "perk.bannermod.universal.weapon_training_i": "Боевая подготовка I", + "perk.bannermod.universal.weapon_training_i.desc": "+0.25 к урону ближнего боя.", + "perk.bannermod.universal.quick_hands_i": "Быстрые руки I", + "perk.bannermod.universal.quick_hands_i.desc": "+0.10 к скорости атаки.", + "perk.bannermod.universal.marching_drill_i": "Маршевая выучка I", + "perk.bannermod.universal.marching_drill_i.desc": "+0.01 к скорости передвижения.", + "perk.bannermod.universal.steady_aim_i": "Стрелковая выучка I", + "perk.bannermod.universal.steady_aim_i.desc": "Точность дальнего боя повышена на 5%.", + "perk.bannermod.universal.strong_draw_i": "Сильная тетива I", + "perk.bannermod.universal.strong_draw_i.desc": "+5% к скорости снарядов.", "perk.bannermod.swordsman.iron_grip_i": "Железная хватка I", "perk.bannermod.swordsman.iron_grip_i.desc": "+0.5 к урону ближнего боя.", "perk.bannermod.bowman.steady_aim_i": "Твёрдый прицел I", diff --git a/src/main/resources/data/bannermod/perks/universal_iron_skin_i.json b/src/main/resources/data/bannermod/perks/universal_iron_skin_i.json new file mode 100644 index 00000000..fd00d91c --- /dev/null +++ b/src/main/resources/data/bannermod/perks/universal_iron_skin_i.json @@ -0,0 +1,12 @@ +{ + "id": "universal/iron_skin_i", + "archetype": "universal", + "point_cost": 1, + "prerequisites": [], + "bonuses": [ + { + "stat": "knockback_resist", + "amount": 0.05 + } + ] +} diff --git a/src/main/resources/data/bannermod/perks/universal_marching_drill_i.json b/src/main/resources/data/bannermod/perks/universal_marching_drill_i.json new file mode 100644 index 00000000..8ea17bf0 --- /dev/null +++ b/src/main/resources/data/bannermod/perks/universal_marching_drill_i.json @@ -0,0 +1,12 @@ +{ + "id": "universal/marching_drill_i", + "archetype": "universal", + "point_cost": 1, + "prerequisites": [], + "bonuses": [ + { + "stat": "movement_speed", + "amount": 0.01 + } + ] +} diff --git a/src/main/resources/data/bannermod/perks/universal_quick_hands_i.json b/src/main/resources/data/bannermod/perks/universal_quick_hands_i.json new file mode 100644 index 00000000..2e446de4 --- /dev/null +++ b/src/main/resources/data/bannermod/perks/universal_quick_hands_i.json @@ -0,0 +1,12 @@ +{ + "id": "universal/quick_hands_i", + "archetype": "universal", + "point_cost": 1, + "prerequisites": [], + "bonuses": [ + { + "stat": "attack_speed", + "amount": 0.10 + } + ] +} diff --git a/src/main/resources/data/bannermod/perks/universal_steady_aim_i.json b/src/main/resources/data/bannermod/perks/universal_steady_aim_i.json new file mode 100644 index 00000000..a0285cc2 --- /dev/null +++ b/src/main/resources/data/bannermod/perks/universal_steady_aim_i.json @@ -0,0 +1,12 @@ +{ + "id": "universal/steady_aim_i", + "archetype": "universal", + "point_cost": 1, + "prerequisites": [], + "bonuses": [ + { + "stat": "ranged_accuracy", + "amount": 0.05 + } + ] +} diff --git a/src/main/resources/data/bannermod/perks/universal_strong_draw_i.json b/src/main/resources/data/bannermod/perks/universal_strong_draw_i.json new file mode 100644 index 00000000..a90a6f4d --- /dev/null +++ b/src/main/resources/data/bannermod/perks/universal_strong_draw_i.json @@ -0,0 +1,12 @@ +{ + "id": "universal/strong_draw_i", + "archetype": "universal", + "point_cost": 1, + "prerequisites": [], + "bonuses": [ + { + "stat": "ranged_velocity", + "amount": 0.05 + } + ] +} diff --git a/src/main/resources/data/bannermod/perks/universal_weapon_training_i.json b/src/main/resources/data/bannermod/perks/universal_weapon_training_i.json new file mode 100644 index 00000000..7d8f5c83 --- /dev/null +++ b/src/main/resources/data/bannermod/perks/universal_weapon_training_i.json @@ -0,0 +1,12 @@ +{ + "id": "universal/weapon_training_i", + "archetype": "universal", + "point_cost": 1, + "prerequisites": [], + "bonuses": [ + { + "stat": "attack_damage", + "amount": 0.25 + } + ] +} diff --git a/src/test/java/com/talhanation/bannermod/entity/military/perks/PerkCatalogContractTest.java b/src/test/java/com/talhanation/bannermod/entity/military/perks/PerkCatalogContractTest.java new file mode 100644 index 00000000..1be491e0 --- /dev/null +++ b/src/test/java/com/talhanation/bannermod/entity/military/perks/PerkCatalogContractTest.java @@ -0,0 +1,61 @@ +package com.talhanation.bannermod.entity.military.perks; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PerkCatalogContractTest { + + @Test + void defaultCatalogCoversRecruitArchetypesAndUniversalStats() { + Set universalStats = EnumSet.noneOf(PerkStat.class); + for (PerkNode node : PerkRegistry.byArchetype(PerkArchetype.UNIVERSAL)) { + for (PerkBonus bonus : node.bonuses()) { + universalStats.add(bonus.stat()); + } + } + + assertTrue(universalStats.containsAll(EnumSet.allOf(PerkStat.class)), + "Expected universal recruit catalog to expose every general stat"); + for (PerkArchetype archetype : PerkArchetype.values()) { + if (archetype != PerkArchetype.UNIVERSAL) { + assertFalse(PerkRegistry.byArchetype(archetype).isEmpty(), + "Expected at least one tracked perk for " + archetype); + } + } + } + + @Test + void defaultCatalogHasEnglishAndRussianLocalizationKeys() throws IOException { + String enUs = Files.readString(Path.of("src/main/resources/assets/bannermod/lang/en_us.json")); + String ruRu = Files.readString(Path.of("src/main/resources/assets/bannermod/lang/ru_ru.json")); + + for (PerkNode node : allDefaultNodes()) { + assertContains(enUs, node.localizationKey()); + assertContains(enUs, node.localizationKey() + ".desc"); + assertContains(ruRu, node.localizationKey()); + assertContains(ruRu, node.localizationKey() + ".desc"); + } + } + + private static List allDefaultNodes() { + List nodes = new ArrayList<>(); + for (PerkArchetype archetype : PerkArchetype.values()) { + nodes.addAll(PerkRegistry.byArchetype(archetype)); + } + return nodes; + } + + private static void assertContains(String content, String key) { + assertTrue(content.contains("\"" + key + "\""), "Missing localization key: " + key); + } +} From 7d5a000289e5316ce8f55a8aa625f03034ec2413 Mon Sep 17 00:00:00 2001 From: "pozdn.r.a" Date: Sat, 9 May 2026 15:39:11 +0700 Subject: [PATCH 2/7] backlog: close recruit skill tree task --- MULTIPLAYER_GUIDE_EN.md | 2 ++ MULTIPLAYER_GUIDE_RU.md | 2 ++ docs/BANNERMOD_ALMANAC.html | 2 ++ docs/BANNERMOD_BACKLOG.json | 14 ++++++++++---- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/MULTIPLAYER_GUIDE_EN.md b/MULTIPLAYER_GUIDE_EN.md index fb6ef85d..a40a1670 100644 --- a/MULTIPLAYER_GUIDE_EN.md +++ b/MULTIPLAYER_GUIDE_EN.md @@ -275,6 +275,8 @@ You can set per-unit stances from the recruit's inventory (`RecruitInventoryScre If a crossbowman is holding a musketmod gun, the recruit inventory now shows whether that firearm is supported, whether cartridges are present, or whether the gun is unsupported for recruit use. Check that feedback before assuming the recruit combat runtime is broken. +Recruit perks are server-side. Level-ups grant recruit perk points, and unlocked perks can add max health, knockback resistance, melee damage, attack speed, movement speed, ranged accuracy, or projectile velocity. Archetype perks apply only to the matching role: swordsman, bowman, crossbowman, pikeman/shieldman, or cavalry. + ### Recruit command pipeline details Server-side military commands are normalized into `CommandIntent` records before they reach legacy command services. This matters because selection narrowing, queue mode, priority, and audit/logging hooks all live in the unified command path. diff --git a/MULTIPLAYER_GUIDE_RU.md b/MULTIPLAYER_GUIDE_RU.md index a7498500..d9ab1242 100644 --- a/MULTIPLAYER_GUIDE_RU.md +++ b/MULTIPLAYER_GUIDE_RU.md @@ -295,6 +295,8 @@ BannerMod добавляет поселения, рабочих, армии, г Если арбалетчик держит огнестрел из musketmod, в его инвентаре теперь видно, поддерживается ли это оружие, есть ли патроны, или предмет вообще не поддерживается рекрутами. Сначала проверь эту строку, прежде чем считать, что боевая логика сломана. +Перки рекрутов считаются на сервере. За уровни рекрут получает очки перков, а открытые перки могут добавить максимум здоровья, сопротивление отбрасыванию, урон ближнего боя, скорость атаки, скорость движения, точность дальнего боя или скорость снарядов. Архетипные перки работают только для своей роли: мечник, лучник, арбалетчик, копейщик/щитоносец или кавалерия. + ## War Room (`U`) War Room — главный игровой UI для войны и политики: diff --git a/docs/BANNERMOD_ALMANAC.html b/docs/BANNERMOD_ALMANAC.html index a948acd2..62554a6c 100644 --- a/docs/BANNERMOD_ALMANAC.html +++ b/docs/BANNERMOD_ALMANAC.html @@ -118,6 +118,7 @@

8. Recruits, Orders, And Combat

Recruits obey owner and group authority. All movement, facing, attack, aggression, ranged fire, stance, mount, and siege-machine commands flow through a server command intent pipeline. This preserves selection narrowing, queued orders, priorities, and audit logging.

The recruit command, recruit inventory, hiring, rename, promotion, and group-management screens now keep a visible status line. When an action is disabled, that line or the tooltip tells you the missing next step: choose a company or player, aim at ground or a unit, type a name, or save the company first.

If a crossbowman is holding a musketmod firearm, inspect that recruit's inventory screen for explicit firearm feedback. It now tells you whether the gun is recruit-supported, whether cartridges are present, or whether the weapon is unsupported and should not be expected to fire.

+

Recruit perks are server-side: level-ups grant perk points, universal perks can improve health, knockback resistance, melee damage, attack speed, movement, ranged accuracy, or projectile velocity, and archetype perks apply only to swordsmen, bowmen, crossbowmen, pikemen/shieldmen, or cavalry.

Movement states

StateOrder
0Hold position.
1Follow owner.
2Regroup.
3Wander.
4Come to me.
5Patrol.
6Move to position.
7 / 8Formation forward / backward.

Stances and combat rules

@@ -259,6 +260,7 @@

Рост населения: рождения и порог еды

8. Рекруты, приказы и бой

Рекруты подчиняются владельцу и группе. Все приказы движения, поворота, атаки, поведения, дальнего огня, строя, посадки и осадных машин проходят через единый серверный путь приказов. Это сохраняет выбор бойцов, очередь, приоритет и журнал команд.

+

Перки рекрутов считаются на сервере: уровни дают очки перков, универсальные перки улучшают здоровье, сопротивление отбрасыванию, урон ближнего боя, скорость атаки, движение, точность дальнего боя или скорость снарядов, а архетипные перки работают только для мечников, лучников, арбалетчиков, копейщиков/щитоносцев или кавалерии.

Состояния движения

КодПриказ
0Держать место.
1Следовать за владельцем.
2Собраться.
3Бродить.
4Ко мне.
5Патруль.
6Идти в точку.
7 / 8Строй вперёд / назад.

Стойки и правила боя

diff --git a/docs/BANNERMOD_BACKLOG.json b/docs/BANNERMOD_BACKLOG.json index 6f497c1d..28482bba 100644 --- a/docs/BANNERMOD_BACKLOG.json +++ b/docs/BANNERMOD_BACKLOG.json @@ -7980,8 +7980,8 @@ { "id": "SKILLTREE-003", "title": "Per-archetype recruit perk catalogs + combat hooks", - "status": "open", - "updated": "2026-05-04", + "status": "done", + "updated": "2026-05-09", "why": "SKILLTREE-001 phase plan step 2: once the data model exists, recruit perks (sword/bow/crossbow/pikeman/cavalry) plus general stats (HP, KB-resist, attack damage, attack speed, movement, ranged accuracy, ranged velocity) need real catalog entries and observable combat effects.", "scope": [ "Author per-archetype perk catalogs for swordsman, bowman, crossbowman, pikeman, and cavalry recruits using the SKILLTREE-002 model.", @@ -7999,8 +7999,14 @@ "SKILLTREE-002" ], "progress": [], - "verification": [], - "evidence": [] + "verification": [ + { + "date": "2026-05-09", + "result": "1) ./gradlew runGameTestServer passed under JDK 21, including BannerModRecruitSkillTreeCombatGameTests coverage for swordsman damage, universal HP, pikeman knockback resistance, cavalry movement, bow accuracy, and captured crossbow projectile velocity deltas. 2) freshRecruitKeepsBaselineStatsAndNoUnlockedPerks GameTest passed, proving no-perk recruits keep baseline stats and zero unlocked perks. 3) PerkCatalogContractTest passed for universal stat coverage plus en_us/ru_ru keys for every default perk; runGameTestServer log had no missing-key failure. 4) tools/backlog validate passed and ./gradlew compileJava passed under JDK 21." + } + ], + "evidence": [], + "doneDate": "2026-05-09" }, { "id": "SKILLTREE-004", From 5d256cc890b26c71bb566cd95e1e739986b90529 Mon Sep 17 00:00:00 2001 From: "pozdn.r.a" Date: Sat, 9 May 2026 16:09:04 +0700 Subject: [PATCH 3/7] add player skill tree combat hooks --- ...nnerModPlayerSkillTreeCombatGameTests.java | 119 ++++++++++++++++++ ...annerModSkillTreePersistenceGameTests.java | 25 +++- .../bannermod/bootstrap/BannerModMain.java | 1 + .../military/perks/PerkEffectService.java | 77 +++++++++--- .../entity/military/perks/PerkRegistry.java | 14 +++ .../perks/PlayerPerkProgressService.java | 16 +++ .../bannermod/events/PlayerPerkEvents.java | 69 ++++++++++ .../assets/bannermod/lang/en_us.json | 14 +++ .../assets/bannermod/lang/ru_ru.json | 14 +++ .../bannermod/perks/player_iron_skin_i.json | 12 ++ .../perks/player_marching_drill_i.json | 12 ++ .../bannermod/perks/player_quick_hands_i.json | 12 ++ .../bannermod/perks/player_steady_aim_i.json | 12 ++ .../bannermod/perks/player_strong_draw_i.json | 12 ++ .../bannermod/perks/player_toughness_i.json | 12 ++ .../perks/player_weapon_training_i.json | 12 ++ 16 files changed, 412 insertions(+), 21 deletions(-) create mode 100644 src/gametest/java/com/talhanation/bannermod/entity/military/perks/BannerModPlayerSkillTreeCombatGameTests.java create mode 100644 src/main/java/com/talhanation/bannermod/events/PlayerPerkEvents.java create mode 100644 src/main/resources/data/bannermod/perks/player_iron_skin_i.json create mode 100644 src/main/resources/data/bannermod/perks/player_marching_drill_i.json create mode 100644 src/main/resources/data/bannermod/perks/player_quick_hands_i.json create mode 100644 src/main/resources/data/bannermod/perks/player_steady_aim_i.json create mode 100644 src/main/resources/data/bannermod/perks/player_strong_draw_i.json create mode 100644 src/main/resources/data/bannermod/perks/player_toughness_i.json create mode 100644 src/main/resources/data/bannermod/perks/player_weapon_training_i.json diff --git a/src/gametest/java/com/talhanation/bannermod/entity/military/perks/BannerModPlayerSkillTreeCombatGameTests.java b/src/gametest/java/com/talhanation/bannermod/entity/military/perks/BannerModPlayerSkillTreeCombatGameTests.java new file mode 100644 index 00000000..b5cd11cf --- /dev/null +++ b/src/gametest/java/com/talhanation/bannermod/entity/military/perks/BannerModPlayerSkillTreeCombatGameTests.java @@ -0,0 +1,119 @@ +package com.talhanation.bannermod.entity.military.perks; + +import com.talhanation.bannermod.BannerModDedicatedServerGameTestSupport; +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.events.PlayerPerkEvents; +import net.minecraft.core.BlockPos; +import net.minecraft.gametest.framework.GameTest; +import net.minecraft.gametest.framework.GameTestHelper; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.ai.attributes.Attribute; +import net.minecraft.world.entity.ai.attributes.Attributes; +import net.minecraft.world.entity.projectile.AbstractArrow; +import net.minecraft.world.entity.projectile.Arrow; +import net.minecraft.world.item.Items; +import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent; +import net.neoforged.neoforge.event.entity.player.PlayerXpEvent; +import net.neoforged.neoforge.gametest.GameTestHolder; +import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; + +import java.util.UUID; + +@GameTestHolder(BannerModMain.MOD_ID) +public class BannerModPlayerSkillTreeCombatGameTests { + private static final UUID PLAYER_UUID = UUID.fromString("00000000-0000-0000-0000-000000002d02"); + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void playerPerksModifyCombatStatsAndProjectiles(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + ServerPlayer player = (ServerPlayer) BannerModDedicatedServerGameTestSupport.createPositionedFakeServerPlayer( + level, PLAYER_UUID, "perk-player", helper.absolutePos(new BlockPos(1, 2, 1))); + + double health = value(player, Attributes.MAX_HEALTH); + double knockback = value(player, Attributes.KNOCKBACK_RESISTANCE); + double damage = value(player, Attributes.ATTACK_DAMAGE); + double attackSpeed = value(player, Attributes.ATTACK_SPEED); + double movement = value(player, Attributes.MOVEMENT_SPEED); + float inaccuracy = PerkEffectService.playerRangedInaccuracyFor(player, 4.0F); + PlayerPerkEvents events = new PlayerPerkEvents(); + AbstractArrow baselineArrow = arrow(level, player); + baselineArrow.shoot(1.0D, 0.0D, 0.0D, 2.0F, 0.0F); + events.onProjectileJoin(new EntityJoinLevelEvent(baselineArrow, level)); + + unlock(player, "player/toughness_i"); + unlock(player, "player/iron_skin_i"); + unlock(player, "player/weapon_training_i"); + unlock(player, "player/quick_hands_i"); + unlock(player, "player/marching_drill_i"); + unlock(player, "player/steady_aim_i"); + unlock(player, "player/strong_draw_i"); + PerkEffectService.applyPlayerAttributeBonuses(player); + AbstractArrow perkArrow = arrow(level, player); + perkArrow.shoot(1.0D, 0.0D, 0.0D, 2.0F, 0.0F); + events.onProjectileJoin(new EntityJoinLevelEvent(perkArrow, level)); + + helper.assertTrue(value(player, Attributes.MAX_HEALTH) > health, + "Expected player max-health perk to raise max health"); + helper.assertTrue(value(player, Attributes.KNOCKBACK_RESISTANCE) > knockback, + "Expected player knockback perk to raise knockback resistance"); + helper.assertTrue(value(player, Attributes.ATTACK_DAMAGE) > damage, + "Expected player damage perk to raise attack damage"); + helper.assertTrue(value(player, Attributes.ATTACK_SPEED) > attackSpeed, + "Expected player attack-speed perk to raise attack speed"); + helper.assertTrue(value(player, Attributes.MOVEMENT_SPEED) > movement, + "Expected player movement perk to raise movement speed"); + helper.assertTrue(PerkEffectService.playerRangedInaccuracyFor(player, 4.0F) < inaccuracy, + "Expected player accuracy perk to reduce ranged inaccuracy"); + helper.assertTrue(perkArrow.getDeltaMovement().lengthSqr() > baselineArrow.getDeltaMovement().lengthSqr(), + "Expected player velocity perk to produce a faster captured projectile"); + helper.succeed(); + } + + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void playerLevelEarningKillCreditAndRespecMatchRecruitRate(GameTestHelper helper) { + ServerPlayer player = (ServerPlayer) BannerModDedicatedServerGameTestSupport.createPositionedFakeServerPlayer( + helper.getLevel(), UUID.fromString("00000000-0000-0000-0000-000000002d03"), "perk-earner", helper.absolutePos(new BlockPos(1, 2, 2))); + PlayerPerkEvents events = new PlayerPerkEvents(); + + events.onPlayerLevelChange(new PlayerXpEvent.LevelChange(player, 3)); + helper.assertTrue(PlayerPerkProgressService.availablePoints(player) == 3, + "Expected player level earning to grant one perk point per gained level"); + PlayerPerkProgressService.grantKillCredit(player); + helper.assertTrue(PlayerPerkProgressService.availablePoints(player) == 4, + "Expected player kill credit to grant the same single-point increment"); + + if (PlayerPerkProgressService.unlock(player, "player/weapon_training_i") != PerkProgress.UnlockResult.OK) { + throw new IllegalStateException("Could not unlock player earning test perk"); + } + helper.assertTrue(PlayerPerkProgressService.availablePoints(player) == 3, + "Expected unlocking one cost-1 player perk to spend one point"); + int refund = PlayerPerkProgressService.respec(player); + helper.assertTrue(refund == 1, + "Expected player respec to refund the unlocked perk cost"); + helper.assertTrue(PlayerPerkProgressService.availablePoints(player) == 4, + "Expected player respec to restore spent points"); + helper.assertTrue(PlayerPerkProgressService.unlockedPerkIds(player).isEmpty(), + "Expected player respec to clear unlocked perks"); + helper.assertTrue(PlayerPerkProgressService.perkPointsPerLevel() == 1, + "Expected player point rate to match recruit point rate numerically"); + helper.succeed(); + } + + private static void unlock(ServerPlayer player, String perkId) { + PlayerPerkProgressService.progress(player).grantPoints(1); + if (PlayerPerkProgressService.unlock(player, perkId) != PerkProgress.UnlockResult.OK) { + throw new IllegalStateException("Could not unlock player test perk: " + perkId); + } + } + + private static AbstractArrow arrow(ServerLevel level, ServerPlayer player) { + return new Arrow(level, player, Items.ARROW.getDefaultInstance(), Items.BOW.getDefaultInstance()); + } + + private static double value(ServerPlayer player, net.minecraft.core.Holder attribute) { + return player.getAttributeValue(attribute); + } +} diff --git a/src/gametest/java/com/talhanation/bannermod/entity/military/perks/BannerModSkillTreePersistenceGameTests.java b/src/gametest/java/com/talhanation/bannermod/entity/military/perks/BannerModSkillTreePersistenceGameTests.java index 7332df51..645b6cd0 100644 --- a/src/gametest/java/com/talhanation/bannermod/entity/military/perks/BannerModSkillTreePersistenceGameTests.java +++ b/src/gametest/java/com/talhanation/bannermod/entity/military/perks/BannerModSkillTreePersistenceGameTests.java @@ -12,6 +12,7 @@ import net.minecraft.nbt.CompoundTag; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.Level; import net.neoforged.neoforge.common.util.FakePlayer; import net.neoforged.neoforge.gametest.GameTestHolder; import net.neoforged.neoforge.gametest.PrefixGameTestTemplate; @@ -23,7 +24,7 @@ public class BannerModSkillTreePersistenceGameTests { private static final UUID RECRUIT_OWNER_UUID = UUID.fromString("00000000-0000-0000-0000-000000002c01"); private static final UUID PLAYER_UUID = UUID.fromString("00000000-0000-0000-0000-000000002c02"); private static final String RECRUIT_PERK_ID = "universal/toughness_i"; - private static final String PLAYER_PERK_ID = "swordsman/iron_grip_i"; + private static final String PLAYER_PERK_ID = "player/weapon_training_i"; @PrefixGameTestTemplate(false) @GameTest(template = "harness_empty") @@ -70,8 +71,20 @@ public static void playerAttachmentPerksAndSkillPointsSurviveSaveLoad(GameTestHe helper.assertTrue(PlayerPerkProgressService.unlock(player, PLAYER_PERK_ID) == PerkProgress.UnlockResult.OK, "Expected player test perk to unlock before save"); + ServerLevel destination = level.getServer().getLevel(Level.NETHER); + if (destination == null) { + destination = level; + } + if (destination != level) { + player.setServerLevel(destination); + player.moveTo(player.getX(), player.getY() + 1.0D, player.getZ(), player.getYRot(), player.getXRot()); + } + helper.assertTrue(PlayerPerkProgressService.progress(player).isOwned(PLAYER_PERK_ID), + "Expected player perk to survive dimension teleport before reload"); + CompoundTag saved = BannerModDedicatedServerGameTestSupport.saveEntity(player); - ServerPlayer reloaded = new FakePlayer(level, new GameProfile(UUID.fromString("00000000-0000-0000-0000-000000002c03"), "skilltree-reloaded")); + ServerLevel currentLevel = player.serverLevel(); + ServerPlayer reloaded = new FakePlayer(currentLevel, new GameProfile(UUID.fromString("00000000-0000-0000-0000-000000002c03"), "skilltree-reloaded")); reloaded.load(saved); PerkProgress restored = PlayerPerkProgressService.progress(reloaded); @@ -79,6 +92,14 @@ public static void playerAttachmentPerksAndSkillPointsSurviveSaveLoad(GameTestHe "Expected player attachment unlocked perk to survive save/load"); helper.assertTrue(restored.getAvailablePoints() == 2, "Expected player attachment skill points to survive save/load"); + + ServerPlayer dimensionReloaded = new FakePlayer(destination, new GameProfile(UUID.fromString("00000000-0000-0000-0000-000000002c04"), "skilltree-dimension")); + dimensionReloaded.load(saved); + PerkProgress dimensionProgress = PlayerPerkProgressService.progress(dimensionReloaded); + helper.assertTrue(dimensionProgress.isOwned(PLAYER_PERK_ID), + "Expected player perk to survive reload after dimension transfer"); + helper.assertTrue(dimensionProgress.getAvailablePoints() == 2, + "Expected player skill points to survive reload after dimension transfer"); helper.succeed(); } diff --git a/src/main/java/com/talhanation/bannermod/bootstrap/BannerModMain.java b/src/main/java/com/talhanation/bannermod/bootstrap/BannerModMain.java index 2e0b0313..ee8ed0de 100644 --- a/src/main/java/com/talhanation/bannermod/bootstrap/BannerModMain.java +++ b/src/main/java/com/talhanation/bannermod/bootstrap/BannerModMain.java @@ -147,6 +147,7 @@ private void setup(final FMLCommonSetupEvent event) { NeoForge.EVENT_BUS.register(new ClaimEvents()); NeoForge.EVENT_BUS.register(new CommandEvents()); NeoForge.EVENT_BUS.register(new DamageEvent()); + NeoForge.EVENT_BUS.register(new com.talhanation.bannermod.events.PlayerPerkEvents()); NeoForge.EVENT_BUS.register(new PillagerEvents()); NeoForge.EVENT_BUS.register(new VillagerEvents()); NeoForge.EVENT_BUS.register(new WarPvpEvents()); diff --git a/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkEffectService.java b/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkEffectService.java index ff099ebe..20dc8f22 100644 --- a/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkEffectService.java +++ b/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkEffectService.java @@ -9,6 +9,8 @@ import com.talhanation.bannermod.entity.military.RecruitShieldmanEntity; import net.minecraft.core.Holder; import net.minecraft.resources.ResourceLocation; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.LivingEntity; import net.minecraft.world.entity.ai.attributes.Attribute; import net.minecraft.world.entity.ai.attributes.AttributeInstance; import net.minecraft.world.entity.ai.attributes.AttributeModifier; @@ -26,33 +28,57 @@ private PerkEffectService() { public static void applyRecruitAttributeBonuses(AbstractRecruitEntity recruit) { PerkArchetype archetype = recruitArchetype(recruit); - applyAttributeBonus(recruit, archetype, Attributes.MAX_HEALTH, PerkStat.MAX_HEALTH, MAX_HEALTH_ID); - applyAttributeBonus(recruit, archetype, Attributes.KNOCKBACK_RESISTANCE, PerkStat.KNOCKBACK_RESIST, KNOCKBACK_RESIST_ID); - applyAttributeBonus(recruit, archetype, Attributes.ATTACK_DAMAGE, PerkStat.ATTACK_DAMAGE, ATTACK_DAMAGE_ID); - applyAttributeBonus(recruit, archetype, Attributes.ATTACK_SPEED, PerkStat.ATTACK_SPEED, ATTACK_SPEED_ID); - applyAttributeBonus(recruit, archetype, Attributes.MOVEMENT_SPEED, PerkStat.MOVEMENT_SPEED, MOVEMENT_SPEED_ID); + applyAttributeBonus(recruit, Attributes.MAX_HEALTH, bonus(recruit.getPerkProgress(), PerkStat.MAX_HEALTH, archetype), MAX_HEALTH_ID); + applyAttributeBonus(recruit, Attributes.KNOCKBACK_RESISTANCE, bonus(recruit.getPerkProgress(), PerkStat.KNOCKBACK_RESIST, archetype), KNOCKBACK_RESIST_ID); + applyAttributeBonus(recruit, Attributes.ATTACK_DAMAGE, bonus(recruit.getPerkProgress(), PerkStat.ATTACK_DAMAGE, archetype), ATTACK_DAMAGE_ID); + applyAttributeBonus(recruit, Attributes.ATTACK_SPEED, bonus(recruit.getPerkProgress(), PerkStat.ATTACK_SPEED, archetype), ATTACK_SPEED_ID); + applyAttributeBonus(recruit, Attributes.MOVEMENT_SPEED, bonus(recruit.getPerkProgress(), PerkStat.MOVEMENT_SPEED, archetype), MOVEMENT_SPEED_ID); + } + + public static void applyPlayerAttributeBonuses(ServerPlayer player) { + applyAttributeBonus(player, Attributes.MAX_HEALTH, playerBonus(player, PerkStat.MAX_HEALTH), MAX_HEALTH_ID); + applyAttributeBonus(player, Attributes.KNOCKBACK_RESISTANCE, playerBonus(player, PerkStat.KNOCKBACK_RESIST), KNOCKBACK_RESIST_ID); + applyAttributeBonus(player, Attributes.ATTACK_DAMAGE, playerBonus(player, PerkStat.ATTACK_DAMAGE), ATTACK_DAMAGE_ID); + applyAttributeBonus(player, Attributes.ATTACK_SPEED, playerBonus(player, PerkStat.ATTACK_SPEED), ATTACK_SPEED_ID); + applyAttributeBonus(player, Attributes.MOVEMENT_SPEED, playerBonus(player, PerkStat.MOVEMENT_SPEED), MOVEMENT_SPEED_ID); } public static float rangedInaccuracyFor(AbstractRecruitEntity recruit, float baseInaccuracy) { - double bonus = recruitBonus(recruit, PerkStat.RANGED_ACCURACY); - if (bonus <= 0.0D || baseInaccuracy <= 0.0F) { - return baseInaccuracy; - } - return (float) Math.max(0.0D, baseInaccuracy * Math.max(0.0D, 1.0D - bonus)); + return rangedInaccuracy(baseInaccuracy, recruitBonus(recruit, PerkStat.RANGED_ACCURACY)); } public static float rangedVelocityFor(AbstractRecruitEntity recruit, float baseVelocity) { - double bonus = recruitBonus(recruit, PerkStat.RANGED_VELOCITY); - if (bonus <= 0.0D || baseVelocity <= 0.0F) { - return baseVelocity; - } - return (float) (baseVelocity * (1.0D + bonus)); + return rangedVelocity(baseVelocity, recruitBonus(recruit, PerkStat.RANGED_VELOCITY)); + } + + public static float playerRangedInaccuracyFor(ServerPlayer player, float baseInaccuracy) { + return rangedInaccuracy(baseInaccuracy, playerBonus(player, PerkStat.RANGED_ACCURACY)); + } + + public static float playerRangedVelocityFor(ServerPlayer player, float baseVelocity) { + return rangedVelocity(baseVelocity, playerBonus(player, PerkStat.RANGED_VELOCITY)); } public static double recruitBonus(AbstractRecruitEntity recruit, PerkStat stat) { return bonus(recruit.getPerkProgress(), stat, recruitArchetype(recruit)); } + public static double playerBonus(ServerPlayer player, PerkStat stat) { + double total = 0.0D; + for (String id : PlayerPerkProgressService.unlockedPerkIds(player)) { + PerkNode node = PerkRegistry.get(id).orElse(null); + if (!PlayerPerkProgressService.isPlayerPerk(node)) { + continue; + } + for (PerkBonus bonus : node.bonuses()) { + if (bonus.stat() == stat) { + total += bonus.amount(); + } + } + } + return total; + } + public static double bonus(PerkProgress progress, PerkStat stat, PerkArchetype archetype) { double total = 0.0D; for (String id : progress.getOwnedPerks()) { @@ -86,13 +112,12 @@ public static PerkArchetype recruitArchetype(AbstractRecruitEntity recruit) { return PerkArchetype.SWORDSMAN; } - private static void applyAttributeBonus(AbstractRecruitEntity recruit, PerkArchetype archetype, - Holder attribute, PerkStat stat, ResourceLocation modifierId) { - AttributeInstance instance = recruit.getAttribute(attribute); + private static void applyAttributeBonus(LivingEntity entity, Holder attribute, + double amount, ResourceLocation modifierId) { + AttributeInstance instance = entity.getAttribute(attribute); if (instance == null) { return; } - double amount = bonus(recruit.getPerkProgress(), stat, archetype); AttributeModifier existing = instance.getModifier(modifierId); if (existing != null) { if (amount != 0.0D && Double.compare(existing.amount(), amount) == 0) { @@ -112,4 +137,18 @@ private static void applyAttributeBonus(AbstractRecruitEntity recruit, PerkArche private static ResourceLocation modifierId(String path) { return ResourceLocation.fromNamespaceAndPath(BannerModMain.MOD_ID, path); } + + private static float rangedInaccuracy(float baseInaccuracy, double bonus) { + if (bonus <= 0.0D || baseInaccuracy <= 0.0F) { + return baseInaccuracy; + } + return (float) Math.max(0.0D, baseInaccuracy * Math.max(0.0D, 1.0D - bonus)); + } + + private static float rangedVelocity(float baseVelocity, double bonus) { + if (bonus <= 0.0D || baseVelocity <= 0.0F) { + return baseVelocity; + } + return (float) (baseVelocity * (1.0D + bonus)); + } } diff --git a/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkRegistry.java b/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkRegistry.java index db58ac52..4788aadb 100644 --- a/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkRegistry.java +++ b/src/main/java/com/talhanation/bannermod/entity/military/perks/PerkRegistry.java @@ -78,6 +78,20 @@ private static List defaultNodes() { new PerkBonus(PerkStat.RANGED_ACCURACY, 0.05D)), PerkNode.leaf("universal/strong_draw_i", PerkArchetype.UNIVERSAL, 1, new PerkBonus(PerkStat.RANGED_VELOCITY, 0.05D)), + PerkNode.leaf("player/toughness_i", PerkArchetype.UNIVERSAL, 1, + new PerkBonus(PerkStat.MAX_HEALTH, 2.0D)), + PerkNode.leaf("player/iron_skin_i", PerkArchetype.UNIVERSAL, 1, + new PerkBonus(PerkStat.KNOCKBACK_RESIST, 0.05D)), + PerkNode.leaf("player/weapon_training_i", PerkArchetype.UNIVERSAL, 1, + new PerkBonus(PerkStat.ATTACK_DAMAGE, 0.25D)), + PerkNode.leaf("player/quick_hands_i", PerkArchetype.UNIVERSAL, 1, + new PerkBonus(PerkStat.ATTACK_SPEED, 0.10D)), + PerkNode.leaf("player/marching_drill_i", PerkArchetype.UNIVERSAL, 1, + new PerkBonus(PerkStat.MOVEMENT_SPEED, 0.01D)), + PerkNode.leaf("player/steady_aim_i", PerkArchetype.UNIVERSAL, 1, + new PerkBonus(PerkStat.RANGED_ACCURACY, 0.05D)), + PerkNode.leaf("player/strong_draw_i", PerkArchetype.UNIVERSAL, 1, + new PerkBonus(PerkStat.RANGED_VELOCITY, 0.05D)), PerkNode.leaf("swordsman/iron_grip_i", PerkArchetype.SWORDSMAN, 1, new PerkBonus(PerkStat.ATTACK_DAMAGE, 0.5D)), PerkNode.leaf("bowman/steady_aim_i", PerkArchetype.BOWMAN, 1, diff --git a/src/main/java/com/talhanation/bannermod/entity/military/perks/PlayerPerkProgressService.java b/src/main/java/com/talhanation/bannermod/entity/military/perks/PlayerPerkProgressService.java index 90879242..850bf44f 100644 --- a/src/main/java/com/talhanation/bannermod/entity/military/perks/PlayerPerkProgressService.java +++ b/src/main/java/com/talhanation/bannermod/entity/military/perks/PlayerPerkProgressService.java @@ -7,6 +7,7 @@ public final class PlayerPerkProgressService { private static final int PERK_POINTS_PER_LEVEL = 1; + private static final String PLAYER_PERK_PREFIX = "player/"; private PlayerPerkProgressService() { } @@ -28,12 +29,27 @@ public static void grantLevelPoints(ServerPlayer player, int gainedLevels) { progress(player).grantPoints(gainedLevels * PERK_POINTS_PER_LEVEL); } + public static void grantKillCredit(ServerPlayer player) { + progress(player).grantPoints(PERK_POINTS_PER_LEVEL); + } + public static PerkProgress.UnlockResult unlock(ServerPlayer player, String perkId) { PerkNode node = PerkRegistry.get(perkId).orElse(null); + if (!isPlayerPerk(node)) { + return PerkProgress.UnlockResult.UNKNOWN_PERK; + } return progress(player).unlock(node); } public static int respec(ServerPlayer player) { return progress(player).respec(); } + + public static boolean isPlayerPerk(PerkNode node) { + return node != null && node.id().startsWith(PLAYER_PERK_PREFIX); + } + + public static int perkPointsPerLevel() { + return PERK_POINTS_PER_LEVEL; + } } diff --git a/src/main/java/com/talhanation/bannermod/events/PlayerPerkEvents.java b/src/main/java/com/talhanation/bannermod/events/PlayerPerkEvents.java new file mode 100644 index 00000000..048aee3e --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/events/PlayerPerkEvents.java @@ -0,0 +1,69 @@ +package com.talhanation.bannermod.events; + +import com.talhanation.bannermod.entity.military.perks.PerkEffectService; +import com.talhanation.bannermod.entity.military.perks.PerkStat; +import com.talhanation.bannermod.entity.military.perks.PlayerPerkProgressService; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.projectile.Projectile; +import net.minecraft.world.phys.Vec3; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent; +import net.neoforged.neoforge.event.entity.living.LivingDeathEvent; +import net.neoforged.neoforge.event.entity.player.PlayerXpEvent; +import net.neoforged.neoforge.event.tick.PlayerTickEvent; + +public class PlayerPerkEvents { + @SubscribeEvent + public void onPlayerTick(PlayerTickEvent.Post event) { + if (event.getEntity() instanceof ServerPlayer player) { + PerkEffectService.applyPlayerAttributeBonuses(player); + } + } + + @SubscribeEvent + public void onPlayerLevelChange(PlayerXpEvent.LevelChange event) { + if (!event.isCanceled() && event.getEntity() instanceof ServerPlayer player && event.getLevels() > 0) { + PlayerPerkProgressService.grantLevelPoints(player, event.getLevels()); + } + } + + @SubscribeEvent + public void onProjectileJoin(EntityJoinLevelEvent event) { + if (event.getLevel().isClientSide()) { + return; + } + if (event.getEntity() instanceof Projectile projectile && projectile.getOwner() instanceof ServerPlayer player) { + applyPlayerProjectileBonuses(player, projectile); + } + } + + @SubscribeEvent + public void onLivingDeath(LivingDeathEvent event) { + if (event.getEntity().level().isClientSide()) { + return; + } + Entity killer = event.getSource().getEntity(); + if (killer instanceof ServerPlayer player && killer != event.getEntity()) { + PlayerPerkProgressService.grantKillCredit(player); + } + } + + public static void applyPlayerProjectileBonuses(ServerPlayer player, Projectile projectile) { + Vec3 movement = projectile.getDeltaMovement(); + double speed = movement.length(); + if (speed <= 0.0D) { + return; + } + double accuracyBonus = PerkEffectService.playerBonus(player, PerkStat.RANGED_ACCURACY); + if (accuracyBonus > 0.0D) { + Vec3 ideal = player.getLookAngle().normalize().scale(speed); + movement = ideal.add(movement.subtract(ideal).scale(Math.max(0.0D, 1.0D - accuracyBonus))); + projectile.setDeltaMovement(movement); + } + float adjusted = PerkEffectService.playerRangedVelocityFor(player, (float) speed); + if (adjusted > speed) { + projectile.setDeltaMovement(movement.normalize().scale(adjusted)); + } + } +} diff --git a/src/main/resources/assets/bannermod/lang/en_us.json b/src/main/resources/assets/bannermod/lang/en_us.json index 79341013..28cd53cb 100644 --- a/src/main/resources/assets/bannermod/lang/en_us.json +++ b/src/main/resources/assets/bannermod/lang/en_us.json @@ -2596,6 +2596,20 @@ "perk.bannermod.universal.steady_aim_i.desc": "Tightens ranged accuracy by 5%.", "perk.bannermod.universal.strong_draw_i": "Strong Draw I", "perk.bannermod.universal.strong_draw_i.desc": "+5% projectile velocity.", + "perk.bannermod.player.toughness_i": "Player Toughness I", + "perk.bannermod.player.toughness_i.desc": "+2 max health for the player.", + "perk.bannermod.player.iron_skin_i": "Player Iron Skin I", + "perk.bannermod.player.iron_skin_i.desc": "+5% knockback resistance for the player.", + "perk.bannermod.player.weapon_training_i": "Player Weapon Training I", + "perk.bannermod.player.weapon_training_i.desc": "+0.25 melee attack damage for the player.", + "perk.bannermod.player.quick_hands_i": "Player Quick Hands I", + "perk.bannermod.player.quick_hands_i.desc": "+0.10 attack speed for the player.", + "perk.bannermod.player.marching_drill_i": "Player Marching Drill I", + "perk.bannermod.player.marching_drill_i.desc": "+0.01 movement speed for the player.", + "perk.bannermod.player.steady_aim_i": "Player Steady Aim I", + "perk.bannermod.player.steady_aim_i.desc": "Tightens player ranged accuracy by 5%.", + "perk.bannermod.player.strong_draw_i": "Player Strong Draw I", + "perk.bannermod.player.strong_draw_i.desc": "+5% player projectile velocity.", "perk.bannermod.swordsman.iron_grip_i": "Iron Grip I", "perk.bannermod.swordsman.iron_grip_i.desc": "+0.5 melee attack damage.", "perk.bannermod.bowman.steady_aim_i": "Steady Aim I", diff --git a/src/main/resources/assets/bannermod/lang/ru_ru.json b/src/main/resources/assets/bannermod/lang/ru_ru.json index 49f1aa73..a378aac5 100644 --- a/src/main/resources/assets/bannermod/lang/ru_ru.json +++ b/src/main/resources/assets/bannermod/lang/ru_ru.json @@ -2506,6 +2506,20 @@ "perk.bannermod.universal.steady_aim_i.desc": "Точность дальнего боя повышена на 5%.", "perk.bannermod.universal.strong_draw_i": "Сильная тетива I", "perk.bannermod.universal.strong_draw_i.desc": "+5% к скорости снарядов.", + "perk.bannermod.player.toughness_i": "Стойкость игрока I", + "perk.bannermod.player.toughness_i.desc": "+2 к максимальному здоровью игрока.", + "perk.bannermod.player.iron_skin_i": "Железная кожа игрока I", + "perk.bannermod.player.iron_skin_i.desc": "+5% к сопротивлению отбрасыванию игрока.", + "perk.bannermod.player.weapon_training_i": "Боевая подготовка игрока I", + "perk.bannermod.player.weapon_training_i.desc": "+0.25 к урону ближнего боя игрока.", + "perk.bannermod.player.quick_hands_i": "Быстрые руки игрока I", + "perk.bannermod.player.quick_hands_i.desc": "+0.10 к скорости атаки игрока.", + "perk.bannermod.player.marching_drill_i": "Маршевая выучка игрока I", + "perk.bannermod.player.marching_drill_i.desc": "+0.01 к скорости передвижения игрока.", + "perk.bannermod.player.steady_aim_i": "Меткость игрока I", + "perk.bannermod.player.steady_aim_i.desc": "Точность дальнего боя игрока повышена на 5%.", + "perk.bannermod.player.strong_draw_i": "Сильная тетива игрока I", + "perk.bannermod.player.strong_draw_i.desc": "+5% к скорости снарядов игрока.", "perk.bannermod.swordsman.iron_grip_i": "Железная хватка I", "perk.bannermod.swordsman.iron_grip_i.desc": "+0.5 к урону ближнего боя.", "perk.bannermod.bowman.steady_aim_i": "Твёрдый прицел I", diff --git a/src/main/resources/data/bannermod/perks/player_iron_skin_i.json b/src/main/resources/data/bannermod/perks/player_iron_skin_i.json new file mode 100644 index 00000000..29c3a84f --- /dev/null +++ b/src/main/resources/data/bannermod/perks/player_iron_skin_i.json @@ -0,0 +1,12 @@ +{ + "id": "player/iron_skin_i", + "archetype": "universal", + "point_cost": 1, + "prerequisites": [], + "bonuses": [ + { + "stat": "knockback_resist", + "amount": 0.05 + } + ] +} diff --git a/src/main/resources/data/bannermod/perks/player_marching_drill_i.json b/src/main/resources/data/bannermod/perks/player_marching_drill_i.json new file mode 100644 index 00000000..1477da85 --- /dev/null +++ b/src/main/resources/data/bannermod/perks/player_marching_drill_i.json @@ -0,0 +1,12 @@ +{ + "id": "player/marching_drill_i", + "archetype": "universal", + "point_cost": 1, + "prerequisites": [], + "bonuses": [ + { + "stat": "movement_speed", + "amount": 0.01 + } + ] +} diff --git a/src/main/resources/data/bannermod/perks/player_quick_hands_i.json b/src/main/resources/data/bannermod/perks/player_quick_hands_i.json new file mode 100644 index 00000000..e79a4f00 --- /dev/null +++ b/src/main/resources/data/bannermod/perks/player_quick_hands_i.json @@ -0,0 +1,12 @@ +{ + "id": "player/quick_hands_i", + "archetype": "universal", + "point_cost": 1, + "prerequisites": [], + "bonuses": [ + { + "stat": "attack_speed", + "amount": 0.10 + } + ] +} diff --git a/src/main/resources/data/bannermod/perks/player_steady_aim_i.json b/src/main/resources/data/bannermod/perks/player_steady_aim_i.json new file mode 100644 index 00000000..af686688 --- /dev/null +++ b/src/main/resources/data/bannermod/perks/player_steady_aim_i.json @@ -0,0 +1,12 @@ +{ + "id": "player/steady_aim_i", + "archetype": "universal", + "point_cost": 1, + "prerequisites": [], + "bonuses": [ + { + "stat": "ranged_accuracy", + "amount": 0.05 + } + ] +} diff --git a/src/main/resources/data/bannermod/perks/player_strong_draw_i.json b/src/main/resources/data/bannermod/perks/player_strong_draw_i.json new file mode 100644 index 00000000..1a8c6836 --- /dev/null +++ b/src/main/resources/data/bannermod/perks/player_strong_draw_i.json @@ -0,0 +1,12 @@ +{ + "id": "player/strong_draw_i", + "archetype": "universal", + "point_cost": 1, + "prerequisites": [], + "bonuses": [ + { + "stat": "ranged_velocity", + "amount": 0.05 + } + ] +} diff --git a/src/main/resources/data/bannermod/perks/player_toughness_i.json b/src/main/resources/data/bannermod/perks/player_toughness_i.json new file mode 100644 index 00000000..cf2e9deb --- /dev/null +++ b/src/main/resources/data/bannermod/perks/player_toughness_i.json @@ -0,0 +1,12 @@ +{ + "id": "player/toughness_i", + "archetype": "universal", + "point_cost": 1, + "prerequisites": [], + "bonuses": [ + { + "stat": "max_health", + "amount": 2.0 + } + ] +} diff --git a/src/main/resources/data/bannermod/perks/player_weapon_training_i.json b/src/main/resources/data/bannermod/perks/player_weapon_training_i.json new file mode 100644 index 00000000..6c382a91 --- /dev/null +++ b/src/main/resources/data/bannermod/perks/player_weapon_training_i.json @@ -0,0 +1,12 @@ +{ + "id": "player/weapon_training_i", + "archetype": "universal", + "point_cost": 1, + "prerequisites": [], + "bonuses": [ + { + "stat": "attack_damage", + "amount": 0.25 + } + ] +} From d814f6d42e9612cb5843b94db149efa57a033897 Mon Sep 17 00:00:00 2001 From: "pozdn.r.a" Date: Sat, 9 May 2026 16:09:28 +0700 Subject: [PATCH 4/7] backlog: close player skill tree task --- MULTIPLAYER_GUIDE_EN.md | 2 +- MULTIPLAYER_GUIDE_RU.md | 2 +- docs/BANNERMOD_ALMANAC.html | 4 ++-- docs/BANNERMOD_BACKLOG.json | 18 ++++++++++++++---- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/MULTIPLAYER_GUIDE_EN.md b/MULTIPLAYER_GUIDE_EN.md index a40a1670..3fe7e5b1 100644 --- a/MULTIPLAYER_GUIDE_EN.md +++ b/MULTIPLAYER_GUIDE_EN.md @@ -275,7 +275,7 @@ You can set per-unit stances from the recruit's inventory (`RecruitInventoryScre If a crossbowman is holding a musketmod gun, the recruit inventory now shows whether that firearm is supported, whether cartridges are present, or whether the gun is unsupported for recruit use. Check that feedback before assuming the recruit combat runtime is broken. -Recruit perks are server-side. Level-ups grant recruit perk points, and unlocked perks can add max health, knockback resistance, melee damage, attack speed, movement speed, ranged accuracy, or projectile velocity. Archetype perks apply only to the matching role: swordsman, bowman, crossbowman, pikeman/shieldman, or cavalry. +Recruit and player perks are server-side. Level-ups grant perk points, kill credit can add player perk progress, and unlocked perks can add max health, knockback resistance, melee damage, attack speed, movement speed, ranged accuracy, or projectile velocity. Recruit archetype perks apply only to the matching role: swordsman, bowman, crossbowman, pikeman/shieldman, or cavalry. ### Recruit command pipeline details diff --git a/MULTIPLAYER_GUIDE_RU.md b/MULTIPLAYER_GUIDE_RU.md index d9ab1242..1580b03e 100644 --- a/MULTIPLAYER_GUIDE_RU.md +++ b/MULTIPLAYER_GUIDE_RU.md @@ -295,7 +295,7 @@ BannerMod добавляет поселения, рабочих, армии, г Если арбалетчик держит огнестрел из musketmod, в его инвентаре теперь видно, поддерживается ли это оружие, есть ли патроны, или предмет вообще не поддерживается рекрутами. Сначала проверь эту строку, прежде чем считать, что боевая логика сломана. -Перки рекрутов считаются на сервере. За уровни рекрут получает очки перков, а открытые перки могут добавить максимум здоровья, сопротивление отбрасыванию, урон ближнего боя, скорость атаки, скорость движения, точность дальнего боя или скорость снарядов. Архетипные перки работают только для своей роли: мечник, лучник, арбалетчик, копейщик/щитоносец или кавалерия. +Перки рекрутов и игроков считаются на сервере. За уровни выдаются очки перков, kill-credit может добавлять прогресс перков игрока, а открытые перки могут дать максимум здоровья, сопротивление отбрасыванию, урон ближнего боя, скорость атаки, скорость движения, точность дальнего боя или скорость снарядов. Архетипные перки рекрутов работают только для своей роли: мечник, лучник, арбалетчик, копейщик/щитоносец или кавалерия. ## War Room (`U`) diff --git a/docs/BANNERMOD_ALMANAC.html b/docs/BANNERMOD_ALMANAC.html index 62554a6c..57a1e1c9 100644 --- a/docs/BANNERMOD_ALMANAC.html +++ b/docs/BANNERMOD_ALMANAC.html @@ -118,7 +118,7 @@

8. Recruits, Orders, And Combat

Recruits obey owner and group authority. All movement, facing, attack, aggression, ranged fire, stance, mount, and siege-machine commands flow through a server command intent pipeline. This preserves selection narrowing, queued orders, priorities, and audit logging.

The recruit command, recruit inventory, hiring, rename, promotion, and group-management screens now keep a visible status line. When an action is disabled, that line or the tooltip tells you the missing next step: choose a company or player, aim at ground or a unit, type a name, or save the company first.

If a crossbowman is holding a musketmod firearm, inspect that recruit's inventory screen for explicit firearm feedback. It now tells you whether the gun is recruit-supported, whether cartridges are present, or whether the weapon is unsupported and should not be expected to fire.

-

Recruit perks are server-side: level-ups grant perk points, universal perks can improve health, knockback resistance, melee damage, attack speed, movement, ranged accuracy, or projectile velocity, and archetype perks apply only to swordsmen, bowmen, crossbowmen, pikemen/shieldmen, or cavalry.

+

Recruit and player perks are server-side: level-ups grant perk points, player kill credit can add perk progress, universal perks can improve health, knockback resistance, melee damage, attack speed, movement, ranged accuracy, or projectile velocity, and recruit archetype perks apply only to swordsmen, bowmen, crossbowmen, pikemen/shieldmen, or cavalry.

Movement states

StateOrder
0Hold position.
1Follow owner.
2Regroup.
3Wander.
4Come to me.
5Patrol.
6Move to position.
7 / 8Formation forward / backward.

Stances and combat rules

@@ -260,7 +260,7 @@

Рост населения: рождения и порог еды

8. Рекруты, приказы и бой

Рекруты подчиняются владельцу и группе. Все приказы движения, поворота, атаки, поведения, дальнего огня, строя, посадки и осадных машин проходят через единый серверный путь приказов. Это сохраняет выбор бойцов, очередь, приоритет и журнал команд.

-

Перки рекрутов считаются на сервере: уровни дают очки перков, универсальные перки улучшают здоровье, сопротивление отбрасыванию, урон ближнего боя, скорость атаки, движение, точность дальнего боя или скорость снарядов, а архетипные перки работают только для мечников, лучников, арбалетчиков, копейщиков/щитоносцев или кавалерии.

+

Перки рекрутов и игроков считаются на сервере: уровни дают очки перков, kill-credit игрока может добавлять прогресс, универсальные перки улучшают здоровье, сопротивление отбрасыванию, урон ближнего боя, скорость атаки, движение, точность дальнего боя или скорость снарядов, а архетипные перки рекрутов работают только для мечников, лучников, арбалетчиков, копейщиков/щитоносцев или кавалерии.

Состояния движения

КодПриказ
0Держать место.
1Следовать за владельцем.
2Собраться.
3Бродить.
4Ко мне.
5Патруль.
6Идти в точку.
7 / 8Строй вперёд / назад.

Стойки и правила боя

diff --git a/docs/BANNERMOD_BACKLOG.json b/docs/BANNERMOD_BACKLOG.json index 28482bba..fca7e346 100644 --- a/docs/BANNERMOD_BACKLOG.json +++ b/docs/BANNERMOD_BACKLOG.json @@ -8011,8 +8011,8 @@ { "id": "SKILLTREE-004", "title": "Player skill tree perks + combat hooks", - "status": "open", - "updated": "2026-05-04", + "status": "done", + "updated": "2026-05-09", "why": "SKILLTREE-001 phase plan step 3: player-side perks must use the SKILLTREE-002 AttachmentType and observably affect the player when unlocked. Without this the system is recruit-only.", "scope": [ "Author the player perk catalog (general-stat: HP, KB-resist, attack damage, attack speed, movement, ranged accuracy, ranged velocity) using the SKILLTREE-002 model.", @@ -8031,8 +8031,18 @@ "SKILLTREE-002" ], "progress": [], - "verification": [], - "evidence": [] + "verification": [ + { + "date": "2026-05-09", + "result": "1) ./gradlew runGameTestServer passed under JDK 21, including BannerModPlayerSkillTreeCombatGameTests for player HP, knockback resistance, attack damage, attack speed, movement, ranged accuracy, and captured projectile velocity deltas. 2) BannerModSkillTreePersistenceGameTests passed with player perk save/load plus reload into a dimension target. 3) PlayerPerkEvents LevelChange and kill-credit GameTest coverage proved one point per gained level/kill credit and respec refunded the cost-1 player perk. 4) PerkCatalogContractTest passed for en_us/ru_ru keys for every authored player perk; runGameTestServer completed without missing-key failures. 5) tools/backlog validate passed and ./gradlew compileJava passed under JDK 21." + }, + { + "date": "2026-05-09", + "result": "1) compileJava, compileTestJava, compileGameTestJava, and PerkCatalogContractTest passed under JDK 21 after final changes. 2) runGameTestServer executed after final changes; all SKILLTREE player/recruit GameTests passed, including player stat/ranged projectile deltas, earning/respec, and player perk save/load plus server-level dimension transfer/reload; the only remaining failure was unrelatedclaimstateispreservedwhensiblingclaimisdeleted outside this task. 3) en_us/ru_ru localization coverage passed via PerkCatalogContractTest; the GameTest log showed no missing-key failures. 4) tools/backlog validate passed." + } + ], + "evidence": [], + "doneDate": "2026-05-09" }, { "id": "SKILLTREE-005", From da28918e078f74d9a7dea2051f59c01a78cb5adc Mon Sep 17 00:00:00 2001 From: "pozdn.r.a" Date: Sat, 9 May 2026 20:49:48 +0700 Subject: [PATCH 5/7] add skill tree screens --- MULTIPLAYER_GUIDE_EN.md | 2 +- MULTIPLAYER_GUIDE_RU.md | 4 +- docs/BANNERMOD_ALMANAC.html | 4 +- docs/BANNERMOD_BACKLOG.json | 37 ++- .../messages/PacketAuthorityGameTests.java | 60 +++++ .../client/military/ClientManager.java | 12 + .../client/military/events/KeyEvents.java | 5 + .../client/military/gui/PerkTreeScreen.java | 229 ++++++++++++++++++ .../military/gui/RecruitInventoryScreen.java | 6 + .../catalog/MilitaryPacketCatalog.java | 3 + .../MessageRequestPerkTreeSnapshot.java | 77 ++++++ ...MessageToClientUpdatePerkTreeSnapshot.java | 83 +++++++ .../military/MessageUpdatePerkTree.java | 129 ++++++++++ .../registry/military/ModShortcuts.java | 3 + .../assets/bannermod/lang/en_us.json | 26 +- .../assets/bannermod/lang/ru_ru.json | 26 +- .../gui/CriticalUiStateVerificationTest.java | 39 +++ 17 files changed, 733 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/talhanation/bannermod/client/military/gui/PerkTreeScreen.java create mode 100644 src/main/java/com/talhanation/bannermod/network/messages/military/MessageRequestPerkTreeSnapshot.java create mode 100644 src/main/java/com/talhanation/bannermod/network/messages/military/MessageToClientUpdatePerkTreeSnapshot.java create mode 100644 src/main/java/com/talhanation/bannermod/network/messages/military/MessageUpdatePerkTree.java diff --git a/MULTIPLAYER_GUIDE_EN.md b/MULTIPLAYER_GUIDE_EN.md index 3fe7e5b1..36fe5d0f 100644 --- a/MULTIPLAYER_GUIDE_EN.md +++ b/MULTIPLAYER_GUIDE_EN.md @@ -275,7 +275,7 @@ You can set per-unit stances from the recruit's inventory (`RecruitInventoryScre If a crossbowman is holding a musketmod gun, the recruit inventory now shows whether that firearm is supported, whether cartridges are present, or whether the gun is unsupported for recruit use. Check that feedback before assuming the recruit combat runtime is broken. -Recruit and player perks are server-side. Level-ups grant perk points, kill credit can add player perk progress, and unlocked perks can add max health, knockback resistance, melee damage, attack speed, movement speed, ranged accuracy, or projectile velocity. Recruit archetype perks apply only to the matching role: swordsman, bowman, crossbowman, pikeman/shieldman, or cavalry. +Recruit and player perks are server-side. Level-ups grant perk points, kill credit can add player perk progress, and unlocked perks can add max health, knockback resistance, melee damage, attack speed, movement speed, ranged accuracy, or projectile velocity. Recruit archetype perks apply only to the matching role: swordsman, bowman, crossbowman, pikeman/shieldman, or cavalry. Open a recruit's parchment perk tree from the recruit inventory `Perks` button; open your own parchment skill tree with the `Open Player Skill Tree` keybind (`K` by default). Locked, available, and owned states are shown in the tree. Unlock and respec requests are sent to the server, which validates ownership, points, and prerequisites before sending the refreshed tree back. ### Recruit command pipeline details diff --git a/MULTIPLAYER_GUIDE_RU.md b/MULTIPLAYER_GUIDE_RU.md index 1580b03e..69387bd7 100644 --- a/MULTIPLAYER_GUIDE_RU.md +++ b/MULTIPLAYER_GUIDE_RU.md @@ -293,9 +293,9 @@ BannerMod добавляет поселения, рабочих, армии, г Можно ставить персональные стойки через инвентарь конкретного рекрута (`RecruitInventoryScreen`), а групповые — через командный экран. Приказы рекрутам теперь дают подтверждение в чате: сколько рекрутов приняли немедленный приказ, почему пустая/недоступная выборка отклонена, были ли заменены queued-приказы, и сколько приказов осталось в очереди для queued-пути. -Если арбалетчик держит огнестрел из musketmod, в его инвентаре теперь видно, поддерживается ли это оружие, есть ли патроны, или предмет вообще не поддерживается рекрутами. Сначала проверь эту строку, прежде чем считать, что боевая логика сломана. +Перки рекрутов и игроков считаются на сервере. Уровни дают очки перков, kill-credit может добавлять прогресс игроку, а изученные перки улучшают здоровье, сопротивление отбрасыванию, урон ближнего боя, скорость атаки, движение, точность дальнего боя или скорость снарядов. Архетипные перки рекрутов работают только для своей роли: мечник, лучник, арбалетчик, копейщик/щитоносец или кавалерия. Дерево перков рекрута открывается кнопкой `Перки` в инвентаре рекрута; дерево навыков игрока открывается биндингом `Открыть дерево навыков игрока` (`K` по умолчанию). В дереве видны закрытые, доступные и изученные состояния. Изучение и сброс отправляются на сервер, который проверяет владельца, очки и требования, а затем присылает свежий снимок дерева. -Перки рекрутов и игроков считаются на сервере. За уровни выдаются очки перков, kill-credit может добавлять прогресс перков игрока, а открытые перки могут дать максимум здоровья, сопротивление отбрасыванию, урон ближнего боя, скорость атаки, скорость движения, точность дальнего боя или скорость снарядов. Архетипные перки рекрутов работают только для своей роли: мечник, лучник, арбалетчик, копейщик/щитоносец или кавалерия. +Если арбалетчик держит огнестрел из musketmod, в его инвентаре теперь видно, поддерживается ли это оружие, есть ли патроны, или предмет вообще не поддерживается рекрутами. Сначала проверь эту строку, прежде чем считать, что боевая логика сломана. ## War Room (`U`) diff --git a/docs/BANNERMOD_ALMANAC.html b/docs/BANNERMOD_ALMANAC.html index 57a1e1c9..a4d67a5a 100644 --- a/docs/BANNERMOD_ALMANAC.html +++ b/docs/BANNERMOD_ALMANAC.html @@ -118,7 +118,7 @@

8. Recruits, Orders, And Combat

Recruits obey owner and group authority. All movement, facing, attack, aggression, ranged fire, stance, mount, and siege-machine commands flow through a server command intent pipeline. This preserves selection narrowing, queued orders, priorities, and audit logging.

The recruit command, recruit inventory, hiring, rename, promotion, and group-management screens now keep a visible status line. When an action is disabled, that line or the tooltip tells you the missing next step: choose a company or player, aim at ground or a unit, type a name, or save the company first.

If a crossbowman is holding a musketmod firearm, inspect that recruit's inventory screen for explicit firearm feedback. It now tells you whether the gun is recruit-supported, whether cartridges are present, or whether the weapon is unsupported and should not be expected to fire.

-

Recruit and player perks are server-side: level-ups grant perk points, player kill credit can add perk progress, universal perks can improve health, knockback resistance, melee damage, attack speed, movement, ranged accuracy, or projectile velocity, and recruit archetype perks apply only to swordsmen, bowmen, crossbowmen, pikemen/shieldmen, or cavalry.

+

Recruit and player perks are server-side: level-ups grant perk points, player kill credit can add perk progress, universal perks can improve health, knockback resistance, melee damage, attack speed, movement, ranged accuracy, or projectile velocity, and recruit archetype perks apply only to swordsmen, bowmen, crossbowmen, pikemen/shieldmen, or cavalry. Open recruit perks from the recruit inventory Perks button; open player skills with K by default. Unlock and respec actions are validated on the server before the refreshed tree returns to the client.

Movement states

StateOrder
0Hold position.
1Follow owner.
2Regroup.
3Wander.
4Come to me.
5Patrol.
6Move to position.
7 / 8Formation forward / backward.

Stances and combat rules

@@ -260,7 +260,7 @@

Рост населения: рождения и порог еды

8. Рекруты, приказы и бой

Рекруты подчиняются владельцу и группе. Все приказы движения, поворота, атаки, поведения, дальнего огня, строя, посадки и осадных машин проходят через единый серверный путь приказов. Это сохраняет выбор бойцов, очередь, приоритет и журнал команд.

-

Перки рекрутов и игроков считаются на сервере: уровни дают очки перков, kill-credit игрока может добавлять прогресс, универсальные перки улучшают здоровье, сопротивление отбрасыванию, урон ближнего боя, скорость атаки, движение, точность дальнего боя или скорость снарядов, а архетипные перки рекрутов работают только для мечников, лучников, арбалетчиков, копейщиков/щитоносцев или кавалерии.

+

Перки рекрутов и игроков считаются на сервере: уровни дают очки перков, kill-credit игрока может добавлять прогресс, универсальные перки улучшают здоровье, сопротивление отбрасыванию, урон ближнего боя, скорость атаки, движение, точность дальнего боя или скорость снарядов, а архетипные перки рекрутов работают только для мечников, лучников, арбалетчиков, копейщиков/щитоносцев или кавалерии. Дерево рекрута открывается кнопкой Перки в инвентаре, дерево игрока — клавишей K по умолчанию. Изучение и сброс проверяются сервером, затем клиент получает свежий снимок дерева.

Состояния движения

КодПриказ
0Держать место.
1Следовать за владельцем.
2Собраться.
3Бродить.
4Ко мне.
5Патруль.
6Идти в точку.
7 / 8Строй вперёд / назад.

Стойки и правила боя

diff --git a/docs/BANNERMOD_BACKLOG.json b/docs/BANNERMOD_BACKLOG.json index fca7e346..4acfe310 100644 --- a/docs/BANNERMOD_BACKLOG.json +++ b/docs/BANNERMOD_BACKLOG.json @@ -8047,8 +8047,8 @@ { "id": "SKILLTREE-005", "title": "Recruit + player skill-tree GUI screens", - "status": "open", - "updated": "2026-05-04", + "status": "in_progress", + "updated": "2026-05-09", "why": "SKILLTREE-001 phase plan step 4: the perk system is invisible to players without parchment-style GUI screens, locked/available/owned states, and a player key-binding to open the player tree.", "scope": [ "Add a parchment-style recruit perk-tree screen invoked from the existing RecruitProfileScreen with locked/available/owned visual states and a click-to-unlock flow gated by skill-points and prerequisites.", @@ -8067,10 +8067,14 @@ "tools/backlog validate passes; ./gradlew compileJava is green." ], "dependencies": [ - "SKILLTREE-003", - "SKILLTREE-004" + "SKILLTREE-005A" + ], + "progress": [ + { + "date": "2026-05-09", + "text": "Implemented recruit/player parchment skill-tree screens, keybinding, server-authoritative snapshot/unlock/respec packets, localized strings, guides/almanac updates, source UI contract coverage, and packet-path GameTest coverage. Verified compileJava, compileTestJava, compileGameTestJava, focused CriticalUiStateVerificationTest, verifyGameTestStage, code-simplifier no-op, and code-reviewer no blocker/medium findings. Remaining acceptance moved to SKILLTREE-005A: live manual screenshot/overlap validation at 1080p and 1440p." + } ], - "progress": [], "verification": [], "evidence": [] }, @@ -10010,6 +10014,29 @@ ], "evidence": [], "doneDate": "2026-05-08" + }, + { + "id": "SKILLTREE-005A", + "title": "Manual skill-tree UI overlap validation", + "status": "open", + "updated": "2026-05-09", + "why": "SKILLTREE-005 includes a manual screenshot/overlap acceptance check that cannot be honestly completed from the headless coding session.", + "scope": [ + "Run a Minecraft client with the SKILLTREE-005 implementation and open the recruit perk tree from recruit inventory.", + "Open the player skill tree through the new player skill-tree keybinding.", + "Capture or record manual UI notes for 1080p and 1440p showing no overlap with hotbar, chat, crosshair, boss bars, or existing BannerMod overlays.", + "Verify the visible locked, available, owned, pending, denied, unlock, and respec-confirmation states in the live UI; if defects are found, create concrete follow-up fix tasks instead of closing this validation task." + ], + "acceptance": [ + "Recruit perk tree opens from recruit inventory and live screenshots/notes show locked, available, and owned states at 1080p and 1440p without overlap.", + "Player skill tree opens from the keybinding and live screenshots/notes show locked, available, and owned states at 1080p and 1440p without overlap.", + "Live unlock, insufficient-points denial, and respec confirmation feedback are visible and do not obscure required decision context.", + "tools/backlog validate passes after recording the verification result." + ], + "dependencies": [], + "progress": [], + "verification": [], + "evidence": [] } ] } diff --git a/src/gametest/java/com/talhanation/bannermod/network/messages/PacketAuthorityGameTests.java b/src/gametest/java/com/talhanation/bannermod/network/messages/PacketAuthorityGameTests.java index ebf57fdd..f1517a41 100644 --- a/src/gametest/java/com/talhanation/bannermod/network/messages/PacketAuthorityGameTests.java +++ b/src/gametest/java/com/talhanation/bannermod/network/messages/PacketAuthorityGameTests.java @@ -6,6 +6,7 @@ import com.talhanation.bannermod.bootstrap.BannerModMain; import com.talhanation.bannermod.entity.civilian.workarea.StorageArea; import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; +import com.talhanation.bannermod.entity.military.perks.PlayerPerkProgressService; import com.talhanation.bannermod.entity.military.runtime.RecruitEvents; import com.talhanation.bannermod.gametest.support.PacketGameTestSupport; import com.talhanation.bannermod.gametest.support.RecruitsBattleGameTestSupport; @@ -13,6 +14,7 @@ import com.talhanation.bannermod.network.messages.military.MessageAssignGroupToPlayer; import com.talhanation.bannermod.network.messages.military.MessageAssignRecruitToPlayer; import com.talhanation.bannermod.network.messages.military.MessageTeleportPlayer; +import com.talhanation.bannermod.network.messages.military.MessageUpdatePerkTree; import com.talhanation.bannermod.persistence.military.RecruitsGroup; import com.talhanation.bannermod.persistence.military.RecruitsPlayerInfo; import com.talhanation.bannermod.registry.military.ModEntityTypes; @@ -150,6 +152,64 @@ public static void workAreaOwnerPacketPathRejectsOutsiderAndAllowsOwner(GameTest }); } + @PrefixGameTestTemplate(false) + @GameTest(template = "harness_empty") + public static void perkTreeUpdatePacketRejectsUnauthorizedAndAppliesServerMutations(GameTestHelper helper) { + ServerLevel level = helper.getLevel(); + ServerPlayer owner = createPlayer(helper, level, OWNER_UUID, "packet-perk-owner"); + ServerPlayer outsider = createPlayer(helper, level, OUTSIDER_UUID, "packet-perk-outsider"); + AbstractRecruitEntity recruit = spawnOwnedRecruit(helper, OWNER_UUID, "Packet Perk Recruit"); + String recruitPerk = "universal/toughness_i"; + String playerPerk = "player/toughness_i"; + + PacketGameTestSupport.dispatchServerbound(outsider, + new MessageUpdatePerkTree(false, recruit.getUUID(), MessageUpdatePerkTree.ACTION_UNLOCK, recruitPerk), + MessageUpdatePerkTree::new); + helper.assertTrue(!recruit.getPerkProgress().isOwned(recruitPerk), + "Expected outsider recruit perk packet to leave the perk locked"); + + PacketGameTestSupport.dispatchServerbound(owner, + new MessageUpdatePerkTree(false, recruit.getUUID(), MessageUpdatePerkTree.ACTION_UNLOCK, recruitPerk), + MessageUpdatePerkTree::new); + helper.assertTrue(!recruit.getPerkProgress().isOwned(recruitPerk), + "Expected insufficient-points recruit perk packet to leave the perk locked"); + + recruit.getPerkProgress().grantPoints(1); + PacketGameTestSupport.dispatchServerbound(owner, + new MessageUpdatePerkTree(false, recruit.getUUID(), MessageUpdatePerkTree.ACTION_UNLOCK, recruitPerk), + MessageUpdatePerkTree::new); + helper.assertTrue(recruit.getPerkProgress().isOwned(recruitPerk), + "Expected owner recruit perk packet with points to unlock the perk"); + + PacketGameTestSupport.dispatchServerbound(owner, + new MessageUpdatePerkTree(false, recruit.getUUID(), MessageUpdatePerkTree.ACTION_RESPEC, ""), + MessageUpdatePerkTree::new); + helper.assertTrue(!recruit.getPerkProgress().isOwned(recruitPerk) + && recruit.getPerkProgress().getAvailablePoints() == 1, + "Expected recruit respec packet to clear the perk and refund its point"); + + PacketGameTestSupport.dispatchServerbound(owner, + new MessageUpdatePerkTree(true, null, MessageUpdatePerkTree.ACTION_UNLOCK, playerPerk), + MessageUpdatePerkTree::new); + helper.assertTrue(!PlayerPerkProgressService.progress(owner).isOwned(playerPerk), + "Expected insufficient-points player perk packet to leave the perk locked"); + + PlayerPerkProgressService.progress(owner).grantPoints(1); + PacketGameTestSupport.dispatchServerbound(owner, + new MessageUpdatePerkTree(true, null, MessageUpdatePerkTree.ACTION_UNLOCK, playerPerk), + MessageUpdatePerkTree::new); + helper.assertTrue(PlayerPerkProgressService.progress(owner).isOwned(playerPerk), + "Expected player perk packet with points to unlock the perk"); + + PacketGameTestSupport.dispatchServerbound(owner, + new MessageUpdatePerkTree(true, null, MessageUpdatePerkTree.ACTION_RESPEC, ""), + MessageUpdatePerkTree::new); + helper.assertTrue(!PlayerPerkProgressService.progress(owner).isOwned(playerPerk) + && PlayerPerkProgressService.progress(owner).getAvailablePoints() == 1, + "Expected player respec packet to clear the perk and refund its point"); + helper.succeed(); + } + private static ServerPlayer createPlayer(GameTestHelper helper, ServerLevel level, UUID playerId, String name) { ServerPlayer player = (ServerPlayer) BannerModDedicatedServerGameTestSupport.createFakeServerPlayer(level, playerId, name); BlockPos pos = helper.absolutePos(RecruitsBattleGameTestSupport.SquadAnchor.WEST.anchor()); diff --git a/src/main/java/com/talhanation/bannermod/client/military/ClientManager.java b/src/main/java/com/talhanation/bannermod/client/military/ClientManager.java index ea213777..c6ec7b92 100644 --- a/src/main/java/com/talhanation/bannermod/client/military/ClientManager.java +++ b/src/main/java/com/talhanation/bannermod/client/military/ClientManager.java @@ -1,9 +1,11 @@ package com.talhanation.bannermod.client.military; import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; +import com.talhanation.bannermod.entity.military.perks.PerkProgress; import com.talhanation.bannermod.army.map.FormationMapContact; import com.talhanation.bannermod.persistence.military.*; import net.minecraft.client.Minecraft; +import net.minecraft.network.chat.Component; import net.minecraft.world.entity.player.Player; import net.minecraft.world.item.ItemStack; import net.minecraft.world.item.Items; @@ -30,6 +32,11 @@ public class ClientManager { public static int groupsVersion; public static List formationMapContacts = new ArrayList<>(); public static int formationMapContactsVersion; + public static PerkProgress playerPerkSnapshot = new PerkProgress(); + public static UUID recruitPerkSnapshotUuid; + public static PerkProgress recruitPerkSnapshot = new PerkProgress(); + public static Component perkTreeFeedback = Component.empty(); + public static int perkTreeSnapshotVersion; public static int configValueClaimCost; public static int configValueChunkCost; public static boolean configValueCascadeClaimCost; @@ -64,6 +71,11 @@ public static void resetSynchronizedState() { groupsVersion++; formationMapContacts = new ArrayList<>(); formationMapContactsVersion++; + playerPerkSnapshot = new PerkProgress(); + recruitPerkSnapshotUuid = null; + recruitPerkSnapshot = new PerkProgress(); + perkTreeFeedback = Component.empty(); + perkTreeSnapshotVersion++; configValueClaimCost = 0; configValueChunkCost = 0; configValueCascadeClaimCost = false; diff --git a/src/main/java/com/talhanation/bannermod/client/military/events/KeyEvents.java b/src/main/java/com/talhanation/bannermod/client/military/events/KeyEvents.java index 7413dd5a..a85c0260 100644 --- a/src/main/java/com/talhanation/bannermod/client/military/events/KeyEvents.java +++ b/src/main/java/com/talhanation/bannermod/client/military/events/KeyEvents.java @@ -6,6 +6,7 @@ import com.talhanation.bannermod.client.civilian.input.AssignHomeTargetSelector; import com.talhanation.bannermod.client.civilian.render.WorkerAreaRenderer; import com.talhanation.bannermod.client.military.gui.war.WarListScreen; +import com.talhanation.bannermod.client.military.gui.PerkTreeScreen; import com.talhanation.bannermod.client.military.gui.worldmap.WorldMapScreen; import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; import com.talhanation.bannermod.registry.military.ModShortcuts; @@ -60,6 +61,10 @@ public void onKeyInput(InputEvent.Key event) { minecraft.setScreen(new WarListScreen(null)); } + if (ModShortcuts.PLAYER_SKILL_TREE_KEY != null && ModShortcuts.PLAYER_SKILL_TREE_KEY.consumeClick()) { + minecraft.setScreen(PerkTreeScreen.playerTree()); + } + if (com.talhanation.bannermod.registry.civilian.ModShortcuts.TOGGLE_PREFAB_RENDER_KEY != null && com.talhanation.bannermod.registry.civilian.ModShortcuts.TOGGLE_PREFAB_RENDER_KEY.consumeClick()) { boolean enabled = WorkerAreaRenderer.toggleStructurePreviewRendering(); diff --git a/src/main/java/com/talhanation/bannermod/client/military/gui/PerkTreeScreen.java b/src/main/java/com/talhanation/bannermod/client/military/gui/PerkTreeScreen.java new file mode 100644 index 00000000..39cd66a8 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/client/military/gui/PerkTreeScreen.java @@ -0,0 +1,229 @@ +package com.talhanation.bannermod.client.military.gui; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.client.military.ClientManager; +import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; +import com.talhanation.bannermod.entity.military.perks.PerkArchetype; +import com.talhanation.bannermod.entity.military.perks.PerkEffectService; +import com.talhanation.bannermod.entity.military.perks.PerkNode; +import com.talhanation.bannermod.entity.military.perks.PerkProgress; +import com.talhanation.bannermod.entity.military.perks.PerkRegistry; +import com.talhanation.bannermod.network.messages.military.MessageRequestPerkTreeSnapshot; +import com.talhanation.bannermod.network.messages.military.MessageUpdatePerkTree; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.MutableComponent; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; +import net.neoforged.neoforge.client.gui.widget.ExtendedButton; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + +@OnlyIn(Dist.CLIENT) +public class PerkTreeScreen extends RecruitsScreenBase { + private static final MutableComponent TITLE_PLAYER = Component.translatable("gui.bannermod.perk_tree.player.title"); + private static final MutableComponent TITLE_RECRUIT = Component.translatable("gui.bannermod.perk_tree.recruit.title"); + private static final MutableComponent TEXT_LOCKED = Component.translatable("gui.bannermod.perk_tree.state.locked"); + private static final MutableComponent TEXT_AVAILABLE = Component.translatable("gui.bannermod.perk_tree.state.available"); + private static final MutableComponent TEXT_OWNED = Component.translatable("gui.bannermod.perk_tree.state.owned"); + private static final MutableComponent TEXT_UNLOCK = Component.translatable("gui.bannermod.perk_tree.unlock"); + private static final MutableComponent TEXT_RESPEC = Component.translatable("gui.bannermod.perk_tree.respec"); + private static final MutableComponent TEXT_CONFIRM_RESPEC_BUTTON = Component.translatable("gui.bannermod.perk_tree.respec.confirm_button"); + private static final MutableComponent TEXT_BACK = Component.translatable("gui.recruits.button.back"); + private static final MutableComponent TEXT_CONFIRM_RESPEC = Component.translatable("gui.bannermod.perk_tree.respec.confirm"); + private static final MutableComponent TEXT_WAITING = Component.translatable("gui.bannermod.perk_tree.waiting_sync"); + private static final MutableComponent TEXT_NO_PERKS = Component.translatable("gui.bannermod.perk_tree.empty"); + + private final boolean playerTree; + @Nullable + private final AbstractRecruitEntity recruit; + private int seenSnapshotVersion; + private boolean snapshotReady; + private boolean confirmingRespec; + private boolean requestedSnapshot; + + public static PerkTreeScreen playerTree() { + return new PerkTreeScreen(true, null); + } + + public static PerkTreeScreen recruitTree(AbstractRecruitEntity recruit) { + return new PerkTreeScreen(false, recruit); + } + + private PerkTreeScreen(boolean playerTree, @Nullable AbstractRecruitEntity recruit) { + super(playerTree ? TITLE_PLAYER : TITLE_RECRUIT, 320, 260); + this.playerTree = playerTree; + this.recruit = recruit; + } + + @Override + protected void init() { + super.init(); + if (!requestedSnapshot) { + requestedSnapshot = true; + BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageRequestPerkTreeSnapshot(playerTree, recruitUuid())); + } + PerkProgress progress = progress(); + addRenderableWidget(new ExtendedButton(guiLeft + 10, guiTop + ySize - 28, 70, 20, TEXT_BACK, button -> onClose())); + Button respecButton = addRenderableWidget(new ExtendedButton(guiLeft + xSize - 106, guiTop + ySize - 28, 96, 20, + confirmingRespec ? TEXT_CONFIRM_RESPEC_BUTTON : TEXT_RESPEC, button -> confirmRespec())); + respecButton.active = progress != null; + if (progress == null) return; + + int y = guiTop + 48; + for (PerkNode node : visibleNodes()) { + PerkState state = stateFor(node, progress); + Button unlock = new ExtendedButton(guiLeft + xSize - 84, y + 3, 66, 18, TEXT_UNLOCK, + button -> sendUpdate(MessageUpdatePerkTree.ACTION_UNLOCK, node.id())); + unlock.active = state == PerkState.AVAILABLE; + addRenderableWidget(unlock); + y += 22; + } + } + + @Override + public void renderBackground(GuiGraphics guiGraphics, int mouseX, int mouseY, float delta) { + MilitaryGuiStyle.parchmentPanel(guiGraphics, guiLeft, guiTop, xSize, ySize); + MilitaryGuiStyle.titleStrip(guiGraphics, guiLeft + 8, guiTop + 6, xSize - 16, 16); + MilitaryGuiStyle.parchmentInset(guiGraphics, guiLeft + 10, guiTop + 30, xSize - 20, ySize - 66); + } + + @Override + public void renderForeground(GuiGraphics guiGraphics, int mouseX, int mouseY, float delta) { + if (seenSnapshotVersion != ClientManager.perkTreeSnapshotVersion) { + seenSnapshotVersion = ClientManager.perkTreeSnapshotVersion; + rebuildWidgets(); + } + + MilitaryGuiStyle.drawCenteredTitle(guiGraphics, font, title, guiLeft, guiTop + 10, xSize); + PerkProgress progress = progress(); + Object points = progress == null ? "..." : progress.getAvailablePoints(); + guiGraphics.drawString(font, Component.translatable("gui.bannermod.perk_tree.points", points), guiLeft + 16, guiTop + 36, MilitaryGuiStyle.TEXT_DARK, false); + Component feedback = confirmingRespec ? TEXT_CONFIRM_RESPEC : ClientManager.perkTreeFeedback; + guiGraphics.drawString(font, MilitaryGuiStyle.clampLabel(font, feedback, 170), guiLeft + 110, guiTop + 36, feedbackColor(), false); + + List nodes = visibleNodes(); + if (nodes.isEmpty()) { + guiGraphics.drawString(font, TEXT_NO_PERKS, guiLeft + 18, guiTop + 58, MilitaryGuiStyle.TEXT_DARK, false); + return; + } + if (progress == null) { + guiGraphics.drawString(font, TEXT_WAITING, guiLeft + 18, guiTop + 58, MilitaryGuiStyle.TEXT_DARK, false); + return; + } + + int y = guiTop + 50; + for (PerkNode node : nodes) { + renderNode(guiGraphics, node, progress, y); + y += 22; + } + } + + @Override + public void onClose() { + Minecraft.getInstance().setScreen(null); + } + + public void onSnapshot(boolean playerTree, @Nullable UUID recruitUuid, boolean hasProgress) { + if (this.playerTree != playerTree) return; + if (!playerTree && (this.recruit == null || !this.recruit.getUUID().equals(recruitUuid))) return; + this.snapshotReady = hasProgress; + this.seenSnapshotVersion = -1; + } + + private void renderNode(GuiGraphics guiGraphics, PerkNode node, PerkProgress progress, int y) { + PerkState state = stateFor(node, progress); + int color = state.color; + guiGraphics.fill(guiLeft + 16, y, guiLeft + xSize - 92, y + 20, state.background); + guiGraphics.renderOutline(guiLeft + 16, y, xSize - 108, 20, color | 0xFF000000); + guiGraphics.drawString(font, MilitaryGuiStyle.clampLabel(font, Component.translatable(node.localizationKey()), 130), guiLeft + 22, y + 3, color, false); + guiGraphics.drawString(font, state.label, guiLeft + 160, y + 3, color, false); + guiGraphics.drawString(font, Component.literal(String.valueOf(node.pointCost())), guiLeft + 224, y + 3, MilitaryGuiStyle.TEXT_DARK, false); + } + + private void confirmRespec() { + if (!confirmingRespec) { + confirmingRespec = true; + rebuildWidgets(); + return; + } + confirmingRespec = false; + sendUpdate(MessageUpdatePerkTree.ACTION_RESPEC, ""); + } + + private void sendUpdate(String action, String perkId) { + confirmingRespec = false; + ClientManager.perkTreeFeedback = Component.translatable("gui.bannermod.perk_tree.pending"); + BannerModMain.SIMPLE_CHANNEL.sendToServer(new MessageUpdatePerkTree(playerTree, recruitUuid(), action, perkId)); + } + + @Nullable + private UUID recruitUuid() { + return recruit == null ? null : recruit.getUUID(); + } + + @Nullable + private PerkProgress progress() { + if (!snapshotReady) return null; + if (playerTree) { + return ClientManager.playerPerkSnapshot; + } + if (recruit != null && recruit.getUUID().equals(ClientManager.recruitPerkSnapshotUuid)) { + return ClientManager.recruitPerkSnapshot; + } + return null; + } + + private List visibleNodes() { + List nodes = new ArrayList<>(); + if (playerTree) { + for (PerkNode node : PerkRegistry.byArchetype(PerkArchetype.UNIVERSAL)) { + if (node.id().startsWith("player/")) nodes.add(node); + } + } else if (recruit != null) { + PerkArchetype archetype = PerkEffectService.recruitArchetype(recruit); + for (PerkNode node : PerkRegistry.byArchetype(PerkArchetype.UNIVERSAL)) { + if (!node.id().startsWith("player/")) nodes.add(node); + } + nodes.addAll(PerkRegistry.byArchetype(archetype)); + } + nodes.sort(Comparator.comparing(PerkNode::id)); + return nodes; + } + + private static PerkState stateFor(PerkNode node, PerkProgress progress) { + if (progress.isOwned(node.id())) return PerkState.OWNED; + if (node.prerequisitesMet(progress.getOwnedPerks()) && progress.getAvailablePoints() >= node.pointCost()) { + return PerkState.AVAILABLE; + } + return PerkState.LOCKED; + } + + private static int feedbackColor() { + String text = ClientManager.perkTreeFeedback.getString(); + if (text.isEmpty()) return MilitaryGuiStyle.TEXT_DARK; + return text.contains("denied") || text.contains("отклон") ? MilitaryGuiStyle.TEXT_DENIED : MilitaryGuiStyle.TEXT_GOOD; + } + + private enum PerkState { + LOCKED(TEXT_LOCKED, MilitaryGuiStyle.TEXT_MUTED, 0x60301810), + AVAILABLE(TEXT_AVAILABLE, MilitaryGuiStyle.TEXT_WARN, 0x70D7B98C), + OWNED(TEXT_OWNED, MilitaryGuiStyle.TEXT_GOOD, 0x70304C22); + + final Component label; + final int color; + final int background; + + PerkState(Component label, int color, int background) { + this.label = label; + this.color = color; + this.background = background; + } + } +} diff --git a/src/main/java/com/talhanation/bannermod/client/military/gui/RecruitInventoryScreen.java b/src/main/java/com/talhanation/bannermod/client/military/gui/RecruitInventoryScreen.java index 82f527b8..f84321d5 100644 --- a/src/main/java/com/talhanation/bannermod/client/military/gui/RecruitInventoryScreen.java +++ b/src/main/java/com/talhanation/bannermod/client/military/gui/RecruitInventoryScreen.java @@ -113,6 +113,8 @@ public class RecruitInventoryScreen extends ScreenBase { private static final MutableComponent TEXT_MENU_MOUNT = Component.translatable("gui.recruits.inv.menu.mount"); private static final MutableComponent TEXT_ASSIGN_HOME = Component.translatable("bannermod.assign_home.button"); private static final MutableComponent TOOLTIP_ASSIGN_HOME = Component.translatable("bannermod.assign_home.tooltip"); + private static final MutableComponent TEXT_PERK_TREE = Component.translatable("gui.bannermod.perk_tree.recruit.button"); + private static final MutableComponent TOOLTIP_PERK_TREE = Component.translatable("gui.bannermod.perk_tree.recruit.tooltip"); private static final MutableComponent STATUS_READ_ONLY = Component.translatable("gui.recruits.inv.status.read_only"); private static final MutableComponent STATUS_GROUP_UNSET = Component.translatable("gui.recruits.inv.status.group_unset"); private static final MutableComponent STATUS_GROUP_LOCKED = Component.translatable("gui.recruits.inv.status.group_locked"); @@ -313,6 +315,10 @@ protected void init() { if (!moreButton.active) moreButton.setTooltip(Tooltip.create(TOOLTIP_NOBLE_LOCKED)); addRenderableWidget(moreButton); + Button perkTreeButton = addRenderableWidget(new ProfileButton(leftPos + 292, topPos + 24, 78, 16, TEXT_PERK_TREE, + button -> minecraft.setScreen(PerkTreeScreen.recruitTree(this.recruit)))); + perkTreeButton.setTooltip(Tooltip.create(TOOLTIP_PERK_TREE)); + Button assignHome = addRenderableWidget(new ProfileButton(zeroLeftPos, zeroTopPos + (20 + topPosGab) * 6, 80, 20, TEXT_ASSIGN_HOME, button -> { AssignHomeTargetSelector.start(this.recruit.getUUID()); diff --git a/src/main/java/com/talhanation/bannermod/network/catalog/MilitaryPacketCatalog.java b/src/main/java/com/talhanation/bannermod/network/catalog/MilitaryPacketCatalog.java index 95120e9e..5e308b29 100644 --- a/src/main/java/com/talhanation/bannermod/network/catalog/MilitaryPacketCatalog.java +++ b/src/main/java/com/talhanation/bannermod/network/catalog/MilitaryPacketCatalog.java @@ -105,6 +105,9 @@ public final class MilitaryPacketCatalog { MessageFormationMapMoveOrder.class, MessageFormationMapEngage.class, MessageConvertRecruitType.class, + MessageRequestPerkTreeSnapshot.class, + MessageUpdatePerkTree.class, + MessageToClientUpdatePerkTreeSnapshot.class, }; public static final PacketCatalog CATALOG = new PacketCatalog(MESSAGES); diff --git a/src/main/java/com/talhanation/bannermod/network/messages/military/MessageRequestPerkTreeSnapshot.java b/src/main/java/com/talhanation/bannermod/network/messages/military/MessageRequestPerkTreeSnapshot.java new file mode 100644 index 00000000..33797235 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/network/messages/military/MessageRequestPerkTreeSnapshot.java @@ -0,0 +1,77 @@ +package com.talhanation.bannermod.network.messages.military; + +import com.talhanation.bannermod.bootstrap.BannerModMain; +import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; +import com.talhanation.bannermod.entity.military.perks.PlayerPerkProgressService; +import com.talhanation.bannermod.network.compat.BannerModNetworkContext; +import com.talhanation.bannermod.network.compat.BannerModPacketDistributor; +import com.talhanation.bannermod.network.payload.BannerModMessage; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.server.level.ServerPlayer; + +import javax.annotation.Nullable; +import java.util.UUID; + +public class MessageRequestPerkTreeSnapshot implements BannerModMessage { + private boolean playerTree; + @Nullable + private UUID recruitUuid; + + public MessageRequestPerkTreeSnapshot() { + } + + public MessageRequestPerkTreeSnapshot(boolean playerTree, @Nullable UUID recruitUuid) { + this.playerTree = playerTree; + this.recruitUuid = recruitUuid; + } + + @Override + public PacketFlow getExecutingSide() { + return BannerModMessage.serverbound(); + } + + @Override + public void executeServerSide(BannerModNetworkContext context) { + context.enqueueWork(() -> { + ServerPlayer sender = context.getSender(); + if (sender == null) return; + if (playerTree) { + send(sender, new MessageToClientUpdatePerkTreeSnapshot(true, null, + PlayerPerkProgressService.progress(sender), "gui.bannermod.perk_tree.feedback.synced")); + return; + } + AbstractRecruitEntity recruit = RecruitMessageEntityResolver.resolveRecruitInInflatedBox(sender, recruitUuid, 16.0D); + if (!isAuthorized(sender, recruit)) { + send(sender, new MessageToClientUpdatePerkTreeSnapshot(false, recruitUuid, null, + "gui.bannermod.perk_tree.feedback.denied_authority")); + return; + } + send(sender, new MessageToClientUpdatePerkTreeSnapshot(false, recruitUuid, + recruit.getPerkProgress(), "gui.bannermod.perk_tree.feedback.synced")); + }); + } + + @Override + public MessageRequestPerkTreeSnapshot fromBytes(FriendlyByteBuf buf) { + this.playerTree = buf.readBoolean(); + this.recruitUuid = buf.readBoolean() ? buf.readUUID() : null; + return this; + } + + @Override + public void toBytes(FriendlyByteBuf buf) { + buf.writeBoolean(this.playerTree); + buf.writeBoolean(this.recruitUuid != null); + if (this.recruitUuid != null) buf.writeUUID(this.recruitUuid); + } + + static boolean isAuthorized(ServerPlayer sender, @Nullable AbstractRecruitEntity recruit) { + if (recruit == null || !recruit.isAlive()) return false; + return sender.hasPermissions(2) || sender.getUUID().equals(recruit.getOwnerUUID()); + } + + static void send(ServerPlayer sender, MessageToClientUpdatePerkTreeSnapshot message) { + BannerModMain.SIMPLE_CHANNEL.send(BannerModPacketDistributor.PLAYER.with(() -> sender), message); + } +} diff --git a/src/main/java/com/talhanation/bannermod/network/messages/military/MessageToClientUpdatePerkTreeSnapshot.java b/src/main/java/com/talhanation/bannermod/network/messages/military/MessageToClientUpdatePerkTreeSnapshot.java new file mode 100644 index 00000000..49500902 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/network/messages/military/MessageToClientUpdatePerkTreeSnapshot.java @@ -0,0 +1,83 @@ +package com.talhanation.bannermod.network.messages.military; + +import com.talhanation.bannermod.client.military.ClientManager; +import com.talhanation.bannermod.client.military.gui.PerkTreeScreen; +import com.talhanation.bannermod.entity.military.perks.PerkProgress; +import com.talhanation.bannermod.network.compat.BannerModNetworkContext; +import com.talhanation.bannermod.network.payload.BannerModMessage; +import net.minecraft.client.Minecraft; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.chat.Component; +import net.minecraft.network.protocol.PacketFlow; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.api.distmarker.OnlyIn; + +import javax.annotation.Nullable; +import java.util.UUID; + +public class MessageToClientUpdatePerkTreeSnapshot implements BannerModMessage { + private boolean playerTree; + @Nullable + private UUID recruitUuid; + @Nullable + private CompoundTag progressTag; + private String feedbackKey = ""; + + public MessageToClientUpdatePerkTreeSnapshot() { + } + + public MessageToClientUpdatePerkTreeSnapshot(boolean playerTree, @Nullable UUID recruitUuid, + @Nullable PerkProgress progress, String feedbackKey) { + this.playerTree = playerTree; + this.recruitUuid = recruitUuid; + this.progressTag = progress == null ? null : progress.toNbt(); + this.feedbackKey = feedbackKey; + } + + @Override + public PacketFlow getExecutingSide() { + return BannerModMessage.clientbound(); + } + + @Override + @OnlyIn(Dist.CLIENT) + public void executeClientSide(BannerModNetworkContext context) { + boolean hasProgress = progressTag != null; + if (hasProgress) { + PerkProgress progress = new PerkProgress(); + progress.fromNbt(progressTag); + if (playerTree) { + ClientManager.playerPerkSnapshot = progress; + } else { + ClientManager.recruitPerkSnapshotUuid = recruitUuid; + ClientManager.recruitPerkSnapshot = progress; + } + } + ClientManager.perkTreeFeedback = feedbackKey == null || feedbackKey.isEmpty() + ? Component.empty() + : Component.translatable(feedbackKey); + ClientManager.perkTreeSnapshotVersion++; + if (Minecraft.getInstance().screen instanceof PerkTreeScreen screen) { + screen.onSnapshot(playerTree, recruitUuid, hasProgress); + } + } + + @Override + public MessageToClientUpdatePerkTreeSnapshot fromBytes(FriendlyByteBuf buf) { + this.playerTree = buf.readBoolean(); + this.recruitUuid = buf.readBoolean() ? buf.readUUID() : null; + this.progressTag = buf.readNbt(); + this.feedbackKey = buf.readUtf(128); + return this; + } + + @Override + public void toBytes(FriendlyByteBuf buf) { + buf.writeBoolean(this.playerTree); + buf.writeBoolean(this.recruitUuid != null); + if (this.recruitUuid != null) buf.writeUUID(this.recruitUuid); + buf.writeNbt(this.progressTag); + buf.writeUtf(this.feedbackKey == null ? "" : this.feedbackKey); + } +} diff --git a/src/main/java/com/talhanation/bannermod/network/messages/military/MessageUpdatePerkTree.java b/src/main/java/com/talhanation/bannermod/network/messages/military/MessageUpdatePerkTree.java new file mode 100644 index 00000000..9178c342 --- /dev/null +++ b/src/main/java/com/talhanation/bannermod/network/messages/military/MessageUpdatePerkTree.java @@ -0,0 +1,129 @@ +package com.talhanation.bannermod.network.messages.military; + +import com.talhanation.bannermod.entity.military.AbstractRecruitEntity; +import com.talhanation.bannermod.entity.military.perks.PerkArchetype; +import com.talhanation.bannermod.entity.military.perks.PerkEffectService; +import com.talhanation.bannermod.entity.military.perks.PerkNode; +import com.talhanation.bannermod.entity.military.perks.PerkProgress; +import com.talhanation.bannermod.entity.military.perks.PerkRegistry; +import com.talhanation.bannermod.entity.military.perks.PlayerPerkProgressService; +import com.talhanation.bannermod.network.compat.BannerModNetworkContext; +import com.talhanation.bannermod.network.payload.BannerModMessage; +import net.minecraft.network.FriendlyByteBuf; +import net.minecraft.network.protocol.PacketFlow; +import net.minecraft.server.level.ServerPlayer; + +import javax.annotation.Nullable; +import java.util.UUID; + +public class MessageUpdatePerkTree implements BannerModMessage { + public static final String ACTION_UNLOCK = "unlock"; + public static final String ACTION_RESPEC = "respec"; + + private boolean playerTree; + @Nullable + private UUID recruitUuid; + private String action = ""; + private String perkId = ""; + + public MessageUpdatePerkTree() { + } + + public MessageUpdatePerkTree(boolean playerTree, @Nullable UUID recruitUuid, String action, String perkId) { + this.playerTree = playerTree; + this.recruitUuid = recruitUuid; + this.action = action; + this.perkId = perkId; + } + + @Override + public PacketFlow getExecutingSide() { + return BannerModMessage.serverbound(); + } + + @Override + public void executeServerSide(BannerModNetworkContext context) { + context.enqueueWork(() -> { + ServerPlayer sender = context.getSender(); + if (sender == null) return; + if (playerTree) { + handlePlayer(sender); + return; + } + handleRecruit(sender); + }); + } + + private void handlePlayer(ServerPlayer sender) { + String feedback = "gui.bannermod.perk_tree.feedback.denied_unknown"; + if (ACTION_UNLOCK.equals(action)) { + PerkProgress.UnlockResult result = PlayerPerkProgressService.unlock(sender, perkId); + if (result == PerkProgress.UnlockResult.OK) PerkEffectService.applyPlayerAttributeBonuses(sender); + feedback = feedbackKey(result); + } else if (ACTION_RESPEC.equals(action)) { + PlayerPerkProgressService.respec(sender); + PerkEffectService.applyPlayerAttributeBonuses(sender); + feedback = "gui.bannermod.perk_tree.feedback.respec"; + } + MessageRequestPerkTreeSnapshot.send(sender, new MessageToClientUpdatePerkTreeSnapshot(true, null, + PlayerPerkProgressService.progress(sender), feedback)); + } + + private void handleRecruit(ServerPlayer sender) { + AbstractRecruitEntity recruit = RecruitMessageEntityResolver.resolveRecruitInInflatedBox(sender, recruitUuid, 16.0D); + if (!MessageRequestPerkTreeSnapshot.isAuthorized(sender, recruit)) { + MessageRequestPerkTreeSnapshot.send(sender, new MessageToClientUpdatePerkTreeSnapshot(false, recruitUuid, null, + "gui.bannermod.perk_tree.feedback.denied_authority")); + return; + } + + String feedback = "gui.bannermod.perk_tree.feedback.denied_unknown"; + if (ACTION_UNLOCK.equals(action)) { + PerkNode node = PerkRegistry.get(perkId).orElse(null); + if (node == null || node.id().startsWith("player/") + || (node.archetype() != PerkArchetype.UNIVERSAL + && node.archetype() != PerkEffectService.recruitArchetype(recruit))) { + feedback = feedbackKey(PerkProgress.UnlockResult.UNKNOWN_PERK); + } else { + PerkProgress.UnlockResult result = recruit.getPerkProgress().unlock(node); + if (result == PerkProgress.UnlockResult.OK) PerkEffectService.applyRecruitAttributeBonuses(recruit); + feedback = feedbackKey(result); + } + } else if (ACTION_RESPEC.equals(action)) { + recruit.getPerkProgress().respec(); + PerkEffectService.applyRecruitAttributeBonuses(recruit); + feedback = "gui.bannermod.perk_tree.feedback.respec"; + } + + MessageRequestPerkTreeSnapshot.send(sender, new MessageToClientUpdatePerkTreeSnapshot(false, recruit.getUUID(), + recruit.getPerkProgress(), feedback)); + } + + @Override + public MessageUpdatePerkTree fromBytes(FriendlyByteBuf buf) { + this.playerTree = buf.readBoolean(); + this.recruitUuid = buf.readBoolean() ? buf.readUUID() : null; + this.action = buf.readUtf(24); + this.perkId = buf.readUtf(128); + return this; + } + + @Override + public void toBytes(FriendlyByteBuf buf) { + buf.writeBoolean(this.playerTree); + buf.writeBoolean(this.recruitUuid != null); + if (this.recruitUuid != null) buf.writeUUID(this.recruitUuid); + buf.writeUtf(this.action == null ? "" : this.action); + buf.writeUtf(this.perkId == null ? "" : this.perkId); + } + + private static String feedbackKey(PerkProgress.UnlockResult result) { + return switch (result) { + case OK -> "gui.bannermod.perk_tree.feedback.unlocked"; + case ALREADY_OWNED -> "gui.bannermod.perk_tree.feedback.denied_owned"; + case NOT_ENOUGH_POINTS -> "gui.bannermod.perk_tree.feedback.denied_points"; + case PREREQUISITES_NOT_MET -> "gui.bannermod.perk_tree.feedback.denied_prereq"; + case UNKNOWN_PERK -> "gui.bannermod.perk_tree.feedback.denied_unknown"; + }; + } +} diff --git a/src/main/java/com/talhanation/bannermod/registry/military/ModShortcuts.java b/src/main/java/com/talhanation/bannermod/registry/military/ModShortcuts.java index f812b5e7..4163d47a 100644 --- a/src/main/java/com/talhanation/bannermod/registry/military/ModShortcuts.java +++ b/src/main/java/com/talhanation/bannermod/registry/military/ModShortcuts.java @@ -11,6 +11,7 @@ public class ModShortcuts { public static KeyMapping COMMAND_SCREEN_KEY; public static KeyMapping WAR_ROOM_KEY; public static KeyMapping MAP_SCREEN_KEY; + public static KeyMapping PLAYER_SKILL_TREE_KEY; @SubscribeEvent @OnlyIn(Dist.CLIENT) @@ -18,9 +19,11 @@ public static void registerBindings(RegisterKeyMappingsEvent event) { COMMAND_SCREEN_KEY = new KeyMapping("key.bannermod.command_screen_key", GLFW.GLFW_KEY_R, "category.bannermod"); WAR_ROOM_KEY = new KeyMapping("key.bannermod.war_room_key", GLFW.GLFW_KEY_U, "category.bannermod"); MAP_SCREEN_KEY = new KeyMapping("key.bannermod.map_screen_key", GLFW.GLFW_KEY_M, "category.bannermod"); + PLAYER_SKILL_TREE_KEY = new KeyMapping("key.bannermod.player_skill_tree_key", GLFW.GLFW_KEY_K, "category.bannermod"); event.register(COMMAND_SCREEN_KEY); event.register(WAR_ROOM_KEY); event.register(MAP_SCREEN_KEY); + event.register(PLAYER_SKILL_TREE_KEY); } } diff --git a/src/main/resources/assets/bannermod/lang/en_us.json b/src/main/resources/assets/bannermod/lang/en_us.json index 28cd53cb..e0e3f728 100644 --- a/src/main/resources/assets/bannermod/lang/en_us.json +++ b/src/main/resources/assets/bannermod/lang/en_us.json @@ -2619,5 +2619,29 @@ "perk.bannermod.pikeman.braced_stance_i": "Braced Stance I", "perk.bannermod.pikeman.braced_stance_i.desc": "+10% knockback resistance.", "perk.bannermod.cavalry.swift_charge_i": "Swift Charge I", - "perk.bannermod.cavalry.swift_charge_i.desc": "+0.01 movement speed." + "perk.bannermod.cavalry.swift_charge_i.desc": "+0.01 movement speed.", + "key.bannermod.player_skill_tree_key": "Open Player Skill Tree", + "gui.bannermod.perk_tree.player.title": "Player Skill Tree", + "gui.bannermod.perk_tree.recruit.title": "Recruit Perk Tree", + "gui.bannermod.perk_tree.recruit.button": "Perks", + "gui.bannermod.perk_tree.recruit.tooltip": "Open this recruit's parchment perk tree.", + "gui.bannermod.perk_tree.points": "Points: %s", + "gui.bannermod.perk_tree.state.locked": "Locked", + "gui.bannermod.perk_tree.state.available": "Available", + "gui.bannermod.perk_tree.state.owned": "Owned", + "gui.bannermod.perk_tree.unlock": "Unlock", + "gui.bannermod.perk_tree.respec": "Respec", + "gui.bannermod.perk_tree.respec.confirm_button": "Confirm Respec", + "gui.bannermod.perk_tree.respec.confirm": "Refund all points and clear every unlocked perk?", + "gui.bannermod.perk_tree.waiting_sync": "Waiting for server snapshot...", + "gui.bannermod.perk_tree.empty": "No perks are registered for this tree.", + "gui.bannermod.perk_tree.pending": "Request sent...", + "gui.bannermod.perk_tree.feedback.synced": "Server snapshot received.", + "gui.bannermod.perk_tree.feedback.unlocked": "Perk unlocked.", + "gui.bannermod.perk_tree.feedback.respec": "Perks reset and points refunded.", + "gui.bannermod.perk_tree.feedback.denied_authority": "Server denied: not your target.", + "gui.bannermod.perk_tree.feedback.denied_owned": "Server denied: already owned.", + "gui.bannermod.perk_tree.feedback.denied_points": "Server denied: not enough points.", + "gui.bannermod.perk_tree.feedback.denied_prereq": "Server denied: prerequisites missing.", + "gui.bannermod.perk_tree.feedback.denied_unknown": "Server denied: unknown perk." } diff --git a/src/main/resources/assets/bannermod/lang/ru_ru.json b/src/main/resources/assets/bannermod/lang/ru_ru.json index a378aac5..fd5620d8 100644 --- a/src/main/resources/assets/bannermod/lang/ru_ru.json +++ b/src/main/resources/assets/bannermod/lang/ru_ru.json @@ -2529,5 +2529,29 @@ "perk.bannermod.pikeman.braced_stance_i": "Упорная стойка I", "perk.bannermod.pikeman.braced_stance_i.desc": "+10% к сопротивлению отбрасыванию.", "perk.bannermod.cavalry.swift_charge_i": "Стремительный натиск I", - "perk.bannermod.cavalry.swift_charge_i.desc": "+0.01 к скорости передвижения." + "perk.bannermod.cavalry.swift_charge_i.desc": "+0.01 к скорости передвижения.", + "key.bannermod.player_skill_tree_key": "Открыть дерево навыков игрока", + "gui.bannermod.perk_tree.player.title": "Дерево навыков игрока", + "gui.bannermod.perk_tree.recruit.title": "Дерево перков рекрута", + "gui.bannermod.perk_tree.recruit.button": "Перки", + "gui.bannermod.perk_tree.recruit.tooltip": "Открыть пергаментное дерево перков этого рекрута.", + "gui.bannermod.perk_tree.points": "Очки: %s", + "gui.bannermod.perk_tree.state.locked": "Закрыто", + "gui.bannermod.perk_tree.state.available": "Доступно", + "gui.bannermod.perk_tree.state.owned": "Изучено", + "gui.bannermod.perk_tree.unlock": "Изучить", + "gui.bannermod.perk_tree.respec": "Сброс", + "gui.bannermod.perk_tree.respec.confirm_button": "Подтвердить сброс", + "gui.bannermod.perk_tree.respec.confirm": "Вернуть все очки и очистить изученные перки?", + "gui.bannermod.perk_tree.waiting_sync": "Ожидание снимка сервера...", + "gui.bannermod.perk_tree.empty": "Для этого дерева нет зарегистрированных перков.", + "gui.bannermod.perk_tree.pending": "Запрос отправлен...", + "gui.bannermod.perk_tree.feedback.synced": "Снимок сервера получен.", + "gui.bannermod.perk_tree.feedback.unlocked": "Перк изучен.", + "gui.bannermod.perk_tree.feedback.respec": "Перки сброшены, очки возвращены.", + "gui.bannermod.perk_tree.feedback.denied_authority": "Сервер отклонил: цель не ваша.", + "gui.bannermod.perk_tree.feedback.denied_owned": "Сервер отклонил: уже изучено.", + "gui.bannermod.perk_tree.feedback.denied_points": "Сервер отклонил: не хватает очков.", + "gui.bannermod.perk_tree.feedback.denied_prereq": "Сервер отклонил: нет требований.", + "gui.bannermod.perk_tree.feedback.denied_unknown": "Сервер отклонил: неизвестный перк." } diff --git a/src/test/java/com/talhanation/bannermod/client/military/gui/CriticalUiStateVerificationTest.java b/src/test/java/com/talhanation/bannermod/client/military/gui/CriticalUiStateVerificationTest.java index 9e0c1fc0..5d16787a 100644 --- a/src/test/java/com/talhanation/bannermod/client/military/gui/CriticalUiStateVerificationTest.java +++ b/src/test/java/com/talhanation/bannermod/client/military/gui/CriticalUiStateVerificationTest.java @@ -72,6 +72,45 @@ void warScreensKeepWaitingForSyncStateSelection() throws IOException { assertTrue(politicalEntityListScreen.contains(": \"gui.bannermod.states.waiting_sync\"") ); } + @Test + void perkTreeKeepsServerAuthoritativeUiAndLocalizedWiring() throws IOException { + String recruitInventoryScreen = read("src/main/java/com/talhanation/bannermod/client/military/gui/RecruitInventoryScreen.java"); + String perkTreeScreen = read("src/main/java/com/talhanation/bannermod/client/military/gui/PerkTreeScreen.java"); + String keyEvents = read("src/main/java/com/talhanation/bannermod/client/military/events/KeyEvents.java"); + String modShortcuts = read("src/main/java/com/talhanation/bannermod/registry/military/ModShortcuts.java"); + String packetCatalog = read("src/main/java/com/talhanation/bannermod/network/catalog/MilitaryPacketCatalog.java"); + String updatePacket = read("src/main/java/com/talhanation/bannermod/network/messages/military/MessageUpdatePerkTree.java"); + String enLang = read("src/main/resources/assets/bannermod/lang/en_us.json"); + String ruLang = read("src/main/resources/assets/bannermod/lang/ru_ru.json"); + + assertTrue(recruitInventoryScreen.contains("PerkTreeScreen.recruitTree(this.recruit)")); + assertTrue(modShortcuts.contains("PLAYER_SKILL_TREE_KEY")); + assertTrue(keyEvents.contains("PerkTreeScreen.playerTree()")); + + assertTrue(perkTreeScreen.contains("private boolean snapshotReady")); + assertTrue(perkTreeScreen.contains("if (!snapshotReady) return null")); + assertTrue(perkTreeScreen.contains("respecButton.active = progress != null")); + assertTrue(perkTreeScreen.contains("setScreen(null)")); + assertTrue(perkTreeScreen.contains("PerkState.LOCKED")); + assertTrue(perkTreeScreen.contains("PerkState.AVAILABLE")); + assertTrue(perkTreeScreen.contains("PerkState.OWNED")); + assertTrue(perkTreeScreen.contains("new MessageUpdatePerkTree(playerTree, recruitUuid(), action, perkId)")); + assertTrue(perkTreeScreen.contains("gui.bannermod.perk_tree.respec.confirm")); + + assertTrue(updatePacket.contains("PlayerPerkProgressService.unlock(sender, perkId)")); + assertTrue(updatePacket.contains("recruit.getPerkProgress().unlock(node)")); + assertTrue(updatePacket.contains("MessageRequestPerkTreeSnapshot.isAuthorized(sender, recruit)")); + assertTrue(updatePacket.contains("PerkEffectService.applyRecruitAttributeBonuses(recruit)")); + assertTrue(packetCatalog.contains("MessageRequestPerkTreeSnapshot.class")); + assertTrue(packetCatalog.contains("MessageUpdatePerkTree.class")); + assertTrue(packetCatalog.contains("MessageToClientUpdatePerkTreeSnapshot.class")); + + assertTrue(enLang.contains("gui.bannermod.perk_tree.state.available")); + assertTrue(enLang.contains("key.bannermod.player_skill_tree_key")); + assertTrue(ruLang.contains("gui.bannermod.perk_tree.state.available")); + assertTrue(ruLang.contains("key.bannermod.player_skill_tree_key")); + } + @Test void recruitAndAssassinScreensKeepVisibleStatusReasons() throws IOException { String recruitInventoryScreen = read("src/main/java/com/talhanation/bannermod/client/military/gui/RecruitInventoryScreen.java"); From 1ef6181f5dc858be1549d854428d2ae967e0837b Mon Sep 17 00:00:00 2001 From: "pozdn.r.a" Date: Sat, 9 May 2026 20:52:49 +0700 Subject: [PATCH 6/7] document agent review gates --- AGENTS.md | 10 +++++----- docs/CONTRIBUTING.md | 22 +++++++++++++--------- docs/TOOLS.md | 1 + 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1b80301c..dcd4914f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,13 +107,13 @@ All server-side movement / face / attack / aggro / stance / strategic-fire comma - **Explicit target positions:** when the move target arrives via network (world-map click, etc.) instead of `player.pick(...)`, use the 6-arg overload `CommandEvents.onMovementCommand(player, recruits, state, formation, tight, Vec3)` — the underlying `MovementFormationCommandService` short-circuits the hit-result lookup when `explicitTargetPos != null`. - **Verifying a wiring change:** `./gradlew compileJava` via `ctx log` is the cheap gate. For runtime verification of formation behavior, save a formation in the command screen, then exercise the command path; `formation == 0` means the player never opened the formation UI and the per-recruit fallback is the correct path. -14. Claude Code plugins -- `code-simplifier` — post-slice clean-up pass: review changed code for clarity / reuse and fix issues found. Run before committing a non-trivial slice. -- `code-reviewer` — independent review of pending changes or a PR. Run before merging an integration branch into master. +14. Agent plugins/tools +- `code-simplifier` — mandatory post-slice clean-up pass before final verification/closure. Apply only justified, behavior-preserving cleanup. +- `code-reviewer` — mandatory independent review after `code-simplifier` and before marking work done or committing. Resolve or explicitly document every finding. - `context7` — live documentation lookup for libraries, frameworks, SDKs, CLI tools (NeoForge, Mojang, Gradle plugins, JUnit, etc.). Use whenever a task touches third-party APIs instead of relying on training-data recollection. -- `jd-tls` — Java decompilation / class-inspection tooling for compiled NeoForge and vendored jars. Use when tracing through bytecode is faster than spelunking sources. +- `jd-tls` / `jdtls` — Java decompilation, class inspection, LSP diagnostics, and compiled API tracing for NeoForge and vendored jars. Use when tracing through bytecode is faster than spelunking sources. -Prefer these plugins over ad-hoc shell commands or local scripts when the task fits. +Prefer these tools over ad-hoc shell commands or local scripts when the task fits. The normal finish order is implementation, `code-simplifier`, verification, `code-reviewer`, fixes if any, and final verification/closure. ## Project diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 9ac839ae..648881f9 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -16,11 +16,14 @@ BannerMod is a brownfield merge workspace. Contributions should be small, verifi 3. Reproduce the bug or define acceptance checks before editing. 4. Make the smallest code change that satisfies the check. 5. Add or update focused tests when behavior changes. -6. Self-verify the result against every acceptance item of the backlog task before marking it done. -7. Update `MULTIPLAYER_GUIDE_RU.md`, `MULTIPLAYER_GUIDE_EN.md`, and `docs/BANNERMOD_ALMANAC.html` when the task changes UI, changes mechanics, or adds player-facing mechanics that non-technical players must know. -8. Run the cheapest relevant verification first, then wider gates only when the touched area needs them. -9. Update the backlog through `tools/backlog progress` or `tools/backlog done --verification`, and update `docs/STATUS.md` / `.planning/STATE.md` when shipped behavior or project status changes. -10. Commit atomically by area: code, tools, and docs should usually be separate commits. +6. Run `code-simplifier` on the completed slice and apply only small, behavior-preserving cleanup that it justifies. +7. Self-verify the result against every acceptance item of the backlog task before marking it done. +8. Update `MULTIPLAYER_GUIDE_RU.md`, `MULTIPLAYER_GUIDE_EN.md`, and `docs/BANNERMOD_ALMANAC.html` when the task changes UI, changes mechanics, or adds player-facing mechanics that non-technical players must know. +9. Run the cheapest relevant verification first, then wider gates only when the touched area needs them. +10. Update `docs/STATUS.md` / `.planning/STATE.md` when shipped behavior or project status changes. +11. Run `code-reviewer` on the completed diff and resolve or explicitly document every finding before closing the task. +12. Update the backlog through `tools/backlog progress` or `tools/backlog done --verification`. +13. Commit atomically by area: code, tools, and docs should usually be separate commits. ## Naming Conventions @@ -112,10 +115,11 @@ Use `tools/task-worktree --base origin/master` for an independent task 3. Rewrite each acceptance item into one concrete verification check you can actually observe now: compile, unit test, GameTest, manual in-game flow, log output, UI state, or code-path inspection when runtime proof is impossible. 4. Run the cheapest relevant verification first. Examples: `./gradlew compileJava` before full tests, a focused test before the whole suite, or a single GameTest class before `verifyGameTestStage`. 5. If the task changes gameplay, UI, multiplayer authority, persistence, or player-facing docs, include at least one verification step that exercises the changed behavior directly rather than only compiling. -6. If one or more acceptance items are still not satisfied, keep the task open and append `tools/backlog progress ""`. -7. Mark a task done only when every acceptance item is observably satisfied right now and every dependency is already done. Record the evidence with `tools/backlog done --verification ""`. -8. The verification note must map back to the acceptance items explicitly. Example: `1) compileJava passed; 2) BannerModClaimProtectionGameTests passed hostile-denial path; 3) in-game War Room flow showed disabled tribute reason text.` -9. When verification is blocked, say so plainly in backlog progress instead of guessing or claiming green status. +6. Before final completion, run `code-simplifier`, verify any accepted cleanup, then run `code-reviewer` and resolve or document findings. +7. If one or more acceptance items are still not satisfied, keep the task open and append `tools/backlog progress ""`. +8. Mark a task done only when every acceptance item is observably satisfied right now, every dependency is already done, and the `code-simplifier` / `code-reviewer` gate is complete. Record the evidence with `tools/backlog done --verification ""`. +9. The verification note must map back to the acceptance items explicitly. Example: `1) compileJava passed; 2) BannerModClaimProtectionGameTests passed hostile-denial path; 3) in-game War Room flow showed disabled tribute reason text; 4) code-simplifier no-op and code-reviewer no findings.` +10. When verification is blocked, say so plainly in backlog progress instead of guessing or claiming green status. ## Verification Defaults diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 9537278e..41db4590 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -80,6 +80,7 @@ Shared local guardrail scripts live in `tools/agent-hooks/`. - Claude Code uses `.claude/settings.local.json`, which runs `tools/ai-context-proxy/hooks/claude-pre-bash.py`; that wrapper delegates to `tools/agent-hooks/pre-bash-guardrails.py`. - Codex supports project hooks through `.codex/config.toml` with `[features].codex_hooks = true`; this repo wires `PreToolUse` for Bash to the shared guardrail script. - OpenCode supports project plugins under `.opencode/plugins/`; this repo uses `.opencode/plugins/project-guardrails.js` to block direct backlog JSON access and raw context dumps before Bash execution. +- `code-simplifier` and `code-reviewer` are required quality gates for every completed feature/task. Run `code-simplifier` first, verify any cleanup, then run `code-reviewer` and resolve or record findings before marking the task done. - Cursor, Windsurf, Gemini, and Copilot rule files currently provide instruction-level guardrails only; no repo-local executable hook format is configured here for them. Repo-local execution commands: From fa87ecb42275ef70425a890960b368b306d517a7 Mon Sep 17 00:00:00 2001 From: "pozdn.r.a" Date: Sat, 9 May 2026 21:00:19 +0700 Subject: [PATCH 7/7] scope jacoco unit coverage --- build.gradle | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 784ea068..6c7125e9 100644 --- a/build.gradle +++ b/build.gradle @@ -264,7 +264,11 @@ jacocoTestReport { 'com/talhanation/bannermod/settlement/prefab/**', 'com/talhanation/bannermod/settlement/validation/**', 'com/talhanation/bannermod/settlement/building/**', - 'com/talhanation/bannermod/settlement/workorder/publisher/**' + 'com/talhanation/bannermod/settlement/workorder/publisher/**', + // Event/heartbeat adapters are exercised by GameTests; keep unit-test JaCoCo on deterministic services. + 'com/talhanation/bannermod/settlement/runtime/ClaimProtectionEventService.class', + 'com/talhanation/bannermod/settlement/runtime/ClaimProtectionPolicy*.class', + 'com/talhanation/bannermod/settlement/runtime/SettlementHeartbeatService.class' ]) })) reports {