From 8f72b9a8121104237fc85d0bdef56a4a2037f638 Mon Sep 17 00:00:00 2001 From: Horoli Date: Wed, 10 Dec 2025 01:35:09 +0900 Subject: [PATCH] update --- assets/data/enemies.json | 1 + assets/data/items.json | 20 +- assets/data/players.json | 11 + lib/game/config/game_config.dart | 2 + lib/game/data/enemy_table.dart | 18 +- lib/game/data/item_table.dart | 3 + lib/game/data/player_table.dart | 4 + lib/game/enums.dart | 4 +- lib/game/logic/combat_calculator.dart | 48 +++-- lib/game/logic/loot_generator.dart | 10 + lib/game/model/entity.dart | 16 +- lib/game/model/item.dart | 3 + lib/providers/battle_provider.dart | 201 ++++++++++++++---- lib/screens/battle_screen.dart | 10 +- .../inventory/character_stats_widget.dart | 13 +- .../inventory/inventory_grid_widget.dart | 5 + prompt/00_project_context_restore.md | 123 ++--------- prompt/65_dynamic_intent_ui_update.md | 26 +++ test/battle_provider_test.dart | 98 +++++++++ test/disarm_test.dart | 112 ++++++++++ 20 files changed, 547 insertions(+), 181 deletions(-) create mode 100644 prompt/65_dynamic_intent_ui_update.md create mode 100644 test/battle_provider_test.dart create mode 100644 test/disarm_test.dart diff --git a/assets/data/enemies.json b/assets/data/enemies.json index ff56383..5f0aac7 100644 --- a/assets/data/enemies.json +++ b/assets/data/enemies.json @@ -59,6 +59,7 @@ "baseHp": 40, "baseAtk": 10, "baseDefense": 2, + "baseDodge": 25, "image": "assets/images/enemies/shadow_assassin.png", "equipment": ["jagged_dagger"], "tier": 3 diff --git a/assets/data/items.json b/assets/data/items.json index 616d7b9..2287c8b 100644 --- a/assets/data/items.json +++ b/assets/data/items.json @@ -25,11 +25,18 @@ { "id": "rusty_dagger", "name": "Rusty Dagger", - "description": "Old and rusty, but better than nothing.", + "description": "Old and rusty, but can disarm foes.", "baseAtk": 3, "slot": "weapon", "price": 30, "image": "assets/images/items/rusty_dagger.png", + "effects": [ + { + "type": "disarmed", + "probability": 100, + "duration": 1 + } + ], "rarity": "magic", "tier": "tier1" }, @@ -42,7 +49,14 @@ "price": 80, "image": "assets/images/items/iron_sword.png", "rarity": "magic", - "tier": "tier2" + "tier": "tier2", + "effects": [ + { + "type": "disarmed", + "probability": 100, + "duration": 1 + } + ] }, { "id": "battle_axe", @@ -267,4 +281,4 @@ "tier": "tier3" } ] -} \ No newline at end of file +} diff --git a/assets/data/players.json b/assets/data/players.json index 17f8e4d..eeb70ea 100644 --- a/assets/data/players.json +++ b/assets/data/players.json @@ -6,6 +6,17 @@ "baseHp": 50, "baseAtk": 5, "baseDefense": 5, + "baseDodge": 2, "image": "assets/images/players/warrior.png" + }, + { + "id": "rogue", + "name": "Rogue", + "description": "A swift shadow with high evasion but lower health.", + "baseHp": 40, + "baseAtk": 7, + "baseDefense": 2, + "baseDodge": 15, + "image": "assets/images/players/rogue.png" } ] diff --git a/lib/game/config/game_config.dart b/lib/game/config/game_config.dart index e8f9f81..f6815cd 100644 --- a/lib/game/config/game_config.dart +++ b/lib/game/config/game_config.dart @@ -18,6 +18,8 @@ class GameConfig { static const double stageHealRatio = 0.1; static const double vulnerableDamageMultiplier = 1.5; static const double armorDecayRate = 1.0; + static const double disarmedDamageMultiplier = + 0.2; // New: Reduces ATK to 10% when disarmed // Rewards static const int baseGoldReward = 10; diff --git a/lib/game/data/enemy_table.dart b/lib/game/data/enemy_table.dart index 18edbeb..21696a2 100644 --- a/lib/game/data/enemy_table.dart +++ b/lib/game/data/enemy_table.dart @@ -11,6 +11,7 @@ class EnemyTemplate { final int baseHp; final int baseAtk; final int baseDefense; + final int baseDodge; // New: Base dodge chance final String? image; final List equipmentIds; final int tier; @@ -20,6 +21,7 @@ class EnemyTemplate { required this.baseHp, required this.baseAtk, required this.baseDefense, + this.baseDodge = 1, // Default value this.image, this.equipmentIds = const [], this.tier = 1, @@ -31,6 +33,7 @@ class EnemyTemplate { baseHp: json['baseHp'] ?? 10, baseAtk: json['baseAtk'] ?? 1, baseDefense: json['baseDefense'] ?? 0, + baseDodge: json['baseDodge'] ?? 1, // Parse from JSON or default to 1 image: json['image'], equipmentIds: (json['equipment'] as List?)?.cast() ?? [], tier: json['tier'] ?? 1, @@ -46,6 +49,7 @@ class EnemyTemplate { maxHp: baseHp, atk: baseAtk, baseDefense: baseDefense, + baseDodge: baseDodge, // Pass baseDodge to Character constructor armor: 0, image: image, ); @@ -85,7 +89,10 @@ class EnemyTable { } /// Returns a random enemy suitable for the current stage. - static EnemyTemplate getRandomEnemy({required int stage, bool isElite = false}) { + static EnemyTemplate getRandomEnemy({ + required int stage, + bool isElite = false, + }) { int targetTier = 1; if (stage > GameConfig.tier2StageMax) { targetTier = 3; @@ -94,7 +101,7 @@ class EnemyTable { } List pool = isElite ? eliteEnemies : normalEnemies; - + // Filter by tier var tierPool = pool.where((e) => e.tier == targetTier).toList(); @@ -108,7 +115,12 @@ class EnemyTable { if (tierPool.isEmpty) { // Should not happen if JSON is correct - return const EnemyTemplate(name: "Fallback Enemy", baseHp: 10, baseAtk: 1, baseDefense: 0); + return const EnemyTemplate( + name: "Fallback Enemy", + baseHp: 10, + baseAtk: 1, + baseDefense: 0, + ); } return tierPool[_random.nextInt(tierPool.length)]; diff --git a/lib/game/data/item_table.dart b/lib/game/data/item_table.dart index e000afb..74d2d0c 100644 --- a/lib/game/data/item_table.dart +++ b/lib/game/data/item_table.dart @@ -15,6 +15,7 @@ class ItemTemplate { final int atkBonus; final int hpBonus; final int armorBonus; + final int dodge; // New final EquipmentSlot slot; final List effects; final int price; @@ -30,6 +31,7 @@ class ItemTemplate { required this.atkBonus, required this.hpBonus, required this.armorBonus, + this.dodge = 0, required this.slot, required this.effects, required this.price, @@ -54,6 +56,7 @@ class ItemTemplate { atkBonus: json['atkBonus'] ?? json['baseAtk'] ?? 0, hpBonus: json['hpBonus'] ?? json['baseHp'] ?? 0, armorBonus: json['armorBonus'] ?? json['baseArmor'] ?? 0, + dodge: json['dodge'] ?? 0, slot: EquipmentSlot.values.firstWhere((e) => e.name == json['slot']), effects: effectsList, price: json['price'] ?? 10, diff --git a/lib/game/data/player_table.dart b/lib/game/data/player_table.dart index 50bd24d..ef2f752 100644 --- a/lib/game/data/player_table.dart +++ b/lib/game/data/player_table.dart @@ -9,6 +9,7 @@ class PlayerTemplate { final int baseHp; final int baseAtk; final int baseDefense; + final int baseDodge; // New field final String? image; const PlayerTemplate({ @@ -18,6 +19,7 @@ class PlayerTemplate { required this.baseHp, required this.baseAtk, required this.baseDefense, + this.baseDodge = 1, // Default 1 this.image, }); @@ -29,6 +31,7 @@ class PlayerTemplate { baseHp: json['baseHp'], baseAtk: json['baseAtk'], baseDefense: json['baseDefense'], + baseDodge: json['baseDodge'] ?? 1, // Parse with default image: json['image'], ); } @@ -39,6 +42,7 @@ class PlayerTemplate { maxHp: baseHp, atk: baseAtk, baseDefense: baseDefense, + baseDodge: baseDodge, // Use template value armor: 0, ); } diff --git a/lib/game/enums.dart b/lib/game/enums.dart index 86a7c7e..b0e5e10 100644 --- a/lib/game/enums.dart +++ b/lib/game/enums.dart @@ -9,12 +9,14 @@ enum StatusEffectType { vulnerable, // Takes 50% more damage bleed, // Takes damage at start/end of turn defenseForbidden, // Cannot use Defend action + disarmed, // Attack strength reduced (e.g., 10%) } /// 공격 실패 시 이펙트 피드백 타입 정의 enum BattleFeedbackType { miss, // 공격이 빗나감 failed, // 방어 실패 + dodge, // 회피 성공 } /// 스탯에 적용될 수 있는 수정자(Modifier)의 타입 정의. @@ -33,7 +35,7 @@ enum EquipmentSlot { weapon, armor, shield, accessory } enum DamageType { normal, bleed, vulnerable } -enum StatType { maxHp, atk, defense, luck } +enum StatType { maxHp, atk, defense, luck, dodge } enum ItemRarity { normal, magic, rare, legendary, unique } diff --git a/lib/game/logic/combat_calculator.dart b/lib/game/logic/combat_calculator.dart index 4e7d748..bea3fdd 100644 --- a/lib/game/logic/combat_calculator.dart +++ b/lib/game/logic/combat_calculator.dart @@ -23,34 +23,45 @@ class CombatResult { class CombatCalculator { static final Random _random = Random(); + /// Helper to get efficiency multiplier based on risk and action type. + static double getEfficiency(ActionType actionType, RiskLevel risk) { + switch (risk) { + case RiskLevel.safe: + return actionType == ActionType.attack + ? BattleConfig.attackSafeEfficiency + : BattleConfig.defendSafeEfficiency; + case RiskLevel.normal: + return actionType == ActionType.attack + ? BattleConfig.attackNormalEfficiency + : BattleConfig.defendNormalEfficiency; + case RiskLevel.risky: + return actionType == ActionType.attack + ? BattleConfig.attackRiskyEfficiency + : BattleConfig.defendRiskyEfficiency; + } + } + /// Calculates success and efficiency based on Risk Level and Luck. static CombatResult calculateActionOutcome({ required ActionType actionType, // New: Action type (attack or defend) required RiskLevel risk, required int luck, required int baseValue, + Random? random, // Injectable Random }) { - double efficiency = 1.0; + final effectiveRandom = random ?? _random; + double efficiency = getEfficiency(actionType, risk); + double baseChance = 0.0; - switch (risk) { case RiskLevel.safe: baseChance = BattleConfig.safeBaseChance; - efficiency = actionType == ActionType.attack - ? BattleConfig.attackSafeEfficiency - : BattleConfig.defendSafeEfficiency; break; case RiskLevel.normal: baseChance = BattleConfig.normalBaseChance; - efficiency = actionType == ActionType.attack - ? BattleConfig.attackNormalEfficiency - : BattleConfig.defendNormalEfficiency; break; case RiskLevel.risky: baseChance = BattleConfig.riskyBaseChance; - efficiency = actionType == ActionType.attack - ? BattleConfig.attackRiskyEfficiency - : BattleConfig.defendRiskyEfficiency; break; } @@ -58,7 +69,7 @@ class CombatCalculator { double chance = baseChance + (luck / 100.0); if (chance > 1.0) chance = 1.0; - bool success = _random.nextDouble() < chance; + bool success = effectiveRandom.nextDouble() < chance; int finalValue = (baseValue * efficiency).toInt(); if (finalValue < 1 && baseValue > 0) finalValue = 1; @@ -146,12 +157,13 @@ class CombatCalculator { /// Tries to apply status effects from attacker's equipment. /// Returns a list of applied effects. - static List getAppliedEffects(Character attacker) { + static List getAppliedEffects(Character attacker, {Random? random}) { + final effectiveRandom = random ?? _random; List appliedEffects = []; for (var item in attacker.equipment.values) { for (var effect in item.effects) { - if (_random.nextInt(100) < effect.probability) { + if (effectiveRandom.nextInt(100) < effect.probability) { appliedEffects.add( StatusEffect( type: effect.type, @@ -164,4 +176,12 @@ class CombatCalculator { } return appliedEffects; } + + /// Calculates if a dodge occurs. + /// [targetDodge] is the total dodge chance percentage (e.g. 5 = 5%). + static bool calculateDodge(int targetDodge, {Random? random}) { + final effectiveRandom = random ?? _random; + if (targetDodge <= 0) return false; + return effectiveRandom.nextInt(100) < targetDodge; + } } diff --git a/lib/game/logic/loot_generator.dart b/lib/game/logic/loot_generator.dart index 9b0ef86..2da65bb 100644 --- a/lib/game/logic/loot_generator.dart +++ b/lib/game/logic/loot_generator.dart @@ -16,6 +16,7 @@ class LootGenerator { int finalHp = template.hpBonus; int finalArmor = template.armorBonus; int finalLuck = template.luck; + int finalDodge = template.dodge; // 0. Normal Rarity: Prefix logic for base stat variations if (template.rarity == ItemRarity.normal) { @@ -44,6 +45,8 @@ class LootGenerator { finalAtk = (finalAtk * mult).floor(); finalHp = (finalHp * mult).floor(); finalArmor = (finalArmor * mult).floor(); + // Dodge typically stays integer, but if we want to scale it: + // finalDodge = (finalDodge * mult).floor(); } } } @@ -75,6 +78,9 @@ class LootGenerator { case StatType.luck: finalLuck += value; break; + case StatType.dodge: // Handle dodge + finalDodge += value; + break; } }); } @@ -117,6 +123,9 @@ class LootGenerator { case StatType.luck: finalLuck += value; break; + case StatType.dodge: // Handle dodge + finalDodge += value; + break; } }); } @@ -130,6 +139,7 @@ class LootGenerator { atkBonus: finalAtk, hpBonus: finalHp, armorBonus: finalArmor, + dodge: finalDodge, // Pass dodge slot: template.slot, effects: template.effects, price: template.price, diff --git a/lib/game/model/entity.dart b/lib/game/model/entity.dart index f8b1a80..3bf8ea9 100644 --- a/lib/game/model/entity.dart +++ b/lib/game/model/entity.dart @@ -12,6 +12,7 @@ class Character { int armor; // Current temporary shield/armor points in battle int baseAtk; int baseDefense; // Base defense stat + int baseDodge; // New: Base dodge chance (e.g. 1 = 1%) int gold; // New: Currency String? image; // New: Image path @@ -32,6 +33,7 @@ class Character { required this.armor, required int atk, this.baseDefense = 0, + this.baseDodge = 1, this.gold = 0, this.image, }) : baseMaxHp = maxHp, @@ -46,6 +48,7 @@ class Character { 'armor': armor, 'baseAtk': baseAtk, 'baseDefense': baseDefense, + 'baseDodge': baseDodge, 'gold': gold, 'image': image, 'equipment': equipment.map((key, value) => MapEntry(key.name, value.id)), @@ -63,6 +66,7 @@ class Character { armor: json['armor'], atk: json['baseAtk'], baseDefense: json['baseDefense'], + baseDodge: json['baseDodge'] ?? 1, gold: json['gold'], image: json['image'], ); @@ -161,7 +165,12 @@ class Character { int get totalAtk { int bonus = equipment.values.fold(0, (sum, item) => sum + item.atkBonus); - return baseAtk + bonus; + int finalAtk = baseAtk + bonus; + + if (hasStatus(StatusEffectType.disarmed)) { + finalAtk = (finalAtk * GameConfig.disarmedDamageMultiplier).toInt(); + } + return finalAtk; } int get totalDefense { @@ -169,6 +178,11 @@ class Character { return baseDefense + bonus; } + int get totalDodge { + int bonus = equipment.values.fold(0, (sum, item) => sum + item.dodge); + return baseDodge + bonus; + } + int get totalLuck { return equipment.values.fold(0, (sum, item) => sum + item.luck); } diff --git a/lib/game/model/item.dart b/lib/game/model/item.dart index 6100ac1..fb3431d 100644 --- a/lib/game/model/item.dart +++ b/lib/game/model/item.dart @@ -27,6 +27,7 @@ class ItemEffect { String typeStr = type.name.toUpperCase(); // Customize names if needed if (type == StatusEffectType.defenseForbidden) typeStr = "UNBLOCKABLE"; + if (type == StatusEffectType.disarmed) typeStr = "DISARM"; String durationStr = "${duration}t"; String valStr = value > 0 ? " ($value dmg)" : ""; @@ -42,6 +43,7 @@ class Item { final int atkBonus; final int hpBonus; final int armorBonus; // New stat for defense + final int dodge; // New: Dodge chance bonus final EquipmentSlot slot; final List effects; // Status effects this item can inflict final int price; // New: Sell/Buy value @@ -57,6 +59,7 @@ class Item { required this.atkBonus, required this.hpBonus, this.armorBonus = 0, // Default to 0 for backward compatibility + this.dodge = 0, // Default to 0 required this.slot, this.effects = const [], // Default to no effects this.price = 0, diff --git a/lib/providers/battle_provider.dart b/lib/providers/battle_provider.dart index c76710b..41ffef8 100644 --- a/lib/providers/battle_provider.dart +++ b/lib/providers/battle_provider.dart @@ -70,8 +70,10 @@ class BattleProvider with ChangeNotifier { // Dependency injection final ShopProvider shopProvider; + final Random _random; // Injected Random instance - BattleProvider({required this.shopProvider}) { + BattleProvider({required this.shopProvider, Random? random}) + : _random = random ?? Random() { // initializeBattle(); // Do not auto-start logic } @@ -134,6 +136,9 @@ class BattleProvider with ChangeNotifier { // Save Game at the start of each stage SaveManager.saveGame(this); + // Reset Player Armor at start of new stage + player.armor = 0; + StageType type; // Stage Type Logic @@ -214,10 +219,10 @@ class BattleProvider with ChangeNotifier { player.hasStatus(StatusEffectType.defenseForbidden)) { _addLog("Cannot defend! You are under Defense Forbidden status."); notifyListeners(); // 상태 변경을 알림 - _endPlayerTurn(); + // _endPlayerTurn(); // Allow player to choose another action return; } - + isPlayerTurn = false; notifyListeners(); @@ -246,28 +251,48 @@ class BattleProvider with ChangeNotifier { risk: risk, luck: player.totalLuck, baseValue: baseValue, + random: _random, // Pass injected random ); if (result.success) { if (type == ActionType.attack) { - int damage = result.value; + // 1. Check for Dodge (Moved from _processAttackImpact) + if (CombatCalculator.calculateDodge(enemy.totalDodge, random: _random)) { // Pass injected random + _addLog("${enemy.name} dodged the attack!"); + final event = EffectEvent( + id: + DateTime.now().millisecondsSinceEpoch.toString() + + _random.nextInt(1000).toString(), // Use injected random + type: ActionType.attack, + risk: risk, + target: EffectTarget.enemy, + feedbackType: BattleFeedbackType.dodge, // Dodge feedback + attacker: player, + targetEntity: enemy, + damageValue: 0, + isSuccess: + false, // Treated as fail for animation purposes (or custom) + ); + _effectEventController.sink.add(event); + } else { + // 2. Hit Success + int damage = result.value; - final event = EffectEvent( - id: - DateTime.now().millisecondsSinceEpoch.toString() + - Random().nextInt(1000).toString(), - type: ActionType.attack, - risk: risk, - target: EffectTarget.enemy, - feedbackType: null, - attacker: player, - targetEntity: enemy, - damageValue: damage, - isSuccess: true, - ); - _effectEventController.sink.add( - event, - ); // No Future.delayed here, BattleScreen will trigger impact + final event = EffectEvent( + id: + DateTime.now().millisecondsSinceEpoch.toString() + + _random.nextInt(1000).toString(), // Use injected random + type: ActionType.attack, + risk: risk, + target: EffectTarget.enemy, + feedbackType: null, + attacker: player, + targetEntity: enemy, + damageValue: damage, + isSuccess: true, + ); + _effectEventController.sink.add(event); + } } else { // Defense Success - Impact is immediate, so process it directly final event = EffectEvent( @@ -348,6 +373,40 @@ class BattleProvider with ChangeNotifier { ); } + /// Recalculates the current enemy intent value based on current stats. + /// Used to update UI when enemy stats change (e.g. Disarmed applied). + void updateEnemyIntent() { + if (currentEnemyIntent == null || enemy.isDead) return; + + final intent = currentEnemyIntent!; + int newValue = 0; + + // Recalculate value based on current stats + if (intent.type == EnemyActionType.attack) { + newValue = (enemy.totalAtk * + CombatCalculator.getEfficiency(ActionType.attack, intent.risk)) + .toInt(); + if (newValue < 1 && enemy.totalAtk > 0) newValue = 1; + } else { + newValue = (enemy.totalDefense * + CombatCalculator.getEfficiency(ActionType.defend, intent.risk)) + .toInt(); + if (newValue < 1 && enemy.totalDefense > 0) newValue = 1; + } + + // Replace intent with updated value, keeping other properties + currentEnemyIntent = EnemyIntent( + type: intent.type, + value: newValue, + risk: intent.risk, + description: "$newValue (${intent.risk.name})", + isSuccess: intent.isSuccess, + finalValue: newValue, + isApplied: intent.isApplied, + ); + notifyListeners(); + } + // --- Turn Management Phases --- // Phase 4: Start Player Turn @@ -364,6 +423,9 @@ class BattleProvider with ChangeNotifier { return; } + // Update Intent if stats changed (e.g. status effects expired) + updateEnemyIntent(); + // [New] Apply Pre-emptive Enemy Intent (Defense/Buffs) // MOVED: Logic moved to applyPendingEnemyDefense() to sync with animation. // We just check intent existence here but do NOT apply effects yet. @@ -430,7 +492,8 @@ class BattleProvider with ChangeNotifier { } // Process Start-of-Turn Effects - bool canAct = _processStartTurnEffects(enemy); + final result = CombatCalculator.processStartTurnEffects(enemy); + bool canAct = !result['isStunned']; if (enemy.isDead) { _onVictory(); @@ -456,17 +519,43 @@ class BattleProvider with ChangeNotifier { } else { // Attack Action (Animating) if (intent.isSuccess) { + // 1. Check for Dodge + if (CombatCalculator.calculateDodge(player.totalDodge, random: _random)) { // Pass injected random + _addLog("${player.name} dodged the attack!"); + final event = EffectEvent( + id: + DateTime.now().millisecondsSinceEpoch.toString() + + _random.nextInt(1000).toString(), // Use injected random + type: ActionType.attack, + risk: intent.risk, + target: EffectTarget.player, + feedbackType: BattleFeedbackType.dodge, + attacker: enemy, + targetEntity: player, + damageValue: 0, + isSuccess: false, + ); + _effectEventController.sink.add(event); + return; + } + + // Recalculate damage to account for status changes (like Disarmed) + int finalDamage = (enemy.totalAtk * + CombatCalculator.getEfficiency(ActionType.attack, intent.risk)) + .toInt(); + if (finalDamage < 1 && enemy.totalAtk > 0) finalDamage = 1; + final event = EffectEvent( id: DateTime.now().millisecondsSinceEpoch.toString() + - Random().nextInt(1000).toString(), + _random.nextInt(1000).toString(), // Use injected random type: ActionType.attack, risk: intent.risk, target: EffectTarget.player, feedbackType: null, attacker: enemy, targetEntity: player, - damageValue: intent.finalValue, + damageValue: finalDamage, isSuccess: true, ); _effectEventController.sink.add(event); @@ -490,16 +579,17 @@ class BattleProvider with ChangeNotifier { _effectEventController.sink.add(event); return; } - } - } else if (!canAct) { - _addLog("Enemy is stunned and cannot act!"); - int tid = _turnTransactionId; - Future.delayed(const Duration(milliseconds: 500), () { - if (tid != _turnTransactionId) return; - _endEnemyTurn(); - }); - } else { - _addLog("Enemy did nothing."); + } + } else if (!canAct) { // If cannot act (stunned) + _addLog("Enemy is stunned and cannot act!"); + int tid = _turnTransactionId; + Future.delayed(const Duration(milliseconds: 500), () { + if (tid != _turnTransactionId) return; + _endEnemyTurn(); + }); + } else { + _addLog("Enemy did nothing."); + int tid = _turnTransactionId; Future.delayed(const Duration(milliseconds: 500), () { if (tid != _turnTransactionId) return; @@ -512,6 +602,9 @@ class BattleProvider with ChangeNotifier { void _endEnemyTurn() { if (player.isDead) return; // Game Over check + // Update enemy status at the end of their turn + enemy.updateStatusEffects(); + // Generate NEXT intent _generateEnemyIntent(); @@ -690,29 +783,45 @@ class BattleProvider with ChangeNotifier { _prepareNextStage(); } + @visibleForTesting + void generateEnemyIntent() { + _generateEnemyIntent(); + } + void _generateEnemyIntent() { if (enemy.isDead) { currentEnemyIntent = null; return; } - final random = Random(); + // Use the injected _random field + // final random = Random(); // Removed // Decide Action Type - bool canDefend = enemy.baseDefense > 0; - if (enemy.hasStatus(StatusEffectType.defenseForbidden)) { - canDefend = false; - } - bool isAttack = true; + // Check constraints + bool canDefend = enemy.baseDefense > 0 && + !enemy.hasStatus(StatusEffectType.defenseForbidden); + bool canAttack = true; // Attack is always possible, but strength is affected by status. - if (canDefend) { - isAttack = random.nextDouble() < BattleConfig.enemyAttackChance; - } else { + bool isAttack = true; // Default to attack + + if (canAttack && canDefend) { + // Both options available: Use configured probability + isAttack = _random.nextDouble() < BattleConfig.enemyAttackChance; + } else if (canAttack) { + // Must attack isAttack = true; + } else if (canDefend) { + // Must defend + isAttack = false; + } else { + // Both forbidden (Rare case, effectively stunned but not via Stun status) + // Default to Defend as a fallback, outcomes will be handled by stats/luck + isAttack = false; } // Decide Risk Level - RiskLevel risk = RiskLevel.values[random.nextInt(RiskLevel.values.length)]; + RiskLevel risk = RiskLevel.values[_random.nextInt(RiskLevel.values.length)]; CombatResult result; if (isAttack) { @@ -872,6 +981,11 @@ class BattleProvider with ChangeNotifier { // Try applying status effects _tryApplyStatusEffects(attacker, target); + + // If target is enemy, update intent to reflect potential status changes (e.g. Disarmed) + if (target == enemy) { + updateEnemyIntent(); + } } else if (event.type == ActionType.defend) { // Defense Impact is immediate (no anim delay from UI) if (event.isSuccess!) { @@ -900,6 +1014,7 @@ class BattleProvider with ChangeNotifier { void _tryApplyStatusEffects(Character attacker, Character target) { List effectsToApply = CombatCalculator.getAppliedEffects( attacker, + random: _random, // Pass injected random ); for (var effect in effectsToApply) { diff --git a/lib/screens/battle_screen.dart b/lib/screens/battle_screen.dart index 5adaee3..3eb3e54 100644 --- a/lib/screens/battle_screen.dart +++ b/lib/screens/battle_screen.dart @@ -248,6 +248,10 @@ class _BattleScreenState extends State { feedbackText = "FAILED"; feedbackColor = ThemeConfig.failedText; break; + case BattleFeedbackType.dodge: + feedbackText = "DODGE"; + feedbackColor = ThemeConfig.statLuckColor; // Use Luck color (Greenish) + break; default: feedbackText = ""; feedbackColor = ThemeConfig.textColorWhite; @@ -649,14 +653,15 @@ class _BattleScreenState extends State { !battleProvider.enemy.isDead && !battleProvider.showRewardPopup && !_isPlayerAttacking && - !_isEnemyAttacking, + !_isEnemyAttacking, // Enabled even if disarmed (damage reduced) isDefendEnabled: battleProvider.isPlayerTurn && !battleProvider.player.isDead && !battleProvider.enemy.isDead && !battleProvider.showRewardPopup && !_isPlayerAttacking && - !_isEnemyAttacking, + !_isEnemyAttacking && + !battleProvider.player.hasStatus(StatusEffectType.defenseForbidden), // Disable if defense is forbidden onAttackPressed: () => _showRiskLevelSelection(context, ActionType.attack), onDefendPressed: () => @@ -859,6 +864,7 @@ class _BattleScreenState extends State { if (item.hpBonus > 0) stats.add("+${item.hpBonus} ${AppStrings.hp}"); if (item.armorBonus > 0) stats.add("+${item.armorBonus} ${AppStrings.def}"); if (item.luck > 0) stats.add("+${item.luck} ${AppStrings.luck}"); + if (item.dodge > 0) stats.add("+${item.dodge}% Dodge"); // Add Dodge List effectTexts = item.effects.map((e) => e.description).toList(); diff --git a/lib/widgets/inventory/character_stats_widget.dart b/lib/widgets/inventory/character_stats_widget.dart index 67fa5ca..d479afa 100644 --- a/lib/widgets/inventory/character_stats_widget.dart +++ b/lib/widgets/inventory/character_stats_widget.dart @@ -35,18 +35,23 @@ class CharacterStatsWidget extends StatelessWidget { _buildStatItem( AppStrings.atk, "${player.totalAtk}", - color: ThemeConfig.statAtkColor, + // color: ThemeConfig.statAtkColor, ), _buildStatItem( AppStrings.def, "${player.totalDefense}", - color: ThemeConfig.statDefColor, + // color: ThemeConfig.statDefColor, ), - _buildStatItem(AppStrings.armor, "${player.armor}"), + // _buildStatItem(AppStrings.armor, "${player.armor}"), _buildStatItem( AppStrings.luck, "${player.totalLuck}", - color: ThemeConfig.statLuckColor, + // color: ThemeConfig.statLuckColor, + ), + _buildStatItem( + "Dodge", // TODO: Add to AppStrings + "${player.totalDodge}%", + // color: ThemeConfig.statLuckColor, ), _buildStatItem( AppStrings.gold, diff --git a/lib/widgets/inventory/inventory_grid_widget.dart b/lib/widgets/inventory/inventory_grid_widget.dart index fc8b238..bec9255 100644 --- a/lib/widgets/inventory/inventory_grid_widget.dart +++ b/lib/widgets/inventory/inventory_grid_widget.dart @@ -263,6 +263,11 @@ class InventoryGridWidget extends StatelessWidget { player.totalLuck, player.totalLuck - (oldItem?.luck ?? 0) + newItem.luck, ), + _buildStatChangeRow( + "Dodge", + player.totalDodge, + player.totalDodge - (oldItem?.dodge ?? 0) + newItem.dodge, + ), ], ), actions: [ diff --git a/prompt/00_project_context_restore.md b/prompt/00_project_context_restore.md index a5ce60b..4a69b55 100644 --- a/prompt/00_project_context_restore.md +++ b/prompt/00_project_context_restore.md @@ -32,6 +32,10 @@ - **Normal:** 성공률 80%+, 효율 100%. - **Risky:** 성공률 40%+, 효율 200% (성공 시 강력한 이펙트). - **Luck 보정:** `totalLuck` 1당 성공률 +1%. +- **회피 시스템 (Dodge System):** + - 캐릭터는 `dodge` 스탯을 가지며, 공격을 회피할 확률이 생김. + - `CombatCalculator`에서 회피 성공 여부를 계산. + - 공격이 회피되면 `dodge` 피드백과 함께 데미지가 0으로 처리됨. - **적 인공지능 (Enemy AI & Intent):** - **Intent UI:** 적의 다음 행동(공격/방어, 데미지/방어도) 미리 표시. - **동기화된 애니메이션:** 적 행동 결정(`_generateEnemyIntent`)은 이전 애니메이션이 완전히 끝난 후 이루어짐. @@ -40,7 +44,7 @@ - **UI 주도 Impact 처리:** 애니메이션 타격 시점(`onImpact`)에 정확히 데미지가 적용되고 텍스트가 뜸 (완벽한 동기화). - **적 돌진:** 적도 공격 시 플레이어 위치로 돌진함 (설정에서 끄기 가능). - **이펙트:** 타격 아이콘, 데미지 텍스트(Floating Text, Risky 공격 시 크기 확대), 화면 흔들림(`ShakeWidget`), 폭발(`ExplosionWidget`). **적 방어 시 성공/실패 이펙트 추가.** -- **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`. +- **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`, `Disarmed`. - **UI 알림 (Toast):** 하단 네비게이션을 가리지 않는 상단 `Overlay` 기반 알림 시스템. ### C. 데이터 및 로직 (Architecture) @@ -48,114 +52,11 @@ - **Data-Driven:** `items.json`, `enemies.json`, `players.json`. - **Logic 분리:** - `BattleProvider`: UI 상태 관리 및 이벤트 스트림(`damageStream`, `effectStream`) 발송. - - `CombatCalculator`: 데미지 공식, 확률 계산, 상태이상 로직 순수 함수화. **공격/방어 액션 타입별 효율 분리.** - - `BattleLogManager`: 전투 로그 관리. - - `LootGenerator`: 아이템 생성, 접두사(Prefix) 부여, 랜덤 스탯 로직. - - `SettingsProvider`: 전역 설정(애니메이션 on/off 등) 관리 및 영구 저장. -- **Soft i18n:** UI 텍스트는 `lib/game/config/app_strings.dart`에서 통합 관리. -- **Config:** `GameConfig`, `BattleConfig`, `ItemConfig` 등 설정 값 중앙화. **BattleConfig의 공격/방어 효율 분리.** - -### D. 아이템 및 경제 - -- **장비:** 무기, 방어구, 방패, 장신구. -- **시스템:** - - **Rarity:** Common ~ Unique. - - **Tier:** 라운드 진행도에 따라 상위 티어 아이템 등장. - - **Prefix:** Rarity에 따라 접두사가 붙으며 스탯이 변형됨 (예: "Sharp Wooden Sword"). -- **상점 (`ShopProvider`):** 아이템 구매/판매, 리롤(Reroll), 인벤토리 관리. - -### E. 저장 및 진행 (Persistence) - -- **자동 저장:** 스테이지 클리어 시 `SaveManager`를 통해 자동 저장. -- **Permadeath:** 패배 시 저장 데이터 삭제 (로그라이크 요소). - -### G. 스테이지 시스템 (Stage System) - -- **Map Generation:** 진행에 따라 랜덤하게 다음 스테이지 타입이 결정됨 (현재는 단순 랜덤). -- **Underground Colosseum System (Rounds/Tiers):** - - **Round Progression:** 스테이지 진행(`stage` count)에 따라 난이도(Tier)가 상승. - - **Tier:** - - Tier 1: Stage 1 ~ 12 (지하 불법 투기장) - - Tier 2: Stage 13 ~ 24 (콜로세움) - - Tier 3: Stage 25+ (왕의 투기장) -- **Stage Types:** - - **Battle:** 일반 몬스터 전투. - - **Elite:** 강화된 몬스터 전투 (보상 증가, 12 스테이지마다 등장). - - **Shop:** 아이템 구매/판매/리롤 (5 스테이지마다 등장). - - **Rest:** 휴식 (8 스테이지마다 등장). - -### F. 코드 구조 (Code Structure - Barrel Pattern) - -- **Barrel File Pattern:** `lib/` 내의 모든 주요 디렉토리는 해당 폴더의 파일들을 묶어주는 단일 진입점 파일(`.dart`)을 가집니다. - - `lib/game/models.dart`, `lib/game/config.dart`, `lib/game/data.dart`, `lib/game/logic.dart` - - `lib/providers.dart`, `lib/utils.dart`, `lib/screens.dart`, `lib/widgets.dart` -- **Imports:** 개별 파일 import 대신 위 Barrel File을 사용하여 가독성과 유지보수성을 높였습니다. - -## 3. 작업 컨벤션 (Working Conventions) - -- **Prompt Driven Development:** `prompt/XX_description.md` 유지. (유사 작업 통합 및 인덱스 정리 권장) -- **i18n Strategy (Soft i18n):** UI에 표시되는 문자열은 하드코딩하지 않고 `lib/game/config/app_strings.dart`의 상수를 사용해야 합니다. (전투 로그 등 동적 문자열 제외) -- **Config Management:** 하드코딩되는 값들은 `config` 폴더 내 파일들(`lib/game/config/` 등)에서 통합 관리할 수 있도록 작성해야 합니다. -- **State Management:** `Provider` (UI 상태) + `Stream` (이벤트성 데이터). -- **Data:** JSON 기반 + `Table` 클래스로 로드. -- **Barrel File Pattern (Strict):** `lib/` 하위의 모든 주요 디렉토리는 Barrel File을 유지해야 하며, 외부에서 참조 시 **반드시** 이 Barrel File을 import 해야 합니다. 개별 파일에 대한 직접 import는 허용되지 않습니다. - -## 4. 최근 주요 변경 사항 (Change Log) - -- **[Refactor] BattleProvider:** `CombatCalculator`, `BattleLogManager`, `LootGenerator` 분리로 코드 다이어트. -- **[Refactor] Animation Sync:** `Future.delayed` 예측 방식을 버리고, UI 애니메이션 콜백(`onImpact`)을 통해 로직을 트리거하는 방식으로 변경하여 타격감 동기화 해결. -- **[Refactor] Settings:** `SettingsProvider` 도입 및 적 애니메이션 토글 기능 추가. - -# 00. 프로젝트 컨텍스트 및 복구 (Project Context & Restore Point) - -이 파일은 다른 개발 환경이나 새로운 AI 세션에서 프로젝트의 현재 상태를 빠르게 파악하고 작업을 이어가기 위해 작성되었습니다. - -## 1. 프로젝트 개요 - -- **프로젝트명:** Colosseum's Choice -- **플랫폼:** Flutter (Android/iOS/Web/Desktop) -- **장르:** 텍스트 기반의 턴제 RPG + GUI (로그라이크 요소 포함) -- **상태:** 핵심 시스템 구현 완료 및 안정화 (i18n 구조 적용, 애니메이션 동기화 완료) - -## 2. 현재 구현된 핵심 기능 (Feature Status) - -### A. 게임 흐름 (Game Flow) - -1. **메인 메뉴 (`MainMenuScreen`):** 게임 시작, 이어하기(저장된 데이터 있을 시), 설정 버튼. -2. **캐릭터 선택 (`CharacterSelectionScreen`):** 'Warrior' 직업 구현 (스탯 확인 후 시작). -3. **메인 게임 (`MainWrapper`):** 하단 탭 네비게이션 (Battle / Inventory / Settings). -4. **설정 (`SettingsScreen`):** - - 적 애니메이션 활성화/비활성화 토글 (`SettingsProvider` 연동). - - 게임 재시작, 메인 메뉴로 돌아가기 기능. -5. **반응형 레이아웃 (Responsive UI):** - - `ResponsiveContainer`를 통해 다양한 화면 크기 대응 (최대 너비/높이 제한). - - Battle UI: 플레이어(좌하단) vs 적(우상단) 대각선 구도. - -### B. 전투 시스템 (`BattleProvider`) - -- **턴제 전투:** 플레이어 턴 -> 적 턴. -- **행동 선택:** 공격(Attack) / 방어(Defend). -- **리스크 시스템 (Risk System):** - - **Safe:** 성공률 100%+, 효율 50%. - - **Normal:** 성공률 80%+, 효율 100%. - - **Risky:** 성공률 40%+, 효율 200% (성공 시 강력한 이펙트). - - **Luck 보정:** `totalLuck` 1당 성공률 +1%. -- **적 인공지능 (Enemy AI & Intent):** - - **Intent UI:** 적의 다음 행동(공격/방어, 데미지/방어도) 미리 표시. - - **동기화된 애니메이션:** 적 행동 결정(`_generateEnemyIntent`)은 이전 애니메이션이 완전히 끝난 후 이루어짐. - - **선제 방어:** 적이 방어 행동을 선택하면 턴 시작 시 **데이터상으로 즉시 방어도가 적용되나, 시각적 애니메이션은 플레이어가 행동을 선택하는 시점에 발동됨.** -- **애니메이션 및 타격감 (Visuals & Impact):** - - **UI 주도 Impact 처리:** 애니메이션 타격 시점(`onImpact`)에 정확히 데미지가 적용되고 텍스트가 뜸 (완벽한 동기화). - - **적 돌진:** 적도 공격 시 플레이어 위치로 돌진함 (설정에서 끄기 가능). - - **이펙트:** 타격 아이콘, 데미지 텍스트(Floating Text, Risky 공격 시 크기 확대), 화면 흔들림(`ShakeWidget`), 폭발(`ExplosionWidget`). **적 방어 시 성공/실패 이펙트 추가.** -- **상태이상:** `Stun`, `Bleed`, `Vulnerable`, `DefenseForbidden`. - -### C. 데이터 및 로직 (Architecture) - -- **Data-Driven:** `items.json`, `enemies.json`, `players.json`. -- **Logic 분리:** - - `BattleProvider`: UI 상태 관리 및 이벤트 스트림(`damageStream`, `effectStream`) 발송. - - `CombatCalculator`: 데미지 공식, 확률 계산, 상태이상 로직 순수 함수화. **공격/방어 액션 타입별 효율 분리.** + - `CombatCalculator`: 데미지 공식, 확률 계산, 상태이상 로직을 순수 함수화하여 전투 로직의 재사용성과 테스트 용이성을 높임. + - **공격/방어 액션 타입별 효율 분리:** 각 행동(공격/방어) 및 리스크 레벨(`Safe`, `Normal`, `Risky`)에 따른 효율(`efficiency`)을 분리하여 적용. + - **받는 피해 계산:** 취약성(`Vulnerable`) 적용 및 현재 방어도를 통한 데미지 흡수 계산 (`calculateDamageToHp`, `calculateRemainingArmor`). + - **주는 피해 계산:** 공격자가 `Disarmed` 상태일 경우, 최종 데미지 값에 대한 감폭 적용 (`Character.totalAtk` 게터에서 처리). + - **기타 계산:** 회피 확률(`calculateDodge`), 턴 시작 효과(`processStartTurnEffects`), 아이템에 의한 상태이상 적용 확률(`getAppliedEffects`) 등을 담당. - `BattleLogManager`: 전투 로그 관리. - `LootGenerator`: 아이템 생성, 접두사(Prefix) 부여, 랜덤 스탯 로직. - `SettingsProvider`: 전역 설정(애니메이션 on/off 등) 관리 및 영구 저장. @@ -209,6 +110,8 @@ ## 4. 최근 주요 변경 사항 (Change Log) +- **[Feature] Dynamic Intent UI:** 적의 상태(예: Disarmed) 변화 시, UI에 표시되는 인텐트(공격/방어 값)가 실시간으로 갱신되도록 로직을 개선하여 전투 정보의 정확성과 직관성 향상. +- **[Feature] Dodge Mechanic:** 캐릭터의 `dodge` 스탯에 기반한 공격 회피 시스템 구현. 회피 시 전용 피드백과 함께 데미지 무효화. - **[Refactor] BattleProvider:** `CombatCalculator`, `BattleLogManager`, `LootGenerator` 분리로 코드 다이어트. - **[Refactor] Animation Sync:** `Future.delayed` 예측 방식을 버리고, UI 애니메이션 콜백(`onImpact`)을 통해 로직을 트리거하는 방식으로 변경하여 타격감 동기화 해결. - **[Refactor] Settings:** `SettingsProvider` 도입 및 적 애니메이션 토글 기능 추가. @@ -228,4 +131,4 @@ 1. **밸런싱:** 현재 몬스터 및 아이템 스탯 미세 조정. 2. **콘텐츠 확장:** 더 많은 아이템, 적, 스킬 패턴 추가. -3. **튜토리얼:** 신규 유저를 위한 가이드 추가. +3. **튜토리얼:** 신규 유저를 위한 가이드 추가. \ No newline at end of file diff --git a/prompt/65_dynamic_intent_ui_update.md b/prompt/65_dynamic_intent_ui_update.md new file mode 100644 index 0000000..cb6e41d --- /dev/null +++ b/prompt/65_dynamic_intent_ui_update.md @@ -0,0 +1,26 @@ +# 65. 동적 인텐트 UI 갱신 및 로직 보강 + +## 목적 +적에게 '무장 해제(Disarmed)'와 같은 상태 이상이 적용되거나 해제될 때, UI에 표시되는 적의 행동 예고(인텐트) 값이 실시간으로 변경되도록 시스템을 개선합니다. 이를 통해 플레이어는 더 정확한 정보를 바탕으로 전략적인 결정을 내릴 수 있습니다. + +## 주요 변경 사항 + +### 1. 동적 인텐트 재계산 로직 추가 (`BattleProvider`) +- `updateEnemyIntent()` 메서드를 추가하여, 현재 적의 능력치(`totalAtk`, `totalDefense`)를 기준으로 인텐트의 공격 값이나 방어 값을 다시 계산하는 기능을 구현했습니다. +- 이 메서드는 기존 인텐트의 `risk`와 `type`은 유지하면서, 변경된 스탯에 따른 결과 값만 갱신하여 UI에 즉시 반영합니다. + +### 2. 인텐트 UI 갱신 트리거 추가 +- **플레이어 공격 시:** 플레이어의 공격으로 적에게 상태 이상이 적용되는 시점(`_processAttackImpact` 내부)에 `updateEnemyIntent()`를 호출하여, UI의 인텐트 값이 즉시 변경되도록 했습니다. (예: '무장 해제' 적용 시 데미지 수치 감소) +- **턴 시작 시:** 플레이어 턴이 시작될 때(`_startPlayerTurn` 내부)에도 `updateEnemyIntent()`를 호출하여, 만료된 상태 이상 효과가 인텐트 값에 반영되도록 했습니다. + +### 3. 상태 이상 처리 로직 보강 +- 적의 턴이 끝나는 시점(`_endEnemyTurn` 내부)에 `enemy.updateStatusEffects()`를 호출하도록 추가했습니다. +- 이를 통해 적의 상태 이상 지속시간이 매 턴 정확하게 감소하며, 다음 턴의 인텐트가 가장 최신 상태를 기준으로 생성되도록 보장합니다. + +### 4. `CombatCalculator` 리팩토링 +- `getEfficiency` 헬퍼 메서드를 추가하여, 행동 타입과 위험도에 따른 효율 계수를 가져오는 로직을 중앙화하고 코드 중복을 제거했습니다. + +## 기대 효과 +- 플레이어는 적의 상태 변화(버프/디버프)가 적의 다음 행동에 미치는 영향을 UI를 통해 직관적으로 확인할 수 있습니다. +- 전투 정보의 투명성이 향상되어 게임의 전략적 깊이가 더해집니다. +- 상태 이상 시스템과 전투 로직의 일관성 및 안정성이 강화됩니다. diff --git a/test/battle_provider_test.dart b/test/battle_provider_test.dart new file mode 100644 index 0000000..3f39329 --- /dev/null +++ b/test/battle_provider_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; // Import SharedPreferences +import 'package:game_test/providers/battle_provider.dart'; +import 'package:game_test/providers/shop_provider.dart'; +import 'package:game_test/game/models.dart'; +import 'package:game_test/game/enums.dart'; +import 'package:game_test/game/data/item_table.dart'; + +void main() { + group('BattleProvider Armor Reset Test', () { + late BattleProvider battleProvider; + late ShopProvider shopProvider; + + setUp(() { + // Fix Binding has not yet been initialized error + TestWidgetsFlutterBinding.ensureInitialized(); + // Mock SharedPreferences + SharedPreferences.setMockInitialValues({}); + + // Mock ItemTable data to prevent RangeError in initializeBattle + // initializeBattle accesses indices up to 5 for weapons and 3 for shields + ItemTable.weapons = List.generate( + 10, + (index) => ItemTemplate( + id: "weapon_$index", + name: "Weapon $index", + description: "Test Weapon", + atkBonus: 10, + hpBonus: 0, + armorBonus: 0, + slot: EquipmentSlot.weapon, + effects: [], + price: 10, + ), + ); + ItemTable.shields = List.generate( + 10, + (index) => ItemTemplate( + id: "shield_$index", + name: "Shield $index", + description: "Test Shield", + atkBonus: 0, + hpBonus: 0, + armorBonus: 10, + slot: EquipmentSlot.shield, + effects: [], + price: 10, + ), + ); + // Initialize other lists to empty to avoid null pointer if accessed loosely + ItemTable.armors = []; + ItemTable.accessories = []; + + shopProvider = ShopProvider(); + battleProvider = BattleProvider(shopProvider: shopProvider); + battleProvider.initializeBattle(); // Initialize player and stage 1 + }); + + test('Armor should be reset to 0 when proceeding to next stage', () { + // 1. Setup initial state + battleProvider.player.armor = 50; + expect( + battleProvider.player.armor, + 50, + reason: "Player armor should be set to 50 initially.", + ); + + // 2. Simulate proceeding to next stage + // Using proceedToNextStage which calls _prepareNextStage internally + battleProvider.proceedToNextStage(); + + // 3. Verify armor is reset + expect(battleProvider.stage, 2, reason: "Stage should advance to 2."); + expect( + battleProvider.player.armor, + 0, + reason: "Player armor should be reset to 0 in the new stage.", + ); + }); + + test('Armor should be reset to 0 when re-initializing battle', () { + // 1. Setup initial state + battleProvider.player.armor = 20; + expect(battleProvider.player.armor, 20); + + // 2. Re-initialize + battleProvider.initializeBattle(); + + // 3. Verify armor is reset + expect(battleProvider.stage, 1); + expect( + battleProvider.player.armor, + 0, + reason: "Player armor should be reset to 0 on initialization.", + ); + }); + }); +} diff --git a/test/disarm_test.dart b/test/disarm_test.dart new file mode 100644 index 0000000..a2d7889 --- /dev/null +++ b/test/disarm_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:game_test/providers/battle_provider.dart'; +import 'package:game_test/providers/shop_provider.dart'; +import 'package:game_test/game/models.dart'; +import 'package:game_test/game/enums.dart'; +import 'package:game_test/game/data/item_table.dart'; +import 'package:game_test/game/config/game_config.dart'; // Import GameConfig for multiplier +import 'dart:math'; // Import dart:math + +void main() { + group('Disarm Mechanic (Weakened Attack) Test', () { + late BattleProvider battleProvider; + late ShopProvider shopProvider; + + setUp(() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + // Mock ItemTable + ItemTable.weapons = []; + ItemTable.shields = []; + ItemTable.armors = []; + ItemTable.accessories = []; + + shopProvider = ShopProvider(); + // Pass a fixed seed Random for predictable intent generation + battleProvider = BattleProvider( + shopProvider: shopProvider, + random: Random(0), + ); + + battleProvider.enemy = Character( + name: "Enemy", + maxHp: 100, + armor: 0, + atk: 50, // High base ATK for clear percentage reduction + baseDefense: + 0, // Set to 0 to make canDefend false for predictable intent + ); + + battleProvider.player = Character( + name: "Player", + maxHp: 100, + armor: 0, + atk: 50, // High base ATK for clear percentage reduction + baseDefense: 10, + ); + }); + + test('Enemy totalAtk reduced to 10% when Disarmed (Attack Forbidden)', () { + // 1. Verify initial ATK + expect(battleProvider.enemy.totalAtk, 50); + + // 2. Apply Disarm + battleProvider.enemy.addStatusEffect( + StatusEffect(type: StatusEffectType.disarmed, duration: 2, value: 0), + ); + + // 3. Verify ATK is reduced to 10% + final expectedAtk = (50 * GameConfig.disarmedDamageMultiplier).toInt(); + expect(battleProvider.enemy.totalAtk, expectedAtk); + + // 4. Verify enemy still generates an attack intent (now predictable due to baseDefense: 0) + battleProvider.generateEnemyIntent(); + final intent = battleProvider.currentEnemyIntent; + expect(intent, isNotNull); + expect(intent!.type, EnemyActionType.attack); + }); + + test( + 'Player totalAtk reduced to 10% when Disarmed (Attack Forbidden) and turn proceeds', + () async { + // 1. Verify initial ATK + expect(battleProvider.player.totalAtk, 50); + + // 2. Apply Disarm to Player + battleProvider.player.addStatusEffect( + StatusEffect(type: StatusEffectType.disarmed, duration: 2, value: 0), + ); + + // 3. Verify ATK is reduced to 10% + final expectedAtk = (50 * GameConfig.disarmedDamageMultiplier).toInt(); + expect(battleProvider.player.totalAtk, expectedAtk); + + battleProvider.isPlayerTurn = true; + + // 4. Attempt Attack - it should now proceed, not be rejected + await battleProvider.playerAction(ActionType.attack, RiskLevel.safe); + + // 5. Verify turn ended (proceeded) + expect( + battleProvider.isPlayerTurn, + false, + reason: "Turn should end after successful (weakened) action.", + ); + + // 6. Verify log no longer contains "Cannot attack" (optional but good) + expect( + battleProvider.logs.last, + isNot(contains("Cannot attack")), + reason: "Should not log 'Cannot attack' anymore.", + ); + expect( + battleProvider.logs.last, + contains("Player chose to attack with safe risk"), + reason: "Should log normal attack.", + ); + }, + ); + }); +}