feat(engine): 엔진 서비스 개선 및 테스트 캐릭터 서비스 추가

- ProgressService 로직 개선
- RewardService 확장
- CombatCalculator, ItemService 정리
- TestCharacterService 추가
This commit is contained in:
JiWoong Sul
2026-01-12 20:02:45 +09:00
parent d23dcd1e6f
commit a1d22369cb
5 changed files with 434 additions and 70 deletions

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;
}
}
}