feat(core): 장비 시스템 및 게임 상태 모델 확장

- Equipment 클래스를 11개 슬롯으로 확장 (원본 Main.dfm 충실)
- TaskInfo에 몬스터 정보(baseName, part) 추가
- Stats에 현재 HP/MP 필드 추가
- 히스토리 기능 구현 (plotHistory, questHistory)
- pq_logic winEquip/winStatIndex 원본 로직 개선
- 퀘스트 몬스터 처리 로직 구현
- SaveData 직렬화 확장
This commit is contained in:
JiWoong Sul
2025-12-09 22:30:37 +09:00
parent b512fde1fb
commit b450bf2600
12 changed files with 571 additions and 208 deletions

View File

@@ -9,27 +9,22 @@ class GameMutations {
final PqConfig config; final PqConfig config;
GameState winEquip(GameState state, int level, EquipmentSlot slot) { /// 장비 획득 (원본 Main.pas:791-830 WinEquip)
/// [slotIndex]: 0-10 (원본 Equips.Items.Count = 11)
GameState winEquipByIndex(GameState state, int level, int slotIndex) {
final rng = state.rng; final rng = state.rng;
final name = pq_logic.winEquip(config, rng, level, slot); final name = pq_logic.winEquip(config, rng, level, slotIndex);
final equip = state.equipment; final updatedEquip = state.equipment
final updatedEquip = switch (slot) { .setByIndex(slotIndex, name)
EquipmentSlot.weapon => equip.copyWith( .copyWith(bestIndex: slotIndex);
weapon: name,
bestIndex: EquipmentSlot.weapon.index,
),
EquipmentSlot.shield => equip.copyWith(
shield: name,
bestIndex: EquipmentSlot.shield.index,
),
EquipmentSlot.armor => equip.copyWith(
armor: name,
bestIndex: EquipmentSlot.armor.index,
),
};
return state.copyWith(rng: rng, equipment: updatedEquip); return state.copyWith(rng: rng, equipment: updatedEquip);
} }
/// EquipmentSlot enum을 사용하는 편의 함수
GameState winEquip(GameState state, int level, EquipmentSlot slot) {
return winEquipByIndex(state, level, slot.index);
}
GameState winStat(GameState state) { GameState winStat(GameState state) {
final updatedStats = pq_logic.winStat(state.stats, state.rng); final updatedStats = pq_logic.winStat(state.stats, state.rng);
return state.copyWith(rng: state.rng, stats: updatedStats); return state.copyWith(rng: state.rng, stats: updatedStats);

View File

@@ -2,7 +2,6 @@ import 'dart:math' as math;
import 'package:askiineverdie/src/core/engine/game_mutations.dart'; import 'package:askiineverdie/src/core/engine/game_mutations.dart';
import 'package:askiineverdie/src/core/engine/reward_service.dart'; import 'package:askiineverdie/src/core/engine/reward_service.dart';
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/pq_config.dart'; import 'package:askiineverdie/src/core/model/pq_config.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic; import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic;
@@ -49,21 +48,24 @@ class ProgressService {
const QueueEntry( const QueueEntry(
kind: QueueKind.task, kind: QueueKind.task,
durationMillis: 6 * 1000, durationMillis: 6 * 1000,
caption: "Much is revealed about that wise old bastard you'd " caption:
"Much is revealed about that wise old bastard you'd "
'underestimated', 'underestimated',
taskType: TaskType.load, taskType: TaskType.load,
), ),
const QueueEntry( const QueueEntry(
kind: QueueKind.task, kind: QueueKind.task,
durationMillis: 6 * 1000, durationMillis: 6 * 1000,
caption: 'A shocking series of events leaves you alone and bewildered, ' caption:
'A shocking series of events leaves you alone and bewildered, '
'but resolute', 'but resolute',
taskType: TaskType.load, taskType: TaskType.load,
), ),
const QueueEntry( const QueueEntry(
kind: QueueKind.task, kind: QueueKind.task,
durationMillis: 4 * 1000, durationMillis: 4 * 1000,
caption: 'Drawing upon an unexpected reserve of determination, ' caption:
'Drawing upon an unexpected reserve of determination, '
'you set out on a long and dangerous journey', 'you set out on a long and dangerous journey',
taskType: TaskType.load, taskType: TaskType.load,
), ),
@@ -76,17 +78,10 @@ class ProgressService {
]; ];
// 첫 번째 태스크 'Loading' 시작 (원본 752줄) // 첫 번째 태스크 'Loading' 시작 (원본 752줄)
final taskResult = pq_logic.startTask( final taskResult = pq_logic.startTask(state.progress, 'Loading', 2 * 1000);
state.progress,
'Loading',
2 * 1000,
);
// ExpBar 초기화 (원본 743-746줄) // ExpBar 초기화 (원본 743-746줄)
final expBar = ProgressBarState( final expBar = ProgressBarState(position: 0, max: pq_logic.levelUpTime(1));
position: 0,
max: pq_logic.levelUpTime(1),
);
// PlotBar 초기화 (원본 759줄) // PlotBar 초기화 (원본 759줄)
final plotBar = const ProgressBarState(position: 0, max: 26 * 1000); final plotBar = const ProgressBarState(position: 0, max: 26 * 1000);
@@ -97,6 +92,8 @@ class ProgressService {
currentTask: const TaskInfo(caption: 'Loading...', type: TaskType.load), currentTask: const TaskInfo(caption: 'Loading...', type: TaskType.load),
plotStageCount: 1, // Prologue plotStageCount: 1, // Prologue
questCount: 0, questCount: 0,
plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)],
questHistory: const [],
); );
return _recalculateEncumbrance( return _recalculateEncumbrance(
@@ -348,12 +345,21 @@ class ProgressService {
// 3. MonsterTask 실행 (원본 678-684줄) // 3. MonsterTask 실행 (원본 678-684줄)
final level = state.traits.level; final level = state.traits.level;
final monster = pq_logic.monsterTask(
// 원본 Main.pas:548-551: 25% 확률로 Quest Monster 사용
// fQuest.Caption이 비어있지 않으면 해당 몬스터 데이터 전달
final questMonster = state.progress.currentQuestMonster;
final questMonsterData = questMonster?.monsterData;
final questLevel = questMonsterData != null
? int.tryParse(questMonsterData.split('|').elementAtOrNull(1) ?? '') ?? 0
: null;
final monsterResult = pq_logic.monsterTask(
config, config,
state.rng, state.rng,
level, level,
null, // questMonster questMonsterData,
null, // questLevel questLevel,
); );
// 태스크 지속시간 계산 (원본 682줄) // 태스크 지속시간 계산 (원본 682줄)
@@ -364,7 +370,7 @@ class ProgressService {
final taskResult = pq_logic.startTask( final taskResult = pq_logic.startTask(
progress, progress,
'Executing $monster', 'Executing ${monsterResult.displayName}',
durationMillis, durationMillis,
); );
@@ -372,6 +378,8 @@ class ProgressService {
currentTask: TaskInfo( currentTask: TaskInfo(
caption: taskResult.caption, caption: taskResult.caption,
type: TaskType.kill, type: TaskType.kill,
monsterBaseName: monsterResult.baseName,
monsterPart: monsterResult.part,
), ),
); );
@@ -389,6 +397,23 @@ class ProgressService {
var nextState = _applyReward(state, result.reward); var nextState = _applyReward(state, result.reward);
final questCount = nextState.progress.questCount + 1; final questCount = nextState.progress.questCount + 1;
// 퀘스트 히스토리 업데이트: 이전 퀘스트 완료 표시, 새 퀘스트 추가
final updatedQuestHistory = [
...nextState.progress.questHistory.map(
(e) => e.isComplete ? e : e.copyWith(isComplete: true),
),
HistoryEntry(caption: result.caption, isComplete: false),
];
// 퀘스트 몬스터 정보 저장 (Exterminate 타입용)
// 원본 fQuest.Caption = monsterData, fQuest.Tag = monsterIndex
final questMonster = result.monsterIndex != null
? QuestMonsterInfo(
monsterData: result.monsterName!,
monsterIndex: result.monsterIndex!,
)
: null;
// Append quest entry to queue (task kind). // Append quest entry to queue (task kind).
final updatedQueue = QueueState( final updatedQueue = QueueState(
entries: [ entries: [
@@ -409,6 +434,8 @@ class ProgressService {
max: 50 + nextState.rng.nextInt(100), max: 50 + nextState.rng.nextInt(100),
), ),
questCount: questCount, questCount: questCount,
questHistory: updatedQuestHistory,
currentQuestMonster: questMonster,
); );
return _recalculateEncumbrance( return _recalculateEncumbrance(
@@ -425,9 +452,19 @@ class ProgressService {
} }
final plotStages = nextState.progress.plotStageCount + 1; final plotStages = nextState.progress.plotStageCount + 1;
// 플롯 히스토리 업데이트: 이전 플롯 완료 표시, 새 플롯 추가
final updatedPlotHistory = [
...nextState.progress.plotHistory.map(
(e) => e.isComplete ? e : e.copyWith(isComplete: true),
),
HistoryEntry(caption: actResult.actTitle, isComplete: false),
];
var updatedProgress = nextState.progress.copyWith( var updatedProgress = nextState.progress.copyWith(
plot: ProgressBarState(position: 0, max: actResult.plotBarMaxSeconds), plot: ProgressBarState(position: 0, max: actResult.plotBarMaxSeconds),
plotStageCount: plotStages, plotStageCount: plotStages,
plotHistory: updatedPlotHistory,
); );
nextState = nextState.copyWith(progress: updatedProgress); nextState = nextState.copyWith(progress: updatedProgress);
@@ -455,6 +492,20 @@ class ProgressService {
max: 50 + state.rng.nextInt(100), max: 50 + state.rng.nextInt(100),
); );
// 첫 퀘스트 히스토리 추가
final questHistory = [
HistoryEntry(caption: result.caption, isComplete: false),
];
// 퀘스트 몬스터 정보 저장 (Exterminate 타입용)
// 원본 fQuest.Caption = monsterData, fQuest.Tag = monsterIndex
final questMonster = result.monsterIndex != null
? QuestMonsterInfo(
monsterData: result.monsterName!,
monsterIndex: result.monsterIndex!,
)
: null;
// 첫 퀘스트 추가 // 첫 퀘스트 추가
final updatedQueue = QueueState( final updatedQueue = QueueState(
entries: [ entries: [
@@ -471,6 +522,8 @@ class ProgressService {
final progress = state.progress.copyWith( final progress = state.progress.copyWith(
quest: questBar, quest: questBar,
questCount: 1, questCount: 1,
questHistory: questHistory,
currentQuestMonster: questMonster,
); );
return state.copyWith(progress: progress, queue: updatedQueue); return state.copyWith(progress: progress, queue: updatedQueue);
@@ -550,28 +603,25 @@ class ProgressService {
/// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630) /// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630)
GameState _winLoot(GameState state) { GameState _winLoot(GameState state) {
final taskCaption = state.progress.currentTask.caption; final taskInfo = state.progress.currentTask;
final monsterPart = taskInfo.monsterPart ?? '';
final monsterBaseName = taskInfo.monsterBaseName ?? '';
// 부위가 '*'이면 WinItem 호출 (특수 아이템)
if (monsterPart == '*') {
return mutations.winItem(state);
}
// 부위가 비어있으면 전리품 없음
if (monsterPart.isEmpty || monsterBaseName.isEmpty) {
return state;
}
// 몬스터 이름에서 전리품 아이템 생성
// 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' + // 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' +
// ProperCase(Split(fTask.Caption,3))), 1); // ProperCase(Split(fTask.Caption,3))), 1);
// 예: "Executing a Goblin..." -> "goblin ear" 등의 아이템 // 예: "goblin Claw" 형태로 인벤토리 추가
final itemName =
// 태스크 캡션에서 몬스터 이름 추출 ("Executing ..." 형태) '${monsterBaseName.toLowerCase()} ${_properCase(monsterPart)}';
String monsterName = taskCaption;
if (monsterName.startsWith('Executing ')) {
monsterName = monsterName.substring('Executing '.length);
}
if (monsterName.endsWith('...')) {
monsterName = monsterName.substring(0, monsterName.length - 3);
}
// 몬스터 부위 선택 (원본에서는 몬스터별로 다르지만, 간단히 랜덤 선택)
final parts = ['Skin', 'Tooth', 'Claw', 'Ear', 'Eye', 'Tail', 'Scale'];
final part = pq_logic.pick(parts, state.rng);
// 아이템 이름 생성 (예: "Goblin Ear")
final itemName = '${_extractBaseName(monsterName)} $part';
// 인벤토리에 추가 // 인벤토리에 추가
final items = [...state.inventory.items]; final items = [...state.inventory.items];
@@ -584,32 +634,13 @@ class ProgressService {
items.add(InventoryEntry(name: itemName, count: 1)); items.add(InventoryEntry(name: itemName, count: 1));
} }
return state.copyWith( return state.copyWith(inventory: state.inventory.copyWith(items: items));
inventory: state.inventory.copyWith(items: items),
);
} }
/// 몬스터 이름에서 기본 이름 추출 (형용사 제거) /// 첫 글자만 대문자로 변환 (원본 ProperCase)
String _extractBaseName(String name) { String _properCase(String s) {
// "a Goblin", "an Orc", "2 Goblins" 등에서 기본 이름 추출 if (s.isEmpty) return s;
final words = name.split(' '); return s[0].toUpperCase() + s.substring(1);
if (words.isEmpty) return name;
// 관사나 숫자 제거
var startIndex = 0;
if (words[0] == 'a' || words[0] == 'an' || words[0] == 'the') {
startIndex = 1;
} else if (int.tryParse(words[0]) != null) {
startIndex = 1;
}
if (startIndex >= words.length) return name;
// 마지막 단어가 몬스터 이름 (형용사들 건너뛰기)
final baseName = words.last;
// 첫 글자 대문자로
if (baseName.isEmpty) return name;
return baseName[0].toUpperCase() + baseName.substring(1).toLowerCase();
} }
/// 인벤토리에서 Gold 수량 반환 /// 인벤토리에서 Gold 수량 반환
@@ -635,11 +666,9 @@ class ProgressService {
); );
// 장비 획득 (WinEquip) // 장비 획득 (WinEquip)
nextState = mutations.winEquip( // 원본 Main.pas:797 - posn := Random(Equips.Items.Count); (11개 슬롯)
nextState, final slotIndex = nextState.rng.nextInt(Equipment.slotCount);
level, nextState = mutations.winEquipByIndex(nextState, level, slotIndex);
EquipmentSlot.values[nextState.rng.nextInt(EquipmentSlot.values.length)],
);
return nextState; return nextState;
} }
@@ -662,7 +691,8 @@ class ProgressService {
// " of " 포함 시 보너스 (원본 639-640) // " of " 포함 시 보너스 (원본 639-640)
if (item.name.contains(' of ')) { if (item.name.contains(' of ')) {
price = price * price =
price *
(1 + pq_logic.randomLow(state.rng, 10)) * (1 + pq_logic.randomLow(state.rng, 10)) *
(1 + pq_logic.randomLow(state.rng, level)); (1 + pq_logic.randomLow(state.rng, level));
} }
@@ -687,10 +717,7 @@ class ProgressService {
1 * 1000, 1 * 1000,
); );
final progress = taskResult.progress.copyWith( final progress = taskResult.progress.copyWith(
currentTask: TaskInfo( currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.sell),
caption: taskResult.caption,
type: TaskType.sell,
),
); );
return ( return (
state: state.copyWith( state: state.copyWith(

View File

@@ -1,5 +1,4 @@
import 'package:askiineverdie/src/core/engine/game_mutations.dart'; import 'package:askiineverdie/src/core/engine/game_mutations.dart';
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/util/pq_logic.dart'; import 'package:askiineverdie/src/core/util/pq_logic.dart';
@@ -14,9 +13,9 @@ class RewardService {
case RewardKind.spell: case RewardKind.spell:
return mutations.winSpell(state, state.stats.wis, state.traits.level); return mutations.winSpell(state, state.stats.wis, state.traits.level);
case RewardKind.equip: case RewardKind.equip:
final slot = EquipmentSlot // 원본 Main.pas:797 - Random(Equips.Items.Count) (11개 슬롯)
.values[state.rng.nextInt(EquipmentSlot.values.length)]; final slotIndex = state.rng.nextInt(Equipment.slotCount);
return mutations.winEquip(state, state.traits.level, slot); return mutations.winEquipByIndex(state, state.traits.level, slotIndex);
case RewardKind.stat: case RewardKind.stat:
return mutations.winStat(state); return mutations.winStat(state);
case RewardKind.item: case RewardKind.item:

View File

@@ -1 +1,29 @@
enum EquipmentSlot { weapon, shield, armor } /// 장비 슬롯 (원본 Main.dfm Equips ListView 순서)
/// 0=Weapon, 1=Shield, 2-10=Armor 계열
enum EquipmentSlot {
weapon, // 0: 무기 → K.Weapons
shield, // 1: 방패 → K.Shields
helm, // 2: 투구 → K.Armors
hauberk, // 3: 사슬갑옷 → K.Armors
brassairts, // 4: 상완갑 → K.Armors
vambraces, // 5: 전완갑 → K.Armors
gauntlets, // 6: 건틀릿 → K.Armors
gambeson, // 7: 갬비슨(누비옷) → K.Armors
cuisses, // 8: 허벅지갑 → K.Armors
greaves, // 9: 정강이갑 → K.Armors
sollerets, // 10: 철제신발 → K.Armors
}
extension EquipmentSlotX on EquipmentSlot {
/// 무기 슬롯 여부
bool get isWeapon => this == EquipmentSlot.weapon;
/// 방패 슬롯 여부
bool get isShield => this == EquipmentSlot.shield;
/// 방어구(armor) 슬롯 여부 (2-10)
bool get isArmor => index >= 2;
/// 슬롯 인덱스 (0-10)
int get slotIndex => index;
}

View File

@@ -90,16 +90,37 @@ enum TaskType {
} }
class TaskInfo { class TaskInfo {
const TaskInfo({required this.caption, required this.type}); const TaskInfo({
required this.caption,
required this.type,
this.monsterBaseName,
this.monsterPart,
});
final String caption; final String caption;
final TaskType type; final TaskType type;
/// 킬 태스크의 몬스터 기본 이름 (형용사 제외, 예: "Goblin")
final String? monsterBaseName;
/// 킬 태스크의 전리품 부위 (예: "claw", "tail", "*"는 WinItem)
final String? monsterPart;
factory TaskInfo.empty() => factory TaskInfo.empty() =>
const TaskInfo(caption: '', type: TaskType.neutral); const TaskInfo(caption: '', type: TaskType.neutral);
TaskInfo copyWith({String? caption, TaskType? type}) { TaskInfo copyWith({
return TaskInfo(caption: caption ?? this.caption, type: type ?? this.type); String? caption,
TaskType? type,
String? monsterBaseName,
String? monsterPart,
}) {
return TaskInfo(
caption: caption ?? this.caption,
type: type ?? this.type,
monsterBaseName: monsterBaseName ?? this.monsterBaseName,
monsterPart: monsterPart ?? this.monsterPart,
);
} }
} }
@@ -158,6 +179,8 @@ class Stats {
required this.cha, required this.cha,
required this.hpMax, required this.hpMax,
required this.mpMax, required this.mpMax,
this.hpCurrent,
this.mpCurrent,
}); });
final int str; final int str;
@@ -169,6 +192,18 @@ class Stats {
final int hpMax; final int hpMax;
final int mpMax; final int mpMax;
/// 현재 HP (null이면 hpMax와 동일)
final int? hpCurrent;
/// 현재 MP (null이면 mpMax와 동일)
final int? mpCurrent;
/// 실제 현재 HP 값
int get hp => hpCurrent ?? hpMax;
/// 실제 현재 MP 값
int get mp => mpCurrent ?? mpMax;
factory Stats.empty() => const Stats( factory Stats.empty() => const Stats(
str: 0, str: 0,
con: 0, con: 0,
@@ -189,6 +224,8 @@ class Stats {
int? cha, int? cha,
int? hpMax, int? hpMax,
int? mpMax, int? mpMax,
int? hpCurrent,
int? mpCurrent,
}) { }) {
return Stats( return Stats(
str: str ?? this.str, str: str ?? this.str,
@@ -199,6 +236,8 @@ class Stats {
cha: cha ?? this.cha, cha: cha ?? this.cha,
hpMax: hpMax ?? this.hpMax, hpMax: hpMax ?? this.hpMax,
mpMax: mpMax ?? this.mpMax, mpMax: mpMax ?? this.mpMax,
hpCurrent: hpCurrent ?? this.hpCurrent,
mpCurrent: mpCurrent ?? this.mpCurrent,
); );
} }
} }
@@ -227,38 +266,118 @@ class Inventory {
} }
} }
/// 장비 (원본 Main.dfm Equips ListView, 11개 슬롯)
class Equipment { class Equipment {
const Equipment({ const Equipment({
required this.weapon, required this.weapon,
required this.shield, required this.shield,
required this.armor, required this.helm,
required this.hauberk,
required this.brassairts,
required this.vambraces,
required this.gauntlets,
required this.gambeson,
required this.cuisses,
required this.greaves,
required this.sollerets,
required this.bestIndex, required this.bestIndex,
}); });
final String weapon; final String weapon; // 0: 무기
final String shield; final String shield; // 1: 방패
final String armor; final String helm; // 2: 투구
final String hauberk; // 3: 사슬갑옷
final String brassairts; // 4: 상완갑
final String vambraces; // 5: 전완갑
final String gauntlets; // 6: 건틀릿
final String gambeson; // 7: 갬비슨
final String cuisses; // 8: 허벅지갑
final String greaves; // 9: 정강이갑
final String sollerets; // 10: 철제신발
/// Tracks best slot index (mirror of Equips.Tag in original code; 0=weapon,1=shield,2=armor). /// 최고 아이템 슬롯 인덱스 (원본 Equips.Tag, 0-10)
final int bestIndex; final int bestIndex;
/// 슬롯 개수
static const slotCount = 11;
factory Equipment.empty() => const Equipment( factory Equipment.empty() => const Equipment(
weapon: 'Sharp Stick', weapon: 'Sharp Stick',
shield: '', shield: '',
armor: '', helm: '',
hauberk: '',
brassairts: '',
vambraces: '',
gauntlets: '',
gambeson: '',
cuisses: '',
greaves: '',
sollerets: '',
bestIndex: 0, bestIndex: 0,
); );
/// 인덱스로 슬롯 값 가져오기
String getByIndex(int index) {
return switch (index) {
0 => weapon,
1 => shield,
2 => helm,
3 => hauberk,
4 => brassairts,
5 => vambraces,
6 => gauntlets,
7 => gambeson,
8 => cuisses,
9 => greaves,
10 => sollerets,
_ => '',
};
}
/// 인덱스로 슬롯 값 설정한 새 Equipment 반환
Equipment setByIndex(int index, String value) {
return switch (index) {
0 => copyWith(weapon: value),
1 => copyWith(shield: value),
2 => copyWith(helm: value),
3 => copyWith(hauberk: value),
4 => copyWith(brassairts: value),
5 => copyWith(vambraces: value),
6 => copyWith(gauntlets: value),
7 => copyWith(gambeson: value),
8 => copyWith(cuisses: value),
9 => copyWith(greaves: value),
10 => copyWith(sollerets: value),
_ => this,
};
}
Equipment copyWith({ Equipment copyWith({
String? weapon, String? weapon,
String? shield, String? shield,
String? armor, String? helm,
String? hauberk,
String? brassairts,
String? vambraces,
String? gauntlets,
String? gambeson,
String? cuisses,
String? greaves,
String? sollerets,
int? bestIndex, int? bestIndex,
}) { }) {
return Equipment( return Equipment(
weapon: weapon ?? this.weapon, weapon: weapon ?? this.weapon,
shield: shield ?? this.shield, shield: shield ?? this.shield,
armor: armor ?? this.armor, helm: helm ?? this.helm,
hauberk: hauberk ?? this.hauberk,
brassairts: brassairts ?? this.brassairts,
vambraces: vambraces ?? this.vambraces,
gauntlets: gauntlets ?? this.gauntlets,
gambeson: gambeson ?? this.gambeson,
cuisses: cuisses ?? this.cuisses,
greaves: greaves ?? this.greaves,
sollerets: sollerets ?? this.sollerets,
bestIndex: bestIndex ?? this.bestIndex, bestIndex: bestIndex ?? this.bestIndex,
); );
} }
@@ -304,6 +423,40 @@ class ProgressBarState {
} }
} }
/// 히스토리 엔트리 (Plot/Quest 진행 기록)
class HistoryEntry {
const HistoryEntry({required this.caption, required this.isComplete});
/// 표시 텍스트 (예: "Prologue", "Act I", "Exterminate the Goblins")
final String caption;
/// 완료 여부 (원본 StateIndex: 0=진행중, 1=완료)
final bool isComplete;
HistoryEntry copyWith({String? caption, bool? isComplete}) {
return HistoryEntry(
caption: caption ?? this.caption,
isComplete: isComplete ?? this.isComplete,
);
}
}
/// 현재 퀘스트 몬스터 정보 (원본 fQuest)
class QuestMonsterInfo {
const QuestMonsterInfo({
required this.monsterData,
required this.monsterIndex,
});
/// 몬스터 데이터 문자열 (예: "Goblin|3|ear")
final String monsterData;
/// 몬스터 인덱스 (Config.monsters에서의 인덱스)
final int monsterIndex;
static const empty = QuestMonsterInfo(monsterData: '', monsterIndex: -1);
}
class ProgressState { class ProgressState {
const ProgressState({ const ProgressState({
required this.task, required this.task,
@@ -314,6 +467,9 @@ class ProgressState {
required this.currentTask, required this.currentTask,
required this.plotStageCount, required this.plotStageCount,
required this.questCount, required this.questCount,
this.plotHistory = const [],
this.questHistory = const [],
this.currentQuestMonster,
}); });
final ProgressBarState task; final ProgressBarState task;
@@ -325,6 +481,15 @@ class ProgressState {
final int plotStageCount; final int plotStageCount;
final int questCount; final int questCount;
/// 플롯 히스토리 (Prologue, Act I, Act II, ...)
final List<HistoryEntry> plotHistory;
/// 퀘스트 히스토리 (완료된/진행중인 퀘스트 목록)
final List<HistoryEntry> questHistory;
/// 현재 퀘스트 몬스터 정보 (Exterminate 타입용)
final QuestMonsterInfo? currentQuestMonster;
factory ProgressState.empty() => ProgressState( factory ProgressState.empty() => ProgressState(
task: ProgressBarState.empty(), task: ProgressBarState.empty(),
quest: ProgressBarState.empty(), quest: ProgressBarState.empty(),
@@ -334,6 +499,9 @@ class ProgressState {
currentTask: TaskInfo.empty(), currentTask: TaskInfo.empty(),
plotStageCount: 1, // Prologue plotStageCount: 1, // Prologue
questCount: 0, questCount: 0,
plotHistory: const [HistoryEntry(caption: 'Prologue', isComplete: false)],
questHistory: const [],
currentQuestMonster: null,
); );
ProgressState copyWith({ ProgressState copyWith({
@@ -345,6 +513,9 @@ class ProgressState {
TaskInfo? currentTask, TaskInfo? currentTask,
int? plotStageCount, int? plotStageCount,
int? questCount, int? questCount,
List<HistoryEntry>? plotHistory,
List<HistoryEntry>? questHistory,
QuestMonsterInfo? currentQuestMonster,
}) { }) {
return ProgressState( return ProgressState(
task: task ?? this.task, task: task ?? this.task,
@@ -355,6 +526,9 @@ class ProgressState {
currentTask: currentTask ?? this.currentTask, currentTask: currentTask ?? this.currentTask,
plotStageCount: plotStageCount ?? this.plotStageCount, plotStageCount: plotStageCount ?? this.plotStageCount,
questCount: questCount ?? this.questCount, questCount: questCount ?? this.questCount,
plotHistory: plotHistory ?? this.plotHistory,
questHistory: questHistory ?? this.questHistory,
currentQuestMonster: currentQuestMonster ?? this.currentQuestMonster,
); );
} }
} }

View File

@@ -63,6 +63,8 @@ class GameSave {
'cha': stats.cha, 'cha': stats.cha,
'hpMax': stats.hpMax, 'hpMax': stats.hpMax,
'mpMax': stats.mpMax, 'mpMax': stats.mpMax,
'hpCurrent': stats.hpCurrent,
'mpCurrent': stats.mpCurrent,
}, },
'inventory': { 'inventory': {
'gold': inventory.gold, 'gold': inventory.gold,
@@ -73,7 +75,15 @@ class GameSave {
'equipment': { 'equipment': {
'weapon': equipment.weapon, 'weapon': equipment.weapon,
'shield': equipment.shield, 'shield': equipment.shield,
'armor': equipment.armor, 'helm': equipment.helm,
'hauberk': equipment.hauberk,
'brassairts': equipment.brassairts,
'vambraces': equipment.vambraces,
'gauntlets': equipment.gauntlets,
'gambeson': equipment.gambeson,
'cuisses': equipment.cuisses,
'greaves': equipment.greaves,
'sollerets': equipment.sollerets,
'bestIndex': equipment.bestIndex, 'bestIndex': equipment.bestIndex,
}, },
'spells': spellBook.spells 'spells': spellBook.spells
@@ -88,9 +98,23 @@ class GameSave {
'taskInfo': { 'taskInfo': {
'caption': progress.currentTask.caption, 'caption': progress.currentTask.caption,
'type': progress.currentTask.type.name, 'type': progress.currentTask.type.name,
'monsterBaseName': progress.currentTask.monsterBaseName,
'monsterPart': progress.currentTask.monsterPart,
}, },
'plotStages': progress.plotStageCount, 'plotStages': progress.plotStageCount,
'questCount': progress.questCount, 'questCount': progress.questCount,
'plotHistory': progress.plotHistory
.map((e) => {'caption': e.caption, 'complete': e.isComplete})
.toList(),
'questHistory': progress.questHistory
.map((e) => {'caption': e.caption, 'complete': e.isComplete})
.toList(),
'questMonster': progress.currentQuestMonster != null
? {
'data': progress.currentQuestMonster!.monsterData,
'index': progress.currentQuestMonster!.monsterIndex,
}
: null,
}, },
'queue': queue.entries 'queue': queue.entries
.map( .map(
@@ -134,6 +158,8 @@ class GameSave {
cha: statsJson['cha'] as int? ?? 0, cha: statsJson['cha'] as int? ?? 0,
hpMax: statsJson['hpMax'] as int? ?? 0, hpMax: statsJson['hpMax'] as int? ?? 0,
mpMax: statsJson['mpMax'] as int? ?? 0, mpMax: statsJson['mpMax'] as int? ?? 0,
hpCurrent: statsJson['hpCurrent'] as int?,
mpCurrent: statsJson['mpCurrent'] as int?,
), ),
inventory: Inventory( inventory: Inventory(
gold: inventoryJson['gold'] as int? ?? 0, gold: inventoryJson['gold'] as int? ?? 0,
@@ -149,7 +175,15 @@ class GameSave {
equipment: Equipment( equipment: Equipment(
weapon: equipmentJson['weapon'] as String? ?? 'Sharp Stick', weapon: equipmentJson['weapon'] as String? ?? 'Sharp Stick',
shield: equipmentJson['shield'] as String? ?? '', shield: equipmentJson['shield'] as String? ?? '',
armor: equipmentJson['armor'] as String? ?? '', helm: equipmentJson['helm'] as String? ?? '',
hauberk: equipmentJson['hauberk'] as String? ?? '',
brassairts: equipmentJson['brassairts'] as String? ?? '',
vambraces: equipmentJson['vambraces'] as String? ?? '',
gauntlets: equipmentJson['gauntlets'] as String? ?? '',
gambeson: equipmentJson['gambeson'] as String? ?? '',
cuisses: equipmentJson['cuisses'] as String? ?? '',
greaves: equipmentJson['greaves'] as String? ?? '',
sollerets: equipmentJson['sollerets'] as String? ?? '',
bestIndex: equipmentJson['bestIndex'] as int? ?? 0, bestIndex: equipmentJson['bestIndex'] as int? ?? 0,
), ),
spellBook: SpellBook( spellBook: SpellBook(
@@ -178,6 +212,15 @@ class GameSave {
), ),
plotStageCount: progressJson['plotStages'] as int? ?? 1, plotStageCount: progressJson['plotStages'] as int? ?? 1,
questCount: progressJson['questCount'] as int? ?? 0, questCount: progressJson['questCount'] as int? ?? 0,
plotHistory: _historyListFromJson(
progressJson['plotHistory'] as List<dynamic>?,
),
questHistory: _historyListFromJson(
progressJson['questHistory'] as List<dynamic>?,
),
currentQuestMonster: _questMonsterFromJson(
progressJson['questMonster'] as Map<String, dynamic>?,
),
), ),
queue: QueueState( queue: QueueState(
entries: Queue<QueueEntry>.from( entries: Queue<QueueEntry>.from(
@@ -233,5 +276,29 @@ TaskInfo _taskInfoFromJson(Map<String, dynamic> json) {
(t) => t.name == typeName, (t) => t.name == typeName,
orElse: () => TaskType.neutral, orElse: () => TaskType.neutral,
); );
return TaskInfo(caption: json['caption'] as String? ?? '', type: type); return TaskInfo(
caption: json['caption'] as String? ?? '',
type: type,
monsterBaseName: json['monsterBaseName'] as String?,
monsterPart: json['monsterPart'] as String?,
);
}
List<HistoryEntry> _historyListFromJson(List<dynamic>? json) {
if (json == null || json.isEmpty) return [];
return json.map((e) {
final m = e as Map<String, dynamic>;
return HistoryEntry(
caption: m['caption'] as String? ?? '',
isComplete: m['complete'] as bool? ?? false,
);
}).toList();
}
QuestMonsterInfo? _questMonsterFromJson(Map<String, dynamic>? json) {
if (json == null) return null;
return QuestMonsterInfo(
monsterData: json['data'] as String? ?? '',
monsterIndex: json['index'] as int? ?? -1,
);
} }

View File

@@ -140,7 +140,6 @@ int _parseLevel(String entry) {
return int.tryParse(parts[1].replaceAll('+', '')) ?? 0; return int.tryParse(parts[1].replaceAll('+', '')) ?? 0;
} }
String addModifier( String addModifier(
DeterministicRandom rng, DeterministicRandom rng,
String baseName, String baseName,
@@ -211,19 +210,27 @@ int random64Below(DeterministicRandom rng, int below) {
return (combined % below).toInt(); return (combined % below).toInt();
} }
/// 장비 생성 (원본 Main.pas:791-830 WinEquip)
/// [slotIndex]: 0=Weapon, 1=Shield, 2-10=Armor 계열
String winEquip( String winEquip(
PqConfig config, PqConfig config,
DeterministicRandom rng, DeterministicRandom rng,
int level, int level,
EquipmentSlot slot, int slotIndex,
) { ) {
// Decide item set and modifiers based on slot. // 원본 로직:
final bool isWeapon = slot == EquipmentSlot.weapon; // posn = 0: Weapon → K.Weapons, OffenseAttrib
final items = switch (slot) { // posn = 1: Shield → K.Shields, DefenseAttrib
EquipmentSlot.weapon => config.weapons, // posn >= 2: Armor → K.Armors, DefenseAttrib
EquipmentSlot.shield => config.shields, final bool isWeapon = slotIndex == 0;
EquipmentSlot.armor => config.armors, final List<String> items;
}; if (slotIndex == 0) {
items = config.weapons;
} else if (slotIndex == 1) {
items = config.shields;
} else {
items = config.armors;
}
final better = isWeapon ? config.offenseAttrib : config.defenseAttrib; final better = isWeapon ? config.offenseAttrib : config.defenseAttrib;
final worse = isWeapon ? config.offenseBad : config.defenseBad; final worse = isWeapon ? config.offenseBad : config.defenseBad;
@@ -239,21 +246,38 @@ String winEquip(
return addModifier(rng, baseName, modifiers, plus); return addModifier(rng, baseName, modifiers, plus);
} }
/// EquipmentSlot enum을 사용하는 편의 함수
String winEquipBySlot(
PqConfig config,
DeterministicRandom rng,
int level,
EquipmentSlot slot,
) {
return winEquip(config, rng, level, slot.index);
}
int winStatIndex(DeterministicRandom rng, List<int> statValues) { int winStatIndex(DeterministicRandom rng, List<int> statValues) {
// 원본 Main.pas:870-883: 50% 확률로 완전 랜덤, 50% 확률로 제곱 가중치 // 원본 Main.pas:870-883
// 50%: 모든 8개 스탯 중 랜덤
// 50%: 첫 6개(STR~CHA)만 제곱 가중치로 선택
if (rng.nextInt(2) == 0) { if (rng.nextInt(2) == 0) {
// Odds(1,2): 완전 랜덤 선택 // Odds(1,2): 모든 스탯 중 완전 랜덤 선택
return rng.nextInt(statValues.length); return rng.nextInt(statValues.length);
} }
// 제곱 가중치로 높은 스탯 선호 // 원본: for i := 0 to 5 do Inc(t, Square(GetI(Stats,i)));
final total = statValues.fold<int>(0, (sum, v) => sum + v * v); // 첫 6개(STR, CON, DEX, INT, WIS, CHA)만 제곱 가중치 적용
if (total == 0) return rng.nextInt(statValues.length); const firstSixCount = 6;
var total = 0;
for (var i = 0; i < firstSixCount && i < statValues.length; i++) {
total += statValues[i] * statValues[i];
}
if (total == 0) return rng.nextInt(firstSixCount);
var pickValue = random64Below(rng, total); var pickValue = random64Below(rng, total);
for (var i = 0; i < statValues.length; i++) { for (var i = 0; i < firstSixCount; i++) {
pickValue -= statValues[i] * statValues[i]; pickValue -= statValues[i] * statValues[i];
if (pickValue < 0) return i; if (pickValue < 0) return i;
} }
return statValues.length - 1; return firstSixCount - 1;
} }
Stats winStat(Stats stats, DeterministicRandom rng) { Stats winStat(Stats stats, DeterministicRandom rng) {
@@ -290,7 +314,7 @@ Stats winStat(Stats stats, DeterministicRandom rng) {
} }
} }
String monsterTask( MonsterTaskResult monsterTask(
PqConfig config, PqConfig config,
DeterministicRandom rng, DeterministicRandom rng,
int level, int level,
@@ -308,6 +332,7 @@ String monsterTask(
String monster; String monster;
int monsterLevel; int monsterLevel;
String part;
bool definite = false; bool definite = false;
// 원본 Main.pas:537-547: 가끔 NPC를 몬스터로 사용 // 원본 Main.pas:537-547: 가끔 NPC를 몬스터로 사용
@@ -324,11 +349,13 @@ String monsterTask(
definite = true; definite = true;
} }
monsterLevel = targetLevel; monsterLevel = targetLevel;
monster = '$monster|$monsterLevel|*'; part = '*'; // NPC는 WinItem 호출
} else if (questMonster != null && rng.nextInt(4) == 0) { } else if (questMonster != null && rng.nextInt(4) == 0) {
// Use quest monster. // Use quest monster.
monster = questMonster; monster = questMonster;
final parts = questMonster.split('|');
monsterLevel = questLevel ?? targetLevel; monsterLevel = questLevel ?? targetLevel;
part = parts.length > 2 ? parts[2] : '';
} else { } else {
// Pick closest level among random samples. // Pick closest level among random samples.
monster = pick(config.monsters, rng); monster = pick(config.monsters, rng);
@@ -342,12 +369,18 @@ String monsterTask(
monsterLevel = candLevel; monsterLevel = candLevel;
} }
} }
// 몬스터 데이터에서 부위 정보 추출 (예: "Rat|0|tail")
final monsterParts = monster.split('|');
part = monsterParts.length > 2 ? monsterParts[2] : '';
} }
// 기본 몬스터 이름 (부위 정보 제외)
final baseName = monster.split('|').first;
// Adjust quantity and adjectives based on level delta. // Adjust quantity and adjectives based on level delta.
var qty = 1; var qty = 1;
final levelDiff = targetLevel - monsterLevel; final levelDiff = targetLevel - monsterLevel;
var name = monster.split('|').first; var name = baseName;
if (levelDiff > 10) { if (levelDiff > 10) {
qty = qty =
@@ -385,7 +418,34 @@ String monsterTask(
name = indefinite(name, qty); name = indefinite(name, qty);
} }
return name; return MonsterTaskResult(
displayName: name,
baseName: baseName,
level: monsterLevel * qty,
part: part,
);
}
/// monsterTask의 반환 타입 (원본 fTask.Caption 정보 포함)
class MonsterTaskResult {
const MonsterTaskResult({
required this.displayName,
required this.baseName,
required this.level,
required this.part,
});
/// 화면에 표시할 몬스터 이름 (형용사 포함, 예: "a sick Goblin")
final String displayName;
/// 기본 몬스터 이름 (형용사 제외, 예: "Goblin")
final String baseName;
/// 몬스터 레벨
final int level;
/// 전리품 부위 (예: "claw", "tail", "*"는 WinItem 호출)
final String part;
} }
enum RewardKind { spell, equip, stat, item } enum RewardKind { spell, equip, stat, item }
@@ -396,12 +456,17 @@ class QuestResult {
required this.reward, required this.reward,
this.monsterName, this.monsterName,
this.monsterLevel, this.monsterLevel,
this.monsterIndex,
}); });
final String caption; final String caption;
final RewardKind reward; final RewardKind reward;
/// 몬스터 전체 데이터 (예: "Rat|5|tail") - 원본 fQuest.Caption
final String? monsterName; final String? monsterName;
/// 몬스터 레벨 (파싱된 값)
final int? monsterLevel; final int? monsterLevel;
/// 몬스터 인덱스 (config.monsters에서의 위치) - 원본 fQuest.Tag
final int? monsterIndex;
} }
QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) { QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
@@ -416,14 +481,19 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
final questRoll = rng.nextInt(5); final questRoll = rng.nextInt(5);
switch (questRoll) { switch (questRoll) {
case 0: case 0:
// Exterminate: 4번 시도하여 레벨에 가장 가까운 몬스터 선택
// 원본 Main.pas:936-954
var best = ''; var best = '';
var bestLevel = 0; var bestLevel = 0;
var bestIndex = 0;
for (var i = 0; i < 4; i++) { for (var i = 0; i < 4; i++) {
final m = pick(config.monsters, rng); final monsterIndex = rng.nextInt(config.monsters.length);
final m = config.monsters[monsterIndex];
final l = _monsterLevel(m); final l = _monsterLevel(m);
if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) { if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) {
best = m; best = m;
bestLevel = l; bestLevel = l;
bestIndex = monsterIndex;
} }
} }
final name = best.split('|').first; final name = best.split('|').first;
@@ -432,6 +502,7 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
reward: reward, reward: reward,
monsterName: best, monsterName: best,
monsterLevel: bestLevel, monsterLevel: bestLevel,
monsterIndex: bestIndex,
); );
case 1: case 1:
final item = interestingItem(config, rng); final item = interestingItem(config, rng);
@@ -446,6 +517,8 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
reward: reward, reward: reward,
); );
default: default:
// Placate: 2번 시도하여 레벨에 가장 가까운 몬스터 선택
// 원본 Main.pas:971-984 (fQuest.Caption := '' 처리됨)
var best = ''; var best = '';
var bestLevel = 0; var bestLevel = 0;
for (var i = 0; i < 2; i++) { for (var i = 0; i < 2; i++) {
@@ -457,11 +530,10 @@ QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
} }
} }
final name = best.split('|').first; final name = best.split('|').first;
// Placate는 fQuest.Caption := '' 로 비움 → monsterIndex 미저장
return QuestResult( return QuestResult(
caption: 'Placate ${definite(name, 2)}', caption: 'Placate ${definite(name, 2)}',
reward: reward, reward: reward,
monsterName: best,
monsterLevel: bestLevel,
); );
} }
} }

View File

@@ -301,7 +301,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
child: Text( child: Text(
'${speed}x', '${speed}x',
style: TextStyle( style: TextStyle(
fontWeight: speed > 1 ? FontWeight.bold : FontWeight.normal, fontWeight: speed > 1
? FontWeight.bold
: FontWeight.normal,
color: speed > 1 color: speed > 1
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: null, : null,
@@ -468,11 +470,19 @@ class _GamePlayScreenState extends State<GamePlayScreen>
} }
Widget _buildEquipmentList(GameState state) { Widget _buildEquipmentList(GameState state) {
// 원본에는 11개 슬롯이 있지만, 현재 모델은 3개만 구현 // 원본 Main.dfm Equips ListView - 11개 슬롯
final equipment = [ final equipment = [
('Weapon', state.equipment.weapon), ('Weapon', state.equipment.weapon),
('Shield', state.equipment.shield), ('Shield', state.equipment.shield),
('Armor', state.equipment.armor), ('Helm', state.equipment.helm),
('Hauberk', state.equipment.hauberk),
('Brassairts', state.equipment.brassairts),
('Vambraces', state.equipment.vambraces),
('Gauntlets', state.equipment.gauntlets),
('Gambeson', state.equipment.gambeson),
('Cuisses', state.equipment.cuisses),
('Greaves', state.equipment.greaves),
('Sollerets', state.equipment.sollerets),
]; ];
return ListView.builder( return ListView.builder(

View File

@@ -38,7 +38,8 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
int _wis = 0; int _wis = 0;
int _cha = 0; int _cha = 0;
// 롤 이력 (Unroll 기능용) // 롤 이력 (Unroll 기능용) - 원본 OldRolls TListBox
static const int _maxRollHistory = 20; // 최대 저장 개수
final List<int> _rollHistory = []; final List<int> _rollHistory = [];
// 현재 RNG 시드 (Re-Roll 전 저장) // 현재 RNG 시드 (Re-Roll 전 저장)
@@ -93,6 +94,11 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
// 현재 시드를 이력에 저장 // 현재 시드를 이력에 저장
_rollHistory.insert(0, _currentSeed); _rollHistory.insert(0, _currentSeed);
// 최대 개수 초과 시 가장 오래된 항목 제거
if (_rollHistory.length > _maxRollHistory) {
_rollHistory.removeLast();
}
// 새 시드로 굴림 // 새 시드로 굴림
_currentSeed = math.Random().nextInt(0x7FFFFFFF); _currentSeed = math.Random().nextInt(0x7FFFFFFF);
_rollStats(); _rollStats();
@@ -132,6 +138,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
} }
/// Sold! 버튼 클릭 - 캐릭터 생성 완료 /// Sold! 버튼 클릭 - 캐릭터 생성 완료
/// 원본 Main.pas:1371-1388 RollCharacter 로직
void _onSold() { void _onSold() {
final name = _nameController.text.trim(); final name = _nameController.text.trim();
if (name.isEmpty) { if (name.isEmpty) {
@@ -144,22 +151,23 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
// 게임에 사용할 새 RNG 생성 // 게임에 사용할 새 RNG 생성
final gameSeed = math.Random().nextInt(0x7FFFFFFF); final gameSeed = math.Random().nextInt(0x7FFFFFFF);
// 종족/직업의 보너스 스탯 파싱 // 원본 Main.pas:1380-1381 - 기본 롤 값(CON.Tag, INT.Tag)만 사용
final raceEntry = _config.races[_selectedRaceIndex]; // 종족/직업 보너스는 스탯에 적용되지 않음 (UI 힌트용)
final klassEntry = _config.klasses[_selectedKlassIndex]; // Put(Stats,'HP Max',Random(8) + CON.Tag div 6);
final raceBonus = _parseStatBonus(raceEntry); // Put(Stats,'MP Max',Random(8) + INT.Tag div 6);
final klassBonus = _parseStatBonus(klassEntry); final hpMax = math.Random().nextInt(8) + _con ~/ 6;
final mpMax = math.Random().nextInt(8) + _int ~/ 6;
// 최종 스탯 계산 (기본 + 종족 보너스 + 직업 보너스) // 원본 Main.pas:1375-1379 - 기본 롤 값 그대로 저장 (보너스 없음)
final finalStats = Stats( final finalStats = Stats(
str: _str + (raceBonus['STR'] ?? 0) + (klassBonus['STR'] ?? 0), str: _str,
con: _con + (raceBonus['CON'] ?? 0) + (klassBonus['CON'] ?? 0), con: _con,
dex: _dex + (raceBonus['DEX'] ?? 0) + (klassBonus['DEX'] ?? 0), dex: _dex,
intelligence: _int + (raceBonus['INT'] ?? 0) + (klassBonus['INT'] ?? 0), intelligence: _int,
wis: _wis + (raceBonus['WIS'] ?? 0) + (klassBonus['WIS'] ?? 0), wis: _wis,
cha: _cha + (raceBonus['CHA'] ?? 0) + (klassBonus['CHA'] ?? 0), cha: _cha,
hpMax: _con + (raceBonus['CON'] ?? 0) + (klassBonus['CON'] ?? 0), hpMax: hpMax,
mpMax: _int + (raceBonus['INT'] ?? 0) + (klassBonus['INT'] ?? 0), mpMax: mpMax,
); );
final traits = Traits( final traits = Traits(
@@ -186,24 +194,6 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
widget.onCharacterCreated?.call(initialState); widget.onCharacterCreated?.call(initialState);
} }
/// 종족/직업 보너스 파싱 (예: "Half Orc|STR+2,INT-1")
Map<String, int> _parseStatBonus(String entry) {
final parts = entry.split('|');
if (parts.length < 2) return {};
final bonuses = <String, int>{};
final bonusPart = parts[1];
// STR+2,INT-1 형식 파싱
final regex = RegExp(r'([A-Z]+)([+-]\d+)');
for (final match in regex.allMatches(bonusPart)) {
final stat = match.group(1)!;
final value = int.parse(match.group(2)!);
bonuses[stat] = value;
}
return bonuses;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(

View File

@@ -35,9 +35,7 @@ void main() {
), ),
inventory: const Inventory( inventory: const Inventory(
gold: 5, gold: 5,
items: [ items: [InventoryEntry(name: 'Rock', count: 3)],
InventoryEntry(name: 'Rock', count: 3),
],
), ),
progress: const ProgressState( progress: const ProgressState(
task: ProgressBarState(position: 0, max: 80), task: ProgressBarState(position: 0, max: 80),

View File

@@ -1,4 +1,3 @@
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/pq_config.dart'; import 'package:askiineverdie/src/core/model/pq_config.dart';
import 'package:askiineverdie/src/core/util/deterministic_random.dart'; import 'package:askiineverdie/src/core/util/deterministic_random.dart';
@@ -65,7 +64,7 @@ void main() {
config, config,
DeterministicRandom(12), DeterministicRandom(12),
5, 5,
EquipmentSlot.weapon, 0, // weapon slot
), ),
'Baselard', 'Baselard',
); );
@@ -74,7 +73,7 @@ void main() {
config, config,
DeterministicRandom(15), DeterministicRandom(15),
2, 2,
EquipmentSlot.armor, 2, // helm slot (armor category)
), ),
'-2 Canvas', '-2 Canvas',
); );
@@ -86,18 +85,35 @@ void main() {
}); });
test('monsterTask picks level-appropriate monsters with modifiers', () { test('monsterTask picks level-appropriate monsters with modifiers', () {
expect( final result1 = pq_logic.monsterTask(
pq_logic.monsterTask(config, DeterministicRandom(99), 5, null, null), config,
'an underage Rakshasa', DeterministicRandom(99),
5,
null,
null,
); );
expect( expect(result1.displayName, 'an underage Rakshasa');
pq_logic.monsterTask(config, DeterministicRandom(7), 10, null, null), expect(result1.baseName, 'Rakshasa');
'a greater Sphinx', expect(result1.part, isNotEmpty);
final result2 = pq_logic.monsterTask(
config,
DeterministicRandom(7),
10,
null,
null,
); );
expect( expect(result2.displayName, 'a greater Sphinx');
pq_logic.monsterTask(config, DeterministicRandom(5), 6, 'Goblin|3', 3), expect(result2.baseName, 'Sphinx');
'a Barbed Devil',
final result3 = pq_logic.monsterTask(
config,
DeterministicRandom(5),
6,
'Goblin|3|ear',
3,
); );
expect(result3.displayName, 'a Barbed Devil');
}); });
test('completeQuest and completeAct return deterministic results', () { test('completeQuest and completeAct return deterministic results', () {

View File

@@ -7,7 +7,6 @@ library;
import 'package:askiineverdie/src/core/engine/game_mutations.dart'; import 'package:askiineverdie/src/core/engine/game_mutations.dart';
import 'package:askiineverdie/src/core/engine/progress_service.dart'; import 'package:askiineverdie/src/core/engine/progress_service.dart';
import 'package:askiineverdie/src/core/engine/reward_service.dart'; import 'package:askiineverdie/src/core/engine/reward_service.dart';
import 'package:askiineverdie/src/core/model/equipment_slot.dart';
import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/pq_config.dart'; import 'package:askiineverdie/src/core/model/pq_config.dart';
import 'package:askiineverdie/src/core/util/deterministic_random.dart'; import 'package:askiineverdie/src/core/util/deterministic_random.dart';
@@ -41,71 +40,59 @@ void main() {
test('monsterTask produces consistent monster names', () { test('monsterTask produces consistent monster names', () {
// 시드 42, 레벨 5에서의 몬스터 이름 // 시드 42, 레벨 5에서의 몬스터 이름
expect( expect(
pq_logic.monsterTask( pq_logic
config, .monsterTask(config, DeterministicRandom(testSeed), 5, null, null)
DeterministicRandom(testSeed), .displayName,
5,
null,
null,
),
'an underage Su-monster', 'an underage Su-monster',
); );
// 시드 42, 레벨 10에서의 몬스터 이름 // 시드 42, 레벨 10에서의 몬스터 이름
expect( expect(
pq_logic.monsterTask( pq_logic
config, .monsterTask(config, DeterministicRandom(testSeed), 10, null, null)
DeterministicRandom(testSeed), .displayName,
10,
null,
null,
),
'a cursed Troll', 'a cursed Troll',
); );
// 시드 42, 레벨 1에서의 몬스터 이름 // 시드 42, 레벨 1에서의 몬스터 이름
expect( expect(
pq_logic.monsterTask( pq_logic
config, .monsterTask(config, DeterministicRandom(testSeed), 1, null, null)
DeterministicRandom(testSeed), .displayName,
1,
null,
null,
),
'a greater Crayfish', 'a greater Crayfish',
); );
}); });
test('winEquip produces consistent equipment', () { test('winEquip produces consistent equipment', () {
// 시드 42에서 무기 획득 // 시드 42에서 무기 획득 (슬롯 0)
expect( expect(
pq_logic.winEquip( pq_logic.winEquip(
config, config,
DeterministicRandom(testSeed), DeterministicRandom(testSeed),
5, 5,
EquipmentSlot.weapon, 0, // weapon slot
), ),
'Longiron', 'Longiron',
); );
// 시드 42에서 방어구 획득 // 시드 42에서 방어구 획득 (슬롯 2 = helm, armor 카테고리)
expect( expect(
pq_logic.winEquip( pq_logic.winEquip(
config, config,
DeterministicRandom(testSeed), DeterministicRandom(testSeed),
5, 5,
EquipmentSlot.armor, 2, // helm slot (armor category)
), ),
'-1 Holey Mildewed Bearskin', '-1 Holey Mildewed Bearskin',
); );
// 시드 42에서 방패 획득 // 시드 42에서 방패 획득 (슬롯 1)
expect( expect(
pq_logic.winEquip( pq_logic.winEquip(
config, config,
DeterministicRandom(testSeed), DeterministicRandom(testSeed),
5, 5,
EquipmentSlot.shield, 1, // shield slot
), ),
'Round Shield', 'Round Shield',
); );