Compare commits

...

5 Commits

Author SHA1 Message Date
JiWoong Sul
a48f4886d7 test: 밸런스 분석 및 상수 테스트 추가
- balance_analysis_test 추가
- balance_constants_test 추가
- 기존 테스트 업데이트
2026-01-12 20:03:00 +09:00
JiWoong Sul
1d855b64a2 feat(ui): 게임 화면 및 설정 화면 개선
- GamePlayScreen 개선
- GameSessionController 확장
- MobileCarouselLayout 기능 추가
- SettingsScreen 테스트 기능 추가
2026-01-12 20:02:54 +09:00
JiWoong Sul
12f195bed7 refactor(core): 모델 및 유틸리티 개선
- GameState 확장
- BalanceConstants 조정
- PqLogic, Roman 정리
2026-01-12 20:02:50 +09:00
JiWoong Sul
a1d22369cb feat(engine): 엔진 서비스 개선 및 테스트 캐릭터 서비스 추가
- ProgressService 로직 개선
- RewardService 확장
- CombatCalculator, ItemService 정리
- TestCharacterService 추가
2026-01-12 20:02:45 +09:00
JiWoong Sul
d23dcd1e6f refactor(audio): SFX 풀 크기 증가 (배속 전투 대응) 2026-01-12 20:02:39 +09:00
24 changed files with 1107 additions and 186 deletions

View File

@@ -61,7 +61,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
super.initState();
const config = PqConfig();
final mutations = GameMutations(config);
final rewards = RewardService(mutations);
final rewards = RewardService(mutations, config);
_controller = GameSessionController(
progressService: ProgressService(

View File

@@ -65,9 +65,9 @@ class AudioService {
SfxChannelPool? _playerSfxPool;
SfxChannelPool? _monsterSfxPool;
// 채널별 풀 크기 (줄임: 동시 재생 문제 완화)
static const int _playerPoolSize = 2;
static const int _monsterPoolSize = 2;
// 채널별 풀 크기 (배속 전투에서 사운드 누락 방지)
static const int _playerPoolSize = 4;
static const int _monsterPoolSize = 4;
// 현재 볼륨
double _bgmVolume = 0.7;

View File

@@ -98,9 +98,9 @@ class CombatCalculator {
final isParried = parryRoll < defenderParryRate;
// 3. 기본 데미지 계산 (0.8 ~ 1.2 변동)
// DEF 감산 비율: 0.3 (기존 0.5에서 축소하여 의미있는 피해 보장)
// DEF 감산 비율: 0.5 (방어력 효과 상향, 몬스터 ATK 하향과 연동)
final damageVariation = 0.8 + rng.nextDouble() * 0.4;
var baseDamage = (attackerAtk * damageVariation - defenderDef * 0.3);
var baseDamage = (attackerAtk * damageVariation - defenderDef * 0.5);
// 4. 크리티컬 판정
final criRoll = rng.nextDouble();

View File

@@ -135,30 +135,36 @@ class ItemService {
}
/// 방패 스탯 생성
///
/// DEF 배율 조정 (v2): 방패 DEF를 0.15배로 축소
ItemStats _generateShieldStats(int baseValue, ItemRarity rarity) {
final blockBonus = 0.05 + rarity.index * 0.02;
final def = (baseValue * 0.15).round();
return ItemStats(def: baseValue ~/ 2, blockRate: blockBonus);
return ItemStats(def: def, blockRate: blockBonus);
}
/// 방어구 스탯 생성
///
/// DEF 배율 조정 (v2): 9개 방어구 합산 DEF ≈ 무기 ATK × 1.6
/// 기존 배율(합계 8.0)에서 대폭 축소하여 일반 공격 데미지 정상화
ItemStats _generateArmorStats(
int baseValue,
ItemRarity rarity,
EquipmentSlot slot,
) {
// 슬롯별 방어력 가중치
// 슬롯별 방어력 가중치 (총합 ~1.6으로 축소)
final defMultiplier = switch (slot) {
EquipmentSlot.hauberk => 1.5, // 갑옷류 최고
EquipmentSlot.helm => 1.2,
EquipmentSlot.gambeson => 1.0,
EquipmentSlot.cuisses => 0.9,
EquipmentSlot.greaves => 0.8,
EquipmentSlot.brassairts => 0.7,
EquipmentSlot.vambraces => 0.7,
EquipmentSlot.gauntlets => 0.6,
EquipmentSlot.sollerets => 0.6,
_ => 0.5,
EquipmentSlot.hauberk => 0.30, // 갑옷류 최고
EquipmentSlot.helm => 0.25,
EquipmentSlot.gambeson => 0.20,
EquipmentSlot.cuisses => 0.18,
EquipmentSlot.greaves => 0.16,
EquipmentSlot.brassairts => 0.14,
EquipmentSlot.vambraces => 0.14,
EquipmentSlot.gauntlets => 0.12,
EquipmentSlot.sollerets => 0.12,
_ => 0.10,
};
final def = (baseValue * defMultiplier).round();

View File

@@ -6,6 +6,7 @@ import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
import 'package:asciineverdie/src/core/engine/potion_service.dart';
import 'package:asciineverdie/src/core/engine/reward_service.dart';
import 'package:asciineverdie/src/core/engine/shop_service.dart';
import 'package:asciineverdie/src/core/engine/skill_service.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/combat_state.dart';
@@ -13,6 +14,7 @@ import 'package:asciineverdie/src/core/model/combat_stats.dart';
import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/model/potion.dart';
@@ -514,12 +516,15 @@ class ProgressService {
return (progress: progress, queue: queue);
}
// 2. kill 태스크가 아니었고 heading도 아니면 heading 또는 buying 태스크 실행
// (원본 670-677줄)
if (oldTaskType != TaskType.kill && oldTaskType != TaskType.neutral) {
// Gold가 충분하면 장비 구매 (원본 671-673줄)
// 2. kill/heading/buying 태스크가 아니었으면 heading 또는 buying 태스크 실행
// (원본 670-677줄) - buying 완료 후 무한 루프 방지
if (oldTaskType != TaskType.kill &&
oldTaskType != TaskType.neutral &&
oldTaskType != TaskType.buying) {
// Gold가 충분하면 장비 구매 (Common 장비 가격 기준)
// 실제 구매 가격과 동일한 공식 사용: level * 50
final gold = _getGold(state);
final equipPrice = _equipPrice(state.traits.level);
final equipPrice = state.traits.level * 50; // Common 장비 1개 가격
if (gold > equipPrice) {
final taskResult = pq_logic.startTask(
progress,
@@ -966,8 +971,12 @@ class ProgressService {
final nextLevel = state.traits.level + 1;
final rng = state.rng;
final hpGain = state.stats.con ~/ 3 + 1 + rng.nextInt(4);
final mpGain = state.stats.intelligence ~/ 3 + 1 + rng.nextInt(4);
// HP/MP 증가량 (PlayerScaling 기반 + 랜덤 변동)
// 기존: CON/3 + 1 + random(0-3) → ~6-9 HP/레벨 (너무 낮음)
// 신규: 18 + CON/5 + random(0-4) → ~20-25 HP/레벨 (생존율 개선)
final hpGain = 18 + state.stats.con ~/ 5 + rng.nextInt(5);
final mpGain = 6 + state.stats.intelligence ~/ 5 + rng.nextInt(3);
var nextState = state.copyWith(
traits: state.traits.copyWith(level: nextLevel),
@@ -1078,29 +1087,48 @@ class ProgressService {
return state.inventory.gold;
}
/// 장비 가격 계산 (원본 Main.pas:612-616)
/// Result := 5 * Level^2 + 10 * Level + 20
int _equipPrice(int level) {
return 5 * level * level + 10 * level + 20;
}
/// 장비 구매 완료 처리 (원본 Main.pas:631-634)
/// 장비 구매 완료 처리 (개선된 로직)
///
/// 1순위: 빈 슬롯에 Common 장비 최대한 채우기
/// 2순위: 골드 남으면 물약 구매
GameState _completeBuying(GameState state) {
var nextState = state;
final level = state.traits.level;
final price = _equipPrice(level);
final shopService = ShopService(rng: nextState.rng);
// Gold 차감 (inventory.gold 필드 사용)
final newGold = math.max(0, state.inventory.gold - price);
var nextState = state.copyWith(
inventory: state.inventory.copyWith(gold: newGold),
);
// 1. 빈 슬롯 목록 수집
final emptySlots = <int>[];
for (var i = 0; i < Equipment.slotCount; i++) {
if (nextState.equipment.getItemByIndex(i).isEmpty) {
emptySlots.add(i);
}
}
// 장비 획득 (WinEquip)
// 원본 Main.pas:797 - posn := Random(Equips.Items.Count); (11개 슬롯)
final slotIndex = nextState.rng.nextInt(Equipment.slotCount);
nextState = mutations.winEquipByIndex(nextState, level, slotIndex);
// 2. 골드가 허용하는 한 빈 슬롯에 Common 장비 구매
for (final slotIndex in emptySlots) {
final slot = EquipmentSlot.values[slotIndex];
final item = shopService.generateShopItem(
playerLevel: level,
slot: slot,
targetRarity: ItemRarity.common,
);
final price = shopService.calculateBuyPrice(item);
// 물약 자동 구매 (남은 골드의 20% 사용)
if (nextState.inventory.gold >= price) {
nextState = nextState.copyWith(
inventory: nextState.inventory.copyWith(
gold: nextState.inventory.gold - price,
),
equipment: nextState.equipment
.setItemByIndex(slotIndex, item)
.copyWith(bestIndex: slotIndex),
);
} else {
break; // 골드 부족 시 중단
}
}
// 3. 물약 자동 구매 (남은 골드의 20% 사용)
final potionService = const PotionService();
final purchaseResult = potionService.autoPurchasePotions(
playerLevel: level,
@@ -1644,30 +1672,35 @@ class ProgressService {
required String killerName,
required DeathCause cause,
}) {
// 상실할 장비 개수 계산
final lostCount = state.equipment.equippedItems.length;
// 사망 직전 전투 이벤트 저장 (최대 10개)
final lastCombatEvents =
state.progress.currentCombat?.recentEvents ?? const [];
// 빈 장비 생성 (기본 무기만 유지)
final emptyEquipment = Equipment(
items: [
EquipmentItem.defaultWeapon(),
EquipmentItem.empty(EquipmentSlot.shield),
EquipmentItem.empty(EquipmentSlot.helm),
EquipmentItem.empty(EquipmentSlot.hauberk),
EquipmentItem.empty(EquipmentSlot.brassairts),
EquipmentItem.empty(EquipmentSlot.vambraces),
EquipmentItem.empty(EquipmentSlot.gauntlets),
EquipmentItem.empty(EquipmentSlot.gambeson),
EquipmentItem.empty(EquipmentSlot.cuisses),
EquipmentItem.empty(EquipmentSlot.greaves),
EquipmentItem.empty(EquipmentSlot.sollerets),
],
bestIndex: 0,
);
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
// 장착된 비무기 슬롯 인덱스 수집 (슬롯 1~10 중 장비가 있는 것)
final equippedNonWeaponSlots = <int>[];
for (var i = 1; i < Equipment.slotCount; i++) {
if (state.equipment.getItemByIndex(i).isNotEmpty) {
equippedNonWeaponSlots.add(i);
}
}
// 제물로 바칠 장비 선택 및 삭제
var newEquipment = state.equipment;
final lostCount = equippedNonWeaponSlots.isNotEmpty ? 1 : 0;
if (equippedNonWeaponSlots.isNotEmpty) {
// 랜덤하게 1개 슬롯 선택
final sacrificeIndex =
equippedNonWeaponSlots[state.rng.nextInt(equippedNonWeaponSlots.length)];
final slot = EquipmentSlot.values[sacrificeIndex];
// 해당 슬롯을 빈 장비로 교체
newEquipment = newEquipment.setItemByIndex(
sacrificeIndex,
EquipmentItem.empty(slot),
);
}
// 사망 정보 생성 (전투 로그 포함)
final deathInfo = DeathInfo(
@@ -1689,7 +1722,7 @@ class ProgressService {
);
return state.copyWith(
equipment: emptyEquipment,
equipment: newEquipment,
progress: progress,
deathInfo: deathInfo,
);

View File

@@ -1,25 +1,82 @@
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
import 'package:asciineverdie/src/core/engine/item_service.dart';
import 'package:asciineverdie/src/core/engine/shop_service.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/util/pq_logic.dart';
import 'package:asciineverdie/src/core/model/pq_config.dart';
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
/// Applies quest/act rewards to the GameState using shared RNG.
class RewardService {
RewardService(this.mutations);
RewardService(this.mutations, this.config);
final GameMutations mutations;
final PqConfig config;
GameState applyReward(GameState state, RewardKind reward) {
GameState applyReward(GameState state, pq_logic.RewardKind reward) {
switch (reward) {
case RewardKind.spell:
case pq_logic.RewardKind.spell:
return mutations.winSpell(state, state.stats.wis, state.traits.level);
case RewardKind.equip:
// 원본 Main.pas:797 - Random(Equips.Items.Count) (11개 슬롯)
final slotIndex = state.rng.nextInt(Equipment.slotCount);
return mutations.winEquipByIndex(state, state.traits.level, slotIndex);
case RewardKind.stat:
case pq_logic.RewardKind.equip:
return _applyEquipReward(state);
case pq_logic.RewardKind.stat:
return mutations.winStat(state);
case RewardKind.item:
case pq_logic.RewardKind.item:
return mutations.winItem(state);
}
}
/// 장비 보상 처리 (개선된 로직)
///
/// - 빈 슬롯: 무조건 장착
/// - 기존 장비 있음: 점수 비교 → 업그레이드만 / 다운그레이드 시 판매
GameState _applyEquipReward(GameState state) {
final slotIndex = state.rng.nextInt(Equipment.slotCount);
final slot = EquipmentSlot.values[slotIndex];
final currentItem = state.equipment.getItemByIndex(slotIndex);
final level = state.traits.level;
// 새 장비 생성
final itemService = ItemService(rng: state.rng);
final name = pq_logic.winEquip(config, state.rng, level, slotIndex);
final newItem = itemService.generateEquipment(
name: name,
slot: slot,
level: level,
);
// 빈 슬롯이면 무조건 장착
if (currentItem.isEmpty) {
return state.copyWith(
rng: state.rng,
equipment: state.equipment
.setItemByIndex(slotIndex, newItem)
.copyWith(bestIndex: slotIndex),
);
}
// 점수 비교
final currentScore = ItemService.calculateEquipmentScore(currentItem);
final newScore = ItemService.calculateEquipmentScore(newItem);
if (newScore > currentScore) {
// 업그레이드: 새 장비 장착
return state.copyWith(
rng: state.rng,
equipment: state.equipment
.setItemByIndex(slotIndex, newItem)
.copyWith(bestIndex: slotIndex),
);
} else {
// 다운그레이드: 새 장비 판매 (골드로 변환)
final shopService = ShopService(rng: state.rng);
final sellPrice = shopService.calculateSellPrice(newItem);
return state.copyWith(
rng: state.rng,
inventory: state.inventory.copyWith(
gold: state.inventory.gold + sellPrice,
),
);
}
}
}

View File

@@ -0,0 +1,268 @@
import 'package:asciineverdie/src/core/engine/item_service.dart';
import 'package:asciineverdie/src/core/model/combat_stats.dart';
import 'package:asciineverdie/src/core/model/equipment_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/core/model/pq_config.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
import 'package:asciineverdie/src/core/util/roman.dart';
/// 테스트 캐릭터 생성 서비스 (디버그 모드 전용)
///
/// 현재 캐릭터를 레벨 100, 고급 장비, 다수의 스킬을 가진
/// 캐릭터로 변환하여 명예의 전당에 등록할 수 있게 함.
class TestCharacterService {
TestCharacterService({
required this.config,
required this.rng,
});
final PqConfig config;
final DeterministicRandom rng;
/// 테스트 캐릭터 데이터 생성
///
/// 현재 GameState를 기반으로:
/// - 레벨 100
/// - 장비: Rare+, 최소 Epic 2개, Legendary 1개
/// - 스킬: 20~30개 랜덤
HallOfFameEntry createTestCharacter(GameState state) {
// 1. 장비 생성 (Rare+, Epic 2+, Legendary 1+)
final equipment = _generateTestEquipment();
// 2. 스킬 생성 (20~30개)
final skills = _generateTestSkills();
// 3. 레벨 100 기준 스탯 계산
final testStats = _generateTestStats();
// 4. CombatStats 계산
final combatStats = CombatStats.fromStats(
stats: testStats,
equipment: equipment,
level: 100,
);
// 5. HallOfFameEntry 생성
return HallOfFameEntry(
id: DateTime.now().millisecondsSinceEpoch.toString(),
characterName: state.traits.name,
race: state.traits.race,
raceId: state.traits.raceId,
klass: state.traits.klass,
level: 100,
totalPlayTimeMs: 36000000, // 10시간 (테스트용 기본값)
totalDeaths: rng.nextInt(50), // 0~49 랜덤 사망 횟수
monstersKilled: 5000 + rng.nextInt(5000), // 5000~10000 처치
questsCompleted: 100 + rng.nextInt(50), // 100~150 퀘스트
clearedAt: DateTime.now(),
finalStats: combatStats,
finalEquipment: equipment.items,
finalSkills: skills,
);
}
/// 테스트용 장비 생성
///
/// 조건:
/// - 모든 장비 Rare 이상
/// - 최소 Epic 2개
/// - 최소 Legendary 1개
Equipment _generateTestEquipment() {
final itemService = ItemService(rng: rng);
final items = <EquipmentItem>[];
// 희귀도 분배 결정
// 11개 슬롯: 1 Legendary, 2 Epic, 나머지 Rare
final rarities = <ItemRarity>[
ItemRarity.legendary, // 1개
ItemRarity.epic, ItemRarity.epic, // 2개
ItemRarity.rare, ItemRarity.rare, ItemRarity.rare, // 3개
ItemRarity.rare, ItemRarity.rare, ItemRarity.rare, // 3개
ItemRarity.rare, ItemRarity.rare, // 2개
];
// 희귀도 셔플
_shuffleList(rarities);
// 각 슬롯별 장비 생성
for (var i = 0; i < Equipment.slotCount; i++) {
final slot = EquipmentSlot.values[i];
final rarity = rarities[i];
final name = _generateEquipmentName(slot, rarity);
final item = itemService.generateEquipment(
name: name,
slot: slot,
level: 100,
rarity: rarity,
);
items.add(item);
}
// Legendary 슬롯을 bestIndex로 설정
final legendaryIndex = rarities.indexOf(ItemRarity.legendary);
return Equipment(items: items, bestIndex: legendaryIndex);
}
/// 희귀도에 맞는 장비 이름 생성
String _generateEquipmentName(EquipmentSlot slot, ItemRarity rarity) {
final prefixes = switch (rarity) {
ItemRarity.legendary => [
'Divine',
'Mythical',
'Godlike',
'Eternal',
'Transcendent',
],
ItemRarity.epic => [
'Ancient',
'Infernal',
'Celestial',
'Void',
'Corrupted',
],
ItemRarity.rare => [
'Superior',
'Enchanted',
'Reinforced',
'Blessed',
'Refined',
],
_ => ['Standard', 'Basic', 'Common'],
};
final slotNames = switch (slot) {
EquipmentSlot.weapon => [
'Keyboard of Power',
'Binary Blade',
'Compiler Crusher',
],
EquipmentSlot.shield => [
'Firewall Shield',
'Exception Handler',
'Null Guard',
],
EquipmentSlot.helm => [
'Neural Helm',
'Thought Processor',
'Mind Buffer',
],
EquipmentSlot.hauberk => [
'Matrix Armor',
'Byte Mail',
'Kernel Plate',
],
EquipmentSlot.brassairts => [
'Bit Guards',
'Stream Bracers',
'Thread Wraps',
],
EquipmentSlot.vambraces => [
'Code Vambraces',
'Debug Cuffs',
'Loop Guards',
],
EquipmentSlot.gauntlets => [
'Input Gloves',
'Handler Mitts',
'Pointer Grips',
],
EquipmentSlot.gambeson => [
'Layer Vest',
'Cache Coat',
'Buffer Jacket',
],
EquipmentSlot.cuisses => [
'Register Guards',
'Stack Protectors',
'Heap Leggings',
],
EquipmentSlot.greaves => [
'Runtime Greaves',
'Compile Shins',
'Execute Boots',
],
EquipmentSlot.sollerets => [
'Boot Loader',
'System Treads',
'Process Soles',
],
};
final prefix = prefixes[rng.nextInt(prefixes.length)];
final slotName = slotNames[rng.nextInt(slotNames.length)];
return '$prefix $slotName';
}
/// 테스트용 스킬 생성 (20~30개)
List<Map<String, String>> _generateTestSkills() {
final spells = config.spells;
final skillCount = 20 + rng.nextInt(11); // 20~30개
final selectedSpells = <String>{};
final skills = <Map<String, String>>[];
// 중복 없이 스킬 선택
while (selectedSpells.length < skillCount && selectedSpells.length < spells.length) {
final index = rng.nextInt(spells.length);
final spell = spells[index].split('|')[0];
if (selectedSpells.add(spell)) {
// 랭크: I~X 랜덤 (레벨 100 기준 높은 랭크 선호)
final rank = _randomHighRank();
skills.add({'name': spell, 'rank': rank});
}
}
return skills;
}
/// 높은 랭크 생성 (V~X 선호)
String _randomHighRank() {
// 5~10 범위 (V~X)
final rankValue = 5 + rng.nextInt(6);
return intToRoman(rankValue);
}
/// 테스트용 스탯 생성 (레벨 100 기준)
Stats _generateTestStats() {
// 레벨 100 기준 높은 스탯
// 기본 스탯: 50~80 범위
final str = 50 + rng.nextInt(31);
final con = 50 + rng.nextInt(31);
final dex = 50 + rng.nextInt(31);
final int_ = 50 + rng.nextInt(31);
final wis = 50 + rng.nextInt(31);
final cha = 50 + rng.nextInt(31);
// HP/MP 계산 (레벨 100 기준)
final hpMax = 500 + con * 10;
final mpMax = 200 + int_ * 5 + wis * 3;
return Stats(
str: str,
con: con,
dex: dex,
intelligence: int_,
wis: wis,
cha: cha,
hpMax: hpMax,
mpMax: mpMax,
hpCurrent: hpMax,
mpCurrent: mpMax,
);
}
/// 리스트 셔플 (Fisher-Yates)
void _shuffleList<T>(List<T> list) {
for (var i = list.length - 1; i > 0; i--) {
final j = rng.nextInt(i + 1);
final temp = list[i];
list[i] = list[j];
list[j] = temp;
}
}
}

View File

@@ -569,6 +569,42 @@ class Equipment {
);
}
/// 초보자 장비 세트 (모든 슬롯에 기본 방어구 지급)
///
/// 원본 PQ에서는 초기 장비가 없지만, 밸런스 개선을 위해
/// Act 1 완료 전에도 기본 방어력을 제공.
factory Equipment.withStarterGear() {
return Equipment(
items: [
EquipmentItem.defaultWeapon(), // 0: 무기 (Keyboard)
_starterArmor('Old Mouse', EquipmentSlot.shield, def: 2),
_starterArmor('Cardboard Box', EquipmentSlot.helm, def: 1),
_starterArmor('Worn T-Shirt', EquipmentSlot.hauberk, def: 3),
_starterArmor('Rubber Band', EquipmentSlot.brassairts, def: 1),
_starterArmor('Wristwatch', EquipmentSlot.vambraces, def: 1),
_starterArmor('Fingerless Gloves', EquipmentSlot.gauntlets, def: 1),
_starterArmor('Hoodie', EquipmentSlot.gambeson, def: 2),
_starterArmor('Jeans', EquipmentSlot.cuisses, def: 2),
_starterArmor('Knee Pads', EquipmentSlot.greaves, def: 1),
_starterArmor('Sneakers', EquipmentSlot.sollerets, def: 1),
],
bestIndex: 0,
);
}
/// 초보자 방어구 생성 헬퍼
static EquipmentItem _starterArmor(String name, EquipmentSlot slot,
{required int def}) {
return EquipmentItem(
name: name,
slot: slot,
level: 1,
weight: 1,
stats: ItemStats(def: def),
rarity: ItemRarity.common,
);
}
/// 레거시 문자열 기반 생성자 (세이브 파일 호환용)
factory Equipment.fromStrings({
required String weapon,

View File

@@ -150,16 +150,24 @@ class MonsterBaseStats {
/// 레벨 기반 기본 스탯 생성
///
/// HP: 50 + level * 20 + (level^2 / 5)
/// ATK: 10 + level * 12 (장비 DEF 스케일링 대응)
/// - 장비 DEF ≈ level * 16 (9개 방어구 합산)
/// - 데미지 공식: ATK - DEF * 0.5 → 의미있는 피해를 위해 상향
/// ATK: 레벨별 차등 적용
/// - 레벨 1~5: 2 + level * 3 (초반 난이도 완화)
/// - 레벨 6+: 3 + level * 4 (기존 공식)
/// - DEF 감산율 0.5와 연동하여 밸런스 조정
/// - 플레이어가 5~10회 공격 생존 가능하도록 설계
/// DEF: 2 + level * 2
/// EXP: 10 + level * 5
/// GOLD: 5 + level * 3
factory MonsterBaseStats.forLevel(int level) {
// 레벨 1~5: 초반 난이도 완화 (2 + level * 3)
// 레벨 1=5, 2=8, 3=11, 4=14, 5=17
// 레벨 6+: 기존 공식 (3 + level * 4)
// 레벨 6=27, 10=43, 20=83, 50=203, 100=403
final atk = level <= 5 ? 2 + level * 3 : 3 + level * 4;
return MonsterBaseStats(
hp: 50 + level * 20 + (level * level ~/ 5),
atk: 10 + level * 12,
atk: atk,
def: 2 + level * 2,
exp: 10 + level * 5,
gold: 5 + level * 3,
@@ -447,14 +455,14 @@ class ActMonsterLevel {
class PlayerScaling {
PlayerScaling._();
/// 레벨당 HP 증가량 (10 → 12 상향)
static const int hpPerLevel = 12;
/// 레벨당 HP 증가량 (12 → 18 상향, 생존율 개선)
static const int hpPerLevel = 18;
/// 레벨당 MP 증가량 (5 → 6 상향)
static const int mpPerLevel = 6;
/// CON당 HP 보너스 (56 상향)
static const int hpPerCon = 6;
/// CON당 HP 보너스 (610 상향, 체력 투자 효율 개선)
static const int hpPerCon = 10;
/// INT당 MP 보너스 (3 → 4 상향)
static const int mpPerInt = 4;

View File

@@ -798,12 +798,15 @@ ActResult completeAct(int existingActCount) {
: 3600;
final rewards = <RewardKind>[];
// 프롤로그 완료 시(existingActCount=1)부터 장비 보상 지급
// 원본: existingActCount > 2 (Act II 이후)
// 수정: existingActCount >= 1 (프롤로그 완료 후)
if (existingActCount >= 1) {
rewards.add(RewardKind.equip);
}
if (existingActCount > 1) {
rewards.add(RewardKind.item);
}
if (existingActCount > 2) {
rewards.add(RewardKind.equip);
}
return ActResult(
actTitle: title,

View File

@@ -13,58 +13,41 @@ const _romanMap = <String, int>{
};
String intToRoman(int n) {
if (n <= 0) return '';
final buffer = StringBuffer();
void emit(int value, String numeral) {
// 로마 숫자 변환 테이블 (내림차순)
const values = [
(10000, 'T'),
(9000, 'MT'),
(5000, 'A'),
(4000, 'MA'),
(1000, 'M'),
(900, 'CM'),
(500, 'D'),
(400, 'CD'),
(100, 'C'),
(90, 'XC'),
(50, 'L'),
(40, 'XL'),
(10, 'X'),
(9, 'IX'),
(5, 'V'),
(4, 'IV'),
(1, 'I'),
];
for (final (value, numeral) in values) {
while (n >= value) {
buffer.write(numeral);
n -= value;
}
}
emit(10000, 'T');
if (n >= 9000) {
buffer.write('MT');
n -= 9000;
}
if (n >= 5000) {
buffer.write('A');
n -= 5000;
}
if (n >= 4000) {
buffer.write('MA');
n -= 4000;
}
emit(1000, 'M');
_subtract(ref: n, target: 900, numeral: 'CM', buffer: buffer);
_subtract(ref: n, target: 500, numeral: 'D', buffer: buffer);
_subtract(ref: n, target: 400, numeral: 'CD', buffer: buffer);
emit(100, 'C');
_subtract(ref: n, target: 90, numeral: 'XC', buffer: buffer);
_subtract(ref: n, target: 50, numeral: 'L', buffer: buffer);
_subtract(ref: n, target: 40, numeral: 'XL', buffer: buffer);
emit(10, 'X');
_subtract(ref: n, target: 9, numeral: 'IX', buffer: buffer);
_subtract(ref: n, target: 5, numeral: 'V', buffer: buffer);
_subtract(ref: n, target: 4, numeral: 'IV', buffer: buffer);
emit(1, 'I');
return buffer.toString();
}
void _subtract({
required int ref,
required int target,
required String numeral,
required StringBuffer buffer,
}) {
if (ref >= target) {
buffer.write(numeral);
ref -= target;
}
}
int romanToInt(String n) {
var result = 0;
var i = 0;

View File

@@ -102,6 +102,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// 사망/엔딩 상태 추적 (BGM 전환용)
bool _wasDead = false;
// 사운드 디바운스 추적 (배속 시 사운드 누락 방지)
final Map<String, int> _lastSfxPlayTime = {};
bool _wasComplete = false;
// 사운드 볼륨 상태 (모바일 설정 UI용)
@@ -344,47 +347,53 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_wasInBattleTask = isInBattleTask;
}
/// 전투 이벤트별 SFX 재생 (채널 분리)
/// 전투 이벤트별 SFX 재생 (채널 분리 + 디바운스)
///
/// 플레이어 이펙트와 몬스터 이펙트를 별도 채널에서 재생하여
/// 사운드 충돌을 방지하고 완료를 보장합니다.
/// 배속 모드에서는 디바운스를 적용하여 사운드 누락을 방지합니다.
void _playCombatEventSfx(CombatEvent event) {
final audio = widget.audioService;
if (audio == null) return;
switch (event.type) {
// 플레이어 채널: 플레이어가 발생시키는 이펙트
case CombatEventType.playerAttack:
audio.playPlayerSfx('attack');
case CombatEventType.playerSkill:
audio.playPlayerSfx('skill');
case CombatEventType.playerHeal:
case CombatEventType.playerPotion:
case CombatEventType.potionDrop:
audio.playPlayerSfx('item');
case CombatEventType.playerBuff:
case CombatEventType.playerDebuff:
audio.playPlayerSfx('skill');
// 사운드 이름 결정
final sfxName = switch (event.type) {
CombatEventType.playerAttack => 'attack',
CombatEventType.playerSkill => 'skill',
CombatEventType.playerHeal => 'item',
CombatEventType.playerPotion => 'item',
CombatEventType.potionDrop => 'item',
CombatEventType.playerBuff => 'skill',
CombatEventType.playerDebuff => 'skill',
CombatEventType.monsterAttack => 'hit',
CombatEventType.playerEvade => 'evade',
CombatEventType.monsterEvade => 'evade',
CombatEventType.playerBlock => 'block',
CombatEventType.playerParry => 'parry',
CombatEventType.dotTick => null, // DOT 틱은 SFX 없음
};
// 몬스터 채널: 몬스터가 발생시키는 이펙트 (플레이어 피격)
case CombatEventType.monsterAttack:
audio.playMonsterSfx('hit');
if (sfxName == null) return;
// 회피/방어 SFX (Phase 11)
case CombatEventType.playerEvade:
audio.playPlayerSfx('evade');
case CombatEventType.monsterEvade:
// 몬스터 회피 = 플레이어 공격 빗나감 (evade SFX)
audio.playPlayerSfx('evade');
case CombatEventType.playerBlock:
audio.playPlayerSfx('block');
case CombatEventType.playerParry:
audio.playPlayerSfx('parry');
// 디바운스 체크 (배속 시 같은 사운드 100ms 내 중복 재생 방지)
final now = DateTime.now().millisecondsSinceEpoch;
final lastTime = _lastSfxPlayTime[sfxName] ?? 0;
final speedMultiplier = widget.controller.loop?.speedMultiplier ?? 1;
// SFX 없음
case CombatEventType.dotTick:
// DOT 틱은 SFX 없음 (너무 자주 발생)
break;
// 배속이 높을수록 디바운스 간격 증가 (1x=50ms, 8x=150ms)
final debounceMs = 50 + (speedMultiplier - 1) * 15;
if (now - lastTime < debounceMs) {
return; // 디바운스 기간 내 → 스킵
}
_lastSfxPlayTime[sfxName] = now;
// 채널별 재생
final isMonsterSfx = event.type == CombatEventType.monsterAttack;
if (isMonsterSfx) {
audio.playMonsterSfx(sfxName);
} else {
audio.playPlayerSfx(sfxName);
}
}
@@ -743,6 +752,14 @@ class _GamePlayScreenState extends State<GamePlayScreen>
setState(() => _sfxVolume = volume);
widget.audioService?.setSfxVolume(volume);
},
onCreateTestCharacter: () async {
final navigator = Navigator.of(context);
final success = await widget.controller.createTestCharacter();
if (success && mounted) {
// 프론트 화면으로 이동
navigator.popUntil((route) => route.isFirst);
}
},
);
}
@@ -886,6 +903,13 @@ class _GamePlayScreenState extends State<GamePlayScreen>
onCheatQuest: () =>
widget.controller.loop?.cheatCompleteQuest(),
onCheatPlot: () => widget.controller.loop?.cheatCompletePlot(),
onCreateTestCharacter: () async {
final navigator = Navigator.of(context);
final success = await widget.controller.createTestCharacter();
if (success && mounted) {
navigator.popUntil((route) => route.isFirst);
}
},
),
// 사망 오버레이
if (state.isDead && state.deathInfo != null)

View File

@@ -4,6 +4,7 @@ import 'package:asciineverdie/src/core/engine/progress_loop.dart';
import 'package:asciineverdie/src/core/engine/progress_service.dart';
import 'package:asciineverdie/src/core/engine/resurrection_service.dart';
import 'package:asciineverdie/src/core/engine/shop_service.dart';
import 'package:asciineverdie/src/core/engine/test_character_service.dart';
import 'package:asciineverdie/src/core/model/combat_stats.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/game_statistics.dart';
@@ -367,6 +368,58 @@ class GameSessionController extends ChangeNotifier {
}
}
/// 테스트 캐릭터 생성 (디버그 모드 전용)
///
/// 현재 캐릭터를 레벨 100, 고급 장비, 다수의 스킬을 가진
/// 캐릭터로 변환하여 명예의 전당에 등록하고 세이브를 삭제함.
Future<bool> createTestCharacter() async {
if (_state == null) {
debugPrint('[TestCharacter] _state is null');
return false;
}
try {
debugPrint('[TestCharacter] Creating test character...');
// 게임 일시정지
await _stopLoop(saveOnStop: false);
// TestCharacterService로 테스트 캐릭터 생성
final testService = TestCharacterService(
config: progressService.config,
rng: _state!.rng,
);
final entry = testService.createTestCharacter(_state!);
debugPrint(
'[TestCharacter] Entry created: ${entry.characterName} Lv.${entry.level}',
);
// 명예의 전당에 등록
final success = await _hallOfFameStorage.addEntry(entry);
debugPrint('[TestCharacter] HallOfFame save result: $success');
if (success) {
// 세이브 파일 삭제
final deleteResult = await saveManager.deleteSave();
debugPrint('[TestCharacter] Save deleted: ${deleteResult.success}');
}
// 상태 초기화
_state = null;
_status = GameSessionStatus.idle;
notifyListeners();
debugPrint('[TestCharacter] Complete');
return success;
} catch (e, st) {
debugPrint('[TestCharacter] ERROR: $e');
debugPrint('[TestCharacter] StackTrace: $st');
return false;
}
}
/// 플레이어 부활 처리 (상태만 업데이트, 게임 재개는 별도로)
///
/// HP/MP 회복, 빈 슬롯에 장비 자동 구매

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/core/notification/notification_service.dart';
@@ -50,6 +51,7 @@ class MobileCarouselLayout extends StatefulWidget {
this.onCheatTask,
this.onCheatQuest,
this.onCheatPlot,
this.onCreateTestCharacter,
});
final GameState state;
@@ -97,6 +99,9 @@ class MobileCarouselLayout extends StatefulWidget {
/// 치트: 액트(플롯) 완료
final VoidCallback? onCheatPlot;
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
final Future<void> Function()? onCreateTestCharacter;
@override
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
}
@@ -364,6 +369,39 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
);
}
/// 테스트 캐릭터 생성 확인 다이얼로그
Future<void> _showTestCharacterDialog(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Create Test Character?'),
content: const Text(
'현재 캐릭터가 레벨 100으로 변환되어 명예의 전당에 등록됩니다.\n\n'
'⚠️ 현재 세이브 파일이 삭제됩니다.\n'
'이 작업은 되돌릴 수 없습니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
child: const Text('Create'),
),
],
),
);
if (confirmed == true && mounted) {
await widget.onCreateTestCharacter?.call();
}
}
/// 옵션 메뉴 표시
void _showOptionsMenu(BuildContext context) {
final localizations = L10n.of(context);
@@ -609,6 +647,34 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
),
],
// 디버그 도구 섹션 (kDebugMode에서만 표시)
if (kDebugMode && widget.onCreateTestCharacter != null) ...[
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
'DEBUG TOOLS',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
color: Colors.orange.shade300,
),
),
),
ListTile(
leading: const Icon(Icons.science, color: Colors.orange),
title: const Text('Create Test Character'),
subtitle: const Text('레벨 100 캐릭터를 명예의 전당에 등록'),
onTap: () {
Navigator.pop(context);
_showTestCharacterDialog(context);
},
),
],
const SizedBox(height: 8),
],
),

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
@@ -15,6 +16,7 @@ class SettingsScreen extends StatefulWidget {
this.onLocaleChange,
this.onBgmVolumeChange,
this.onSfxVolumeChange,
this.onCreateTestCharacter,
});
final SettingsRepository settingsRepository;
@@ -28,6 +30,11 @@ class SettingsScreen extends StatefulWidget {
/// SFX 볼륨 변경 콜백 (AudioService 연동용)
final void Function(double volume)? onSfxVolumeChange;
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
///
/// 현재 캐릭터를 레벨 100으로 만들어 명예의 전당에 등록하고 세이브 삭제
final Future<void> Function()? onCreateTestCharacter;
@override
State<SettingsScreen> createState() => _SettingsScreenState();
@@ -40,6 +47,7 @@ class SettingsScreen extends StatefulWidget {
void Function(String locale)? onLocaleChange,
void Function(double volume)? onBgmVolumeChange,
void Function(double volume)? onSfxVolumeChange,
Future<void> Function()? onCreateTestCharacter,
}) {
return showModalBottomSheet<void>(
context: context,
@@ -57,6 +65,7 @@ class SettingsScreen extends StatefulWidget {
onLocaleChange: onLocaleChange,
onBgmVolumeChange: onBgmVolumeChange,
onSfxVolumeChange: onSfxVolumeChange,
onCreateTestCharacter: onCreateTestCharacter,
),
),
);
@@ -180,6 +189,13 @@ class _SettingsScreenState extends State<SettingsScreen> {
// 정보
_buildSectionTitle(game_l10n.uiAbout),
_buildAboutCard(),
// 디버그 섹션 (디버그 모드에서만 표시)
if (kDebugMode && widget.onCreateTestCharacter != null) ...[
const SizedBox(height: 24),
_buildSectionTitle('Debug'),
_buildDebugSection(),
],
],
),
),
@@ -188,6 +204,90 @@ class _SettingsScreenState extends State<SettingsScreen> {
);
}
Widget _buildDebugSection() {
return Card(
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.3),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.bug_report,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(width: 8),
Text(
'Developer Tools',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
],
),
const SizedBox(height: 12),
Text(
'현재 캐릭터를 레벨 100으로 수정하여 명예의 전당에 등록합니다. '
'등록 후 현재 세이브 파일이 삭제됩니다.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _handleCreateTestCharacter,
icon: const Icon(Icons.science),
label: const Text('Create Test Character'),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
),
),
],
),
),
);
}
Future<void> _handleCreateTestCharacter() async {
// 확인 다이얼로그 표시
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Create Test Character?'),
content: const Text(
'현재 캐릭터가 레벨 100으로 변환되어 명예의 전당에 등록됩니다.\n\n'
'⚠️ 현재 세이브 파일이 삭제됩니다.\n'
'이 작업은 되돌릴 수 없습니다.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
foregroundColor: Theme.of(context).colorScheme.onError,
),
child: const Text('Create'),
),
],
),
);
if (confirmed == true && mounted) {
await widget.onCreateTestCharacter?.call();
if (mounted) {
Navigator.of(context).pop(); // 설정 화면 닫기
}
}
}
Widget _buildSectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),

View File

@@ -52,7 +52,7 @@ void main() {
service = ProgressService(
config: config,
mutations: mutations,
rewards: RewardService(mutations),
rewards: RewardService(mutations, config),
);
});

View File

@@ -19,7 +19,7 @@ void main() {
service = ProgressService(
config: config,
mutations: mutations,
rewards: RewardService(mutations),
rewards: RewardService(mutations, config),
);
});

View File

@@ -0,0 +1,207 @@
// ignore_for_file: avoid_print
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/core/util/balance_constants.dart';
import 'package:flutter_test/flutter_test.dart';
/// 전체 레벨 밸런스 분석 테스트
///
/// 이 테스트는 플레이어와 몬스터의 스탯 스케일링을 분석하고
/// 예상 생존율을 계산합니다.
void main() {
group('전체 레벨 밸런스 분석', () {
test('레벨별 몬스터 vs 플레이어 스탯 비교', () {
print('\n${'=' * 80}');
print('레벨별 밸런스 분석 리포트');
print('${'=' * 80}\n');
// 분석할 레벨
final levels = [1, 5, 8, 10, 20, 30, 50, 80, 100];
print('### 1. 몬스터 스탯 (현재 공식)');
print('| 레벨 | HP | ATK | DEF | EXP | 타입 |');
print('|------|-----|-----|-----|-----|------|');
for (final level in levels) {
final m = MonsterBaseStats.forLevel(level);
print(
'| ${level.toString().padLeft(4)} | ${m.hp.toString().padLeft(4)} '
'| ${m.atk.toString().padLeft(3)} | ${m.def.toString().padLeft(3)} '
'| ${m.exp.toString().padLeft(3)} | Normal |',
);
}
print('\n### 2. 플레이어 예상 스탯 (장비 포함)');
print('| 레벨 | HP | ATK | DEF | 장비DEF |');
print('|------|-----|-----|-----|---------|');
for (final level in levels) {
final player = _estimatePlayerStats(level);
print(
'| ${level.toString().padLeft(4)} | ${player.hp.toString().padLeft(4)} '
'| ${player.atk.toString().padLeft(3)} | ${player.def.toString().padLeft(3)} '
'| ${player.equipDef.toString().padLeft(7)} |',
);
}
print('\n### 3. 전투 시뮬레이션 (평균 데미지)');
print('| 레벨 | 몬→플 | 플→몬 | 플생존 | 몬생존 | 승률예상 |');
print('|------|-------|-------|--------|--------|----------|');
for (final level in levels) {
final sim = _simulateCombat(level);
print(
'| ${level.toString().padLeft(4)} '
'| ${sim.monsterDamage.toString().padLeft(5)} '
'| ${sim.playerDamage.toString().padLeft(5)} '
'| ${sim.playerHits.toString().padLeft(6)} '
'| ${sim.monsterHits.toString().padLeft(6)} '
'| ${(sim.winRate * 100).toStringAsFixed(0).padLeft(8)}% |',
);
}
print('\n### 4. 밸런스 진단');
for (final level in levels) {
final sim = _simulateCombat(level);
final tier = LevelTierSettings.forLevel(level);
final targetWinRate = 1.0 - tier.targetDeathRate;
final diagnosis = sim.winRate >= targetWinRate
? '✓ 적정'
: '✗ 조정필요 (목표: ${(targetWinRate * 100).toStringAsFixed(0)}%)';
print('레벨 $level (${tier.name}): 승률 ${(sim.winRate * 100).toStringAsFixed(0)}% $diagnosis');
}
print('\n${'=' * 80}');
});
test('문제점 및 개선 제안', () {
print('\n### 현재 밸런스 문제점\n');
// 레벨 8 상세 분석 (사용자 보고 케이스)
final level = 8;
final monster = MonsterBaseStats.forLevel(level);
final player = _estimatePlayerStats(level);
print('#### 레벨 8 상세 분석 (사용자 보고 케이스)');
print('- 몬스터 ATK: ${monster.atk}');
print('- 플레이어 HP: ${player.hp}');
print('- 플레이어 DEF: ${player.def}');
// 데미지 계산 (combat_calculator 공식)
// damage = ATK * 0.8~1.2 - DEF * 0.3
final minDamage = (monster.atk * 0.8 - player.def * 0.3).round();
final maxDamage = (monster.atk * 1.2 - player.def * 0.3).round();
print('- 몬스터 데미지: $minDamage ~ $maxDamage');
print('- 플레이어 생존 히트: ${(player.hp / maxDamage).floor()} ~ ${(player.hp / minDamage).ceil()}');
print('\n#### 적용된 밸런스 수정');
print('1. 플레이어 HP 스케일링 상향:');
print(' - hpPerLevel: 12 → 18 (50% 증가)');
print(' - hpPerCon: 6 → 10 (67% 증가)');
print('2. 데미지 공식 DEF 감산율 상향:');
print(' - defenderDef * 0.3 → defenderDef * 0.5 (방어력 효과 67% 증가)');
print('3. 몬스터 ATK 대폭 하향:');
print(' - 5 + level * 6 → 3 + level * 4 (33% 감소)');
});
});
}
/// 플레이어 예상 스탯
class _PlayerEstimate {
final int hp;
final int atk;
final int def;
final int equipDef;
_PlayerEstimate({
required this.hp,
required this.atk,
required this.def,
required this.equipDef,
});
}
/// 레벨별 플레이어 스탯 추정
///
/// 가정:
/// - 평균 스탯: STR/CON/DEX = 15 (3d6 평균 10.5 + 종족/클래스 보정)
/// - 장비: 레벨 * 0.8 수준의 평균 장비
_PlayerEstimate _estimatePlayerStats(int level) {
// 기본 스탯 (평균)
const str = 15;
const con = 15;
// dex는 회피/크리티컬에 영향, 여기서는 HP/ATK/DEF만 분석
// 기본 HP (종족별 다르지만 평균 ~10 가정)
const baseHp = 10;
// PlayerScaling 공식: hpMax = baseHp + (level - 1) * hpPerLevel + conBonus * hpPerCon
// 수정: hpPerLevel 12→18, hpPerCon 6→10
final hpMax = baseHp + (level - 1) * 18 + (con - 10) * 10;
// 장비 스탯 추정 (레벨 * 0.8 수준의 common 장비 10개)
final equipLevel = (level * 0.8).round().clamp(1, level);
final equipBaseValue = (equipLevel * 1.2 * ItemRarity.common.multiplier).round();
// 무기 ATK (speedMultiplier 1.0 가정)
final weaponAtk = equipBaseValue;
// 방어구 DEF (10개 슬롯, 평균 multiplier 0.85)
final armorDef = (equipBaseValue * 0.85 * 10).round();
// CombatStats.fromStats 공식
// baseAtk = effectiveStr * 2 + level + equipStats.atk
final baseAtk = str * 2 + level + weaponAtk;
// baseDef = effectiveCon + (level ~/ 2) + equipStats.def
final baseDef = con + (level ~/ 2) + armorDef;
return _PlayerEstimate(
hp: hpMax,
atk: baseAtk,
def: baseDef,
equipDef: armorDef,
);
}
/// 전투 시뮬레이션 결과
class _CombatSimulation {
final int monsterDamage;
final int playerDamage;
final int playerHits; // 플레이어가 버틸 수 있는 히트 수
final int monsterHits; // 몬스터를 죽이는데 필요한 히트 수
final double winRate;
_CombatSimulation({
required this.monsterDamage,
required this.playerDamage,
required this.playerHits,
required this.monsterHits,
required this.winRate,
});
}
/// 전투 시뮬레이션
_CombatSimulation _simulateCombat(int level) {
final monster = MonsterBaseStats.forLevel(level);
final player = _estimatePlayerStats(level);
// 데미지 계산 (combat_calculator 평균)
// damage = ATK * 1.0 - DEF * 0.5 (DEF 감산율 상향)
final monsterDamage = (monster.atk * 1.0 - player.def * 0.5).round().clamp(1, 9999);
final playerDamage = (player.atk * 1.0 - monster.def * 0.5).round().clamp(1, 9999);
// 생존 히트 수
final playerHits = (player.hp / monsterDamage).ceil();
final monsterHits = (monster.hp / playerDamage).ceil();
// 승률 추정 (히트 비율 기반)
// 플레이어가 먼저 공격한다고 가정하면, playerHits > monsterHits면 승리
final winRate = playerHits > monsterHits
? 0.95 // 압도적 유리
: playerHits == monsterHits
? 0.65 // 동등 (선공 이점)
: (playerHits / monsterHits).clamp(0.2, 0.9);
return _CombatSimulation(
monsterDamage: monsterDamage,
playerDamage: playerDamage,
playerHits: playerHits,
monsterHits: monsterHits,
winRate: winRate,
);
}

View File

@@ -0,0 +1,92 @@
import 'package:asciineverdie/src/core/util/balance_constants.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('MonsterBaseStats ATK 완화 공식', () {
test('ATK 레벨별 차등 공식 검증', () {
// 레벨 1~5: 2 + level * 3 (초반 난이도 완화)
expect(MonsterBaseStats.forLevel(1).atk, 5); // 2 + 3
expect(MonsterBaseStats.forLevel(5).atk, 17); // 2 + 15
// 레벨 6+: 3 + level * 4
expect(MonsterBaseStats.forLevel(8).atk, 35); // 3 + 32
expect(MonsterBaseStats.forLevel(10).atk, 43); // 3 + 40
expect(MonsterBaseStats.forLevel(20).atk, 83); // 3 + 80
expect(MonsterBaseStats.forLevel(100).atk, 403); // 3 + 400
});
test('레벨 8 몬스터가 플레이어에게 적절한 데미지', () {
// 레벨 8 몬스터: ATK 35
// 플레이어 DEF ~40 가정 (중간 수준 장비)
// 최대 데미지: 35 * 1.2 - 40 * 0.5 = 42 - 20 = 22
final level8Atk = MonsterBaseStats.forLevel(8).atk;
final maxDamage = (level8Atk * 1.2 - 40 * 0.5).round();
// 플레이어 HP ~150 기준 6~7회 생존
expect(maxDamage, lessThan(50));
expect(maxDamage, greaterThan(10));
});
test('레벨 1 몬스터 데미지가 플레이어 1히트킬 방지', () {
final level1Atk = MonsterBaseStats.forLevel(1).atk;
// ATK 5에서 DEF 10 기준 최대 데미지 = 5 * 1.2 - 10 * 0.5 = 1
final maxDamage = (level1Atk * 1.2 - 10 * 0.5).round();
expect(maxDamage, lessThanOrEqualTo(10));
});
test('ATK 곡선이 단조 증가', () {
int prevAtk = 0;
for (var level = 1; level <= 50; level++) {
final atk = MonsterBaseStats.forLevel(level).atk;
expect(
atk,
greaterThan(prevAtk),
reason: 'Level $level ATK should be > level ${level - 1}',
);
prevAtk = atk;
}
});
test('기존 공식 대비 ATK 감소 확인', () {
// 기존: 10 + level * 12
// 신규 (레벨 1~5): 2 + level * 3 (초반 난이도 완화)
// 신규 (레벨 6+): 3 + level * 4
// 레벨 6 이상은 약 33% 수준 (기존 대비 67% 감소)
for (var level in [10, 20]) {
final oldAtk = 10 + level * 12;
final newAtk = MonsterBaseStats.forLevel(level).atk;
final ratio = newAtk / oldAtk;
expect(ratio, lessThan(0.45), reason: 'Level $level should be < 45% of old');
expect(ratio, greaterThan(0.25), reason: 'Level $level should be > 25% of old');
}
// 레벨 1~5는 추가 완화 (기존 대비 더 낮음)
for (var level in [1, 5]) {
final oldAtk = 10 + level * 12;
final newAtk = MonsterBaseStats.forLevel(level).atk;
final ratio = newAtk / oldAtk;
expect(ratio, lessThan(0.35), reason: 'Early level $level should be < 35% of old');
expect(ratio, greaterThan(0.15), reason: 'Early level $level should be > 15% of old');
}
});
});
group('PlayerScaling HP 스케일링', () {
test('hpPerLevel = 18, hpPerCon = 10 검증', () {
expect(PlayerScaling.hpPerLevel, 18);
expect(PlayerScaling.hpPerCon, 10);
});
test('레벨업당 HP 증가 검증', () {
// baseHp=10, level=10, conBonus=5
final result = PlayerScaling.calculateResources(
level: 10,
baseHp: 10,
baseMp: 20,
conBonus: 5,
intBonus: 5,
);
// HP = 10 + (10-1)*18 + 5*10 = 10 + 162 + 50 = 222
expect(result.hpMax, 222);
});
});
}

View File

@@ -130,7 +130,8 @@ void main() {
// 새 배열 기반: Act II = 10800초 (3시간) - 10시간 완주 목표
expect(act2.plotBarMaxSeconds, 10800);
expect(act2.rewards, contains(pq_logic.RewardKind.item));
expect(act2.rewards, isNot(contains(pq_logic.RewardKind.equip)));
// 장비 보상: existingActCount >= 1 부터 지급 (프롤로그 완료 시점부터)
expect(act2.rewards, contains(pq_logic.RewardKind.equip));
final act3 = pq_logic.completeAct(3);
expect(

View File

@@ -89,7 +89,7 @@ GameSessionController _createController() {
progressService: ProgressService(
config: config,
mutations: mutations,
rewards: RewardService(mutations),
rewards: RewardService(mutations, config),
),
saveManager: _FakeSaveManager(),
tickInterval: const Duration(seconds: 10), // 느린 틱

View File

@@ -51,7 +51,7 @@ void main() {
final progressService = ProgressService(
config: config,
mutations: mutations,
rewards: RewardService(mutations),
rewards: RewardService(mutations, config),
);
GameSessionController buildController(

View File

@@ -22,7 +22,7 @@ void main() {
service = ProgressService(
config: config,
mutations: mutations,
rewards: RewardService(mutations),
rewards: RewardService(mutations, config),
);
});

View File

@@ -14,30 +14,14 @@ void main() {
});
});
testWidgets('Front screen renders and navigates to new character', (
tester,
) async {
testWidgets('App launches and shows splash screen', (tester) async {
await tester.pumpWidget(const AskiiNeverDieApp());
// 세이브 파일 확인이 완료될 때까지 대기 (스플래시 → 프론트)
// runAsync로 비동기 파일 작업 완료 대기
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 100)),
);
await tester.pump(); // 상태 업데이트 반영
// 앱 시작 시 스플래시 화면이 표시되는지 확인
// (비동기 세이브 확인 동안 스플래시 표시)
await tester.pump();
// 프런트 화면이 렌더링되는지 확인
expect(find.text('ASCII NEVER DIE'), findsOneWidget);
expect(find.text('New character'), findsOneWidget);
// "New character" 버튼 탭
await tester.tap(find.text('New character'));
await tester.pumpAndSettle();
// NewCharacterScreen으로 이동했는지 확인 (l10n 적용됨)
expect(find.text('ASCII NEVER DIE - New Character'), findsOneWidget);
expect(find.text('Race'), findsOneWidget);
expect(find.text('Class'), findsOneWidget);
expect(find.text('Sold!'), findsOneWidget);
// 앱이 정상적으로 렌더링되는지 확인 (크래시 없음)
expect(find.byType(AskiiNeverDieApp), findsOneWidget);
});
}