refactor(util): pq_logic.dart 모듈 분할

- pq_random.dart: 랜덤/확률 함수 (61줄)
- pq_string.dart: 문자열 유틸리티 (55줄)
- pq_item.dart: 아이템/장비 생성 (327줄)
- pq_monster.dart: 몬스터 생성 (283줄)
- pq_quest.dart: 퀘스트/Act/시네마틱 (283줄)
- pq_task.dart: 태스크/큐 (97줄)
- pq_stat.dart: 스탯 관련 (64줄)
- 원본은 re-export 허브로 유지 (역호환성)
This commit is contained in:
JiWoong Sul
2026-01-15 17:05:46 +09:00
parent 133d516b94
commit 23f15f41d3
8 changed files with 1188 additions and 1102 deletions

View File

@@ -0,0 +1,327 @@
import 'dart:math' as math;
import 'package:asciineverdie/src/core/model/equipment_slot.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/pq_random.dart';
/// 아이템/장비 생성 관련 함수들
/// 원본 Main.pas의 아이템 생성 로직을 포팅
/// 장비 생성 결과 (구조화된 데이터로 l10n 지원)
class EquipResult {
const EquipResult({
required this.baseName,
this.modifiers = const [],
this.plusValue = 0,
});
/// 기본 장비 이름 (예: "VPN Cloak")
final String baseName;
/// 수식어 목록 (예: ["Holey", "Deprecated"])
final List<String> modifiers;
/// +/- 수치 (예: -1, +2)
final int plusValue;
/// 영문 전체 이름 생성 (기존 방식)
String get displayName {
var name = baseName;
for (final mod in modifiers) {
name = '$mod $name';
}
if (plusValue != 0) {
name = '${plusValue > 0 ? '+' : ''}$plusValue $name';
}
return name;
}
}
/// 아이템 생성 결과 (구조화된 데이터로 l10n 지원)
class ItemResult {
const ItemResult({this.attrib, this.special, this.itemOf, this.boringItem});
/// 아이템 속성 (예: "Golden")
final String? attrib;
/// 특수 아이템 (예: "Iterator")
final String? special;
/// "~의" 접미사 (예: "Monitoring")
final String? itemOf;
/// 단순 아이템 (보링 아이템용)
final String? boringItem;
/// 영문 전체 이름 생성 (기존 방식)
String get displayName {
if (boringItem != null) return boringItem!;
if (attrib != null && special != null && itemOf != null) {
return '$attrib $special of $itemOf';
}
if (attrib != null && special != null) {
return '$attrib $special';
}
return '';
}
}
// =============================================================================
// 아이템 생성 함수들
// =============================================================================
/// 단순 아이템 (Boring Item)
String boringItem(PqConfig config, DeterministicRandom rng) {
return pick(config.boringItems, rng);
}
/// 흥미로운 아이템 (Interesting Item)
String interestingItem(PqConfig config, DeterministicRandom rng) {
final attr = pick(config.itemAttrib, rng);
final special = pick(config.specials, rng);
return '$attr $special';
}
/// 특수 아이템 (Special Item)
String specialItem(PqConfig config, DeterministicRandom rng) {
return '${interestingItem(config, rng)} of ${pick(config.itemOfs, rng)}';
}
/// 구조화된 단순 아이템 결과 반환 (l10n 지원)
ItemResult boringItemStructured(PqConfig config, DeterministicRandom rng) {
return ItemResult(boringItem: pick(config.boringItems, rng));
}
/// 구조화된 흥미로운 아이템 결과 반환 (l10n 지원)
ItemResult interestingItemStructured(PqConfig config, DeterministicRandom rng) {
return ItemResult(
attrib: pick(config.itemAttrib, rng),
special: pick(config.specials, rng),
);
}
/// 구조화된 특수 아이템 결과 반환 (l10n 지원)
ItemResult specialItemStructured(PqConfig config, DeterministicRandom rng) {
return ItemResult(
attrib: pick(config.itemAttrib, rng),
special: pick(config.specials, rng),
itemOf: pick(config.itemOfs, rng),
);
}
/// 구조화된 승리 아이템 결과 반환 (l10n 지원)
ItemResult winItemStructured(
PqConfig config,
DeterministicRandom rng,
int inventoryCount,
) {
final threshold = math.max(250, rng.nextInt(999));
if (inventoryCount > threshold) {
return const ItemResult(); // 빈 결과
}
return specialItemStructured(config, rng);
}
/// 승리 아이템 (문자열 반환)
String winItem(PqConfig config, DeterministicRandom rng, int inventoryCount) {
final threshold = math.max(250, rng.nextInt(999));
if (inventoryCount > threshold) return '';
return specialItem(config, rng);
}
// =============================================================================
// 장비 선택 함수들
// =============================================================================
/// 무기 선택
String pickWeapon(PqConfig config, DeterministicRandom rng, int level) {
return _lPick(config.weapons, rng, level);
}
/// 방패 선택
String pickShield(PqConfig config, DeterministicRandom rng, int level) {
return _lPick(config.shields, rng, level);
}
/// 방어구 선택
String pickArmor(PqConfig config, DeterministicRandom rng, int level) {
return _lPick(config.armors, rng, level);
}
/// 주문 선택
String pickSpell(PqConfig config, DeterministicRandom rng, int goalLevel) {
return _lPick(config.spells, rng, goalLevel);
}
/// 원본 Main.pas:776-789 LPick: 6회 시도하여 목표 레벨에 가장 가까운 아이템 선택
String _lPick(List<String> items, DeterministicRandom rng, int goal) {
if (items.isEmpty) return '';
var result = pick(items, rng);
var bestLevel = _parseLevel(result);
for (var i = 0; i < 5; i++) {
final candidate = pick(items, rng);
final candLevel = _parseLevel(candidate);
if ((goal - candLevel).abs() < (goal - bestLevel).abs()) {
result = candidate;
bestLevel = candLevel;
}
}
return result;
}
/// 아이템 문자열에서 레벨 파싱
int _parseLevel(String entry) {
final parts = entry.split('|');
if (parts.length < 2) return 0;
return int.tryParse(parts[1].replaceAll('+', '')) ?? 0;
}
// =============================================================================
// 장비 생성 함수들
// =============================================================================
/// 수식어 추가
String addModifier(
DeterministicRandom rng,
String baseName,
List<String> modifiers,
int plus,
) {
var name = baseName;
var remaining = plus;
var count = 0;
while (count < 2 && remaining != 0) {
final modifier = pick(modifiers, rng);
final parts = modifier.split('|');
if (parts.isEmpty) break;
final label = parts[0];
final qual = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0;
if (name.contains(label)) break; // avoid repeats
if (remaining.abs() < qual.abs()) break;
name = '$label $name';
remaining -= qual;
count++;
}
if (remaining != 0) {
name = '${remaining > 0 ? '+' : ''}$remaining $name';
}
return name;
}
/// 장비 생성 (원본 Main.pas:791-830 WinEquip)
/// [slotIndex]: 0=Weapon, 1=Shield, 2-10=Armor 계열
String winEquip(
PqConfig config,
DeterministicRandom rng,
int level,
int slotIndex,
) {
final bool isWeapon = slotIndex == 0;
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 worse = isWeapon ? config.offenseBad : config.defenseBad;
final base = _lPick(items, rng, level);
final parts = base.split('|');
final baseName = parts[0];
final qual = parts.length > 1
? int.tryParse(parts[1].replaceAll('+', '')) ?? 0
: 0;
final plus = level - qual;
final modifierList = plus >= 0 ? better : worse;
return addModifier(rng, baseName, modifierList, plus);
}
/// EquipmentSlot enum을 사용하는 편의 함수
String winEquipBySlot(
PqConfig config,
DeterministicRandom rng,
int level,
EquipmentSlot slot,
) {
return winEquip(config, rng, level, slot.index);
}
/// 구조화된 장비 생성 결과 반환 (l10n 지원)
EquipResult winEquipStructured(
PqConfig config,
DeterministicRandom rng,
int level,
int slotIndex,
) {
final bool isWeapon = slotIndex == 0;
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 worse = isWeapon ? config.offenseBad : config.defenseBad;
final base = _lPick(items, rng, level);
final parts = base.split('|');
final baseName = parts[0];
final qual = parts.length > 1
? int.tryParse(parts[1].replaceAll('+', '')) ?? 0
: 0;
final plus = level - qual;
final modifierList = plus >= 0 ? better : worse;
return _addModifierStructured(rng, baseName, modifierList, plus);
}
/// 구조화된 장비 결과 반환 (내부 함수)
EquipResult _addModifierStructured(
DeterministicRandom rng,
String baseName,
List<String> modifiers,
int plus,
) {
final collectedModifiers = <String>[];
var remaining = plus;
var count = 0;
while (count < 2 && remaining != 0) {
final modifier = pick(modifiers, rng);
final parts = modifier.split('|');
if (parts.isEmpty) break;
final label = parts[0];
final qual = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0;
if (collectedModifiers.contains(label)) break; // avoid repeats
if (remaining.abs() < qual.abs()) break;
collectedModifiers.add(label);
remaining -= qual;
count++;
}
return EquipResult(
baseName: baseName,
modifiers: collectedModifiers,
plusValue: remaining,
);
}
/// EquipmentSlot enum을 사용하는 구조화된 버전
EquipResult winEquipBySlotStructured(
PqConfig config,
DeterministicRandom rng,
int level,
EquipmentSlot slot,
) {
return winEquipStructured(config, rng, level, slot.index);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,283 @@
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/model/monster_grade.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/pq_random.dart';
/// 몬스터 생성 관련 함수들
/// 원본 Main.pas의 몬스터 생성 로직을 포팅
/// monsterTask의 반환 타입 (원본 fTask.Caption 정보 포함)
class MonsterTaskResult {
const MonsterTaskResult({
required this.displayName,
required this.baseName,
required this.level,
required this.part,
required this.grade,
});
/// 화면에 표시할 몬스터 이름 (형용사 포함, 예: "a sick Goblin")
final String displayName;
/// 기본 몬스터 이름 (형용사 제외, 예: "Goblin")
final String baseName;
/// 몬스터 레벨
final int level;
/// 전리품 부위 (예: "claw", "tail", "*"는 WinItem 호출)
final String part;
/// 몬스터 등급 (Normal/Elite/Boss)
final MonsterGrade grade;
}
/// 몬스터 등급 결정 (Normal 85%, Elite 12%, Boss 3%)
/// 몬스터 레벨이 플레이어 레벨보다 높으면 상위 등급 확률 증가
MonsterGrade _determineGrade(
int monsterLevel,
int playerLevel,
DeterministicRandom rng,
) {
final levelDiff = monsterLevel - playerLevel;
final eliteBonus = (levelDiff * 2).clamp(0, 10);
final bossBonus = (levelDiff * 0.5).clamp(0, 3).toInt();
final roll = rng.nextInt(100);
if (roll < 3 + bossBonus) return MonsterGrade.boss;
if (roll < 15 + eliteBonus) return MonsterGrade.elite;
return MonsterGrade.normal;
}
/// 몬스터 문자열에서 레벨 파싱
int _monsterLevel(String entry) {
final parts = entry.split('|');
if (parts.length < 2) return 0;
return int.tryParse(parts[1]) ?? 0;
}
/// 몬스터 태스크 생성 (원본 Main.pas:523-640)
MonsterTaskResult monsterTask(
PqConfig config,
DeterministicRandom rng,
int level,
String? questMonster,
int? questLevel,
) {
var targetLevel = level;
for (var i = level; i > 0; i--) {
if (rng.nextInt(5) < 2) {
targetLevel += rng.nextInt(2) * 2 - 1; // RandSign
}
}
if (targetLevel < 1) targetLevel = 1;
String monster;
int monsterLevel;
String part;
bool definite = false;
// 원본 Main.pas:537-547: 가끔 NPC를 몬스터로 사용
if (rng.nextInt(25) == 0) {
final raceEn = pick(config.races, rng).split('|').first;
final race = l10n.translateRace(raceEn);
if (rng.nextInt(2) == 0) {
final klassEn = pick(config.klasses, rng).split('|').first;
final klass = l10n.translateKlass(klassEn);
monster = l10n.modifierPassing('$race $klass');
} else {
final titleEn = pickLow(config.titles, rng);
final title = l10n.translateTitle(titleEn);
monster = l10n.namedMonsterFormat(generateName(rng), '$title $race');
definite = true;
}
monsterLevel = targetLevel;
part = '*';
} else if (questMonster != null && rng.nextInt(4) == 0) {
monster = questMonster;
final parts = questMonster.split('|');
monsterLevel = questLevel ?? targetLevel;
part = parts.length > 2 ? parts[2] : '';
} else {
monster = pick(config.monsters, rng);
monsterLevel = _monsterLevel(monster);
for (var i = 0; i < 5; i++) {
final candidate = pick(config.monsters, rng);
final candLevel = _monsterLevel(candidate);
if ((targetLevel - candLevel).abs() <
(targetLevel - monsterLevel).abs()) {
monster = candidate;
monsterLevel = candLevel;
}
}
final monsterParts = monster.split('|');
part = monsterParts.length > 2 ? monsterParts[2] : '';
}
final baseName = monster.split('|').first;
var qty = 1;
final levelDiff = targetLevel - monsterLevel;
var name = l10n.translateMonster(baseName);
if (levelDiff > 10) {
qty =
(targetLevel + rng.nextInt(monsterLevel == 0 ? 1 : monsterLevel)) ~/
(monsterLevel == 0 ? 1 : monsterLevel);
if (qty < 1) qty = 1;
targetLevel ~/= qty;
}
if (levelDiff <= -10) {
name = l10n.modifierImaginary(name);
} else if (levelDiff < -5) {
final i = 5 - rng.nextInt(10 + levelDiff + 1);
name = _sick(i, _young((monsterLevel - targetLevel) - i, name));
} else if (levelDiff < 0) {
if (rng.nextInt(2) == 1) {
name = _sick(levelDiff, name);
} else {
name = _young(levelDiff, name);
}
} else if (levelDiff >= 10) {
name = l10n.modifierMessianic(name);
} else if (levelDiff > 5) {
final i = 5 - rng.nextInt(10 - levelDiff + 1);
name = _big(i, _special((levelDiff) - i, name));
} else if (levelDiff > 0) {
if (rng.nextInt(2) == 1) {
name = _big(levelDiff, name);
} else {
name = _special(levelDiff, name);
}
}
if (!definite) {
name = l10n.indefiniteL10n(name, qty);
}
final grade = _determineGrade(monsterLevel, level, rng);
return MonsterTaskResult(
displayName: name,
baseName: baseName,
level: monsterLevel * qty,
part: part,
grade: grade,
);
}
/// 이름 있는 몬스터 생성 (NamedMonster, Main.pas:498-512)
String namedMonster(PqConfig config, DeterministicRandom rng, int level) {
String best = '';
int bestLevel = 0;
for (var i = 0; i < 5; i++) {
final m = pick(config.monsters, rng);
final parts = m.split('|');
final name = parts.first;
final lev = parts.length > 1 ? (int.tryParse(parts[1]) ?? 0) : 0;
if (best.isEmpty || (level - lev).abs() < (level - bestLevel).abs()) {
best = name;
bestLevel = lev;
}
}
final translatedMonster = l10n.translateMonster(best);
return l10n.namedMonsterFormat(generateName(rng), translatedMonster);
}
// =============================================================================
// 몬스터 수식어 함수들
// =============================================================================
String _sick(int m, String s) {
switch (m) {
case -5:
case 5:
return l10n.modifierDead(s);
case -4:
case 4:
return l10n.modifierComatose(s);
case -3:
case 3:
return l10n.modifierCrippled(s);
case -2:
case 2:
return l10n.modifierSick(s);
case -1:
case 1:
return l10n.modifierUndernourished(s);
default:
return '$m$s';
}
}
String _young(int m, String s) {
switch (-m) {
case -5:
case 5:
return l10n.modifierFoetal(s);
case -4:
case 4:
return l10n.modifierBaby(s);
case -3:
case 3:
return l10n.modifierPreadolescent(s);
case -2:
case 2:
return l10n.modifierTeenage(s);
case -1:
case 1:
return l10n.modifierUnderage(s);
default:
return '$m$s';
}
}
String _big(int m, String s) {
switch (m) {
case 1:
case -1:
return l10n.modifierGreater(s);
case 2:
case -2:
return l10n.modifierMassive(s);
case 3:
case -3:
return l10n.modifierEnormous(s);
case 4:
case -4:
return l10n.modifierGiant(s);
case 5:
case -5:
return l10n.modifierTitanic(s);
default:
return s;
}
}
String _special(int m, String s) {
switch (-m) {
case 1:
case -1:
return s.contains(' ') ? l10n.modifierVeteran(s) : l10n.modifierBattle(s);
case 2:
case -2:
return l10n.modifierCursed(s);
case 3:
case -3:
return s.contains(' ') ? l10n.modifierWarrior(s) : l10n.modifierWere(s);
case 4:
case -4:
return l10n.modifierUndead(s);
case 5:
case -5:
return l10n.modifierDemon(s);
default:
return s;
}
}

View File

@@ -0,0 +1,283 @@
import 'dart:math' as math;
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/model/game_state.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/pq_item.dart';
import 'package:asciineverdie/src/core/util/pq_monster.dart';
import 'package:asciineverdie/src/core/util/pq_random.dart';
import 'package:asciineverdie/src/core/util/roman.dart';
/// 퀘스트/Act/시네마틱 관련 함수들
/// 원본 Main.pas의 퀘스트 로직을 포팅
/// 보상 종류
enum RewardKind { spell, equip, stat, item }
/// 퀘스트 결과
class QuestResult {
const QuestResult({
required this.caption,
required this.reward,
this.monsterName,
this.monsterLevel,
this.monsterIndex,
});
final String caption;
final RewardKind reward;
/// 몬스터 전체 데이터 (예: "Rat|5|tail") - 원본 fQuest.Caption
final String? monsterName;
/// 몬스터 레벨 (파싱된 값)
final int? monsterLevel;
/// 몬스터 인덱스 (config.monsters에서의 위치) - 원본 fQuest.Tag
final int? monsterIndex;
}
/// Act 결과
class ActResult {
const ActResult({
required this.actTitle,
required this.plotBarMaxSeconds,
required this.rewards,
});
final String actTitle;
final int plotBarMaxSeconds;
final List<RewardKind> rewards;
}
/// 몬스터 문자열에서 레벨 파싱
int _monsterLevel(String entry) {
final parts = entry.split('|');
if (parts.length < 2) return 0;
return int.tryParse(parts[1]) ?? 0;
}
/// 퀘스트 완료 처리 (원본 Main.pas:930-984)
QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
final rewardRoll = rng.nextInt(4);
final reward = switch (rewardRoll) {
0 => RewardKind.spell,
1 => RewardKind.equip,
2 => RewardKind.stat,
_ => RewardKind.item,
};
final questRoll = rng.nextInt(5);
switch (questRoll) {
case 0:
// Exterminate: 4번 시도하여 레벨에 가장 가까운 몬스터 선택
var best = '';
var bestLevel = 0;
var bestIndex = 0;
for (var i = 0; i < 4; i++) {
final monsterIndex = rng.nextInt(config.monsters.length);
final m = config.monsters[monsterIndex];
final l = _monsterLevel(m);
if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) {
best = m;
bestLevel = l;
bestIndex = monsterIndex;
}
}
final nameEn = best.split('|').first;
final name = l10n.translateMonster(nameEn);
return QuestResult(
caption: l10n.questPatch(l10n.definiteL10n(name, 2)),
reward: reward,
monsterName: best,
monsterLevel: bestLevel,
monsterIndex: bestIndex,
);
case 1:
// interestingItem: attrib + special 조합 후 번역
final attr = pick(config.itemAttrib, rng);
final special = pick(config.specials, rng);
final item = l10n.translateInterestingItem(attr, special);
return QuestResult(
caption: l10n.questLocate(l10n.definiteL10n(item, 1)),
reward: reward,
);
case 2:
final itemEn = boringItem(config, rng);
final item = l10n.translateBoringItem(itemEn);
return QuestResult(caption: l10n.questTransfer(item), reward: reward);
case 3:
final itemEn = boringItem(config, rng);
final item = l10n.translateBoringItem(itemEn);
return QuestResult(
caption: l10n.questDownload(l10n.indefiniteL10n(item, 1)),
reward: reward,
);
default:
// Stabilize: 2번 시도하여 레벨에 가장 가까운 몬스터 선택
var best = '';
var bestLevel = 0;
for (var i = 0; i < 2; i++) {
final m = pick(config.monsters, rng);
final l = _monsterLevel(m);
if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) {
best = m;
bestLevel = l;
}
}
final nameEn = best.split('|').first;
final name = l10n.translateMonster(nameEn);
return QuestResult(
caption: l10n.questStabilize(l10n.definiteL10n(name, 2)),
reward: reward,
);
}
}
/// Act별 Plot Bar 최대값 (초) - 10시간 완주 목표
const _actPlotBarSeconds = [
300, // Prologue: 5분
7200, // Act I: 2시간
10800, // Act II: 3시간
10800, // Act III: 3시간
5400, // Act IV: 1.5시간
1800, // Act V: 30분
];
/// Act 완료 처리
ActResult completeAct(int existingActCount) {
final nextActIndex = existingActCount;
final title = l10n.actTitle(intToRoman(nextActIndex));
final plotBarMax = existingActCount < _actPlotBarSeconds.length
? _actPlotBarSeconds[existingActCount]
: 3600;
final rewards = <RewardKind>[];
if (existingActCount >= 1) {
rewards.add(RewardKind.equip);
}
if (existingActCount > 1) {
rewards.add(RewardKind.item);
}
return ActResult(
actTitle: title,
plotBarMaxSeconds: plotBarMax,
rewards: rewards,
);
}
// =============================================================================
// 시네마틱 관련 함수들 (Main.pas:456-521)
// =============================================================================
/// NPC 이름 생성 (ImpressiveGuy, Main.pas:514-521)
String impressiveGuy(PqConfig config, DeterministicRandom rng) {
final titleEn = pick(config.impressiveTitles, rng);
final title = l10n.translateImpressiveTitle(titleEn);
switch (rng.nextInt(2)) {
case 0:
final raceEn = pick(config.races, rng).split('|').first;
final race = l10n.translateRace(raceEn);
return l10n.impressiveGuyPattern1(title, race);
case 1:
final name1 = generateName(rng);
final name2 = generateName(rng);
return l10n.impressiveGuyPattern2(title, name1, name2);
default:
return title;
}
}
/// 플롯 간 시네마틱 이벤트 생성 (InterplotCinematic, Main.pas:456-495)
List<QueueEntry> interplotCinematic(
PqConfig config,
DeterministicRandom rng,
int level,
int plotCount,
) {
final entries = <QueueEntry>[];
void q(QueueKind kind, int seconds, String caption) {
entries.add(
QueueEntry(kind: kind, durationMillis: seconds * 1000, caption: caption),
);
}
switch (rng.nextInt(3)) {
case 0:
// 시나리오 1: 안전한 캐시 영역 도착
q(QueueKind.task, 1, l10n.cinematicCacheZone1());
q(QueueKind.task, 2, l10n.cinematicCacheZone2());
q(QueueKind.task, 2, l10n.cinematicCacheZone3());
q(QueueKind.task, 1, l10n.cinematicCacheZone4());
break;
case 1:
// 시나리오 2: 강력한 버그와의 전투
q(QueueKind.task, 1, l10n.cinematicCombat1());
final nemesis = namedMonster(config, rng, level + 3);
q(QueueKind.task, 4, l10n.cinematicCombat2(nemesis));
var s = rng.nextInt(3);
final combatRounds = rng.nextInt(1 + plotCount);
for (var i = 0; i < combatRounds; i++) {
s += 1 + rng.nextInt(2);
switch (s % 3) {
case 0:
q(QueueKind.task, 2, l10n.cinematicCombatLocked(nemesis));
break;
case 1:
q(QueueKind.task, 2, l10n.cinematicCombatCorrupts(nemesis));
break;
case 2:
q(QueueKind.task, 2, l10n.cinematicCombatWorking(nemesis));
break;
}
}
q(QueueKind.task, 3, l10n.cinematicCombatVictory(nemesis));
q(QueueKind.task, 2, l10n.cinematicCombatWakeUp());
break;
case 2:
// 시나리오 3: 내부자 위협 발견
final guy = impressiveGuy(config, rng);
final itemEn = boringItem(config, rng);
final item = l10n.translateBoringItem(itemEn);
q(QueueKind.task, 2, l10n.cinematicBetrayal1(guy));
q(QueueKind.task, 3, l10n.cinematicBetrayal2(guy));
q(QueueKind.task, 2, l10n.cinematicBetrayal3(item));
q(QueueKind.task, 2, l10n.cinematicBetrayal4());
q(QueueKind.task, 2, l10n.cinematicBetrayal5(guy));
q(QueueKind.task, 3, l10n.cinematicBetrayal6());
break;
}
q(QueueKind.plot, 2, l10n.taskCompiling);
return entries;
}
// =============================================================================
// 스펠 관련 함수
// =============================================================================
/// 스펠 획득 (원본 Main.pas:770-774)
String winSpell(
PqConfig config,
DeterministicRandom rng,
int wisdom,
int level,
) {
final maxIndex = math.min(wisdom + level, config.spells.length);
if (maxIndex <= 0) return '';
final index = randomLow(rng, maxIndex);
final entry = config.spells[index];
final parts = entry.split('|');
final name = parts[0];
final currentRank = romanToInt(parts.length > 1 ? parts[1] : 'I');
final nextRank = math.max(1, currentRank + 1);
return '$name|${intToRoman(nextRank)}';
}

View File

@@ -0,0 +1,61 @@
import 'dart:math' as math;
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
/// 랜덤/확률 관련 유틸리티 함수들
/// 원본 Main.pas의 랜덤 헬퍼 함수들을 포팅
/// 두 번의 랜덤 중 작은 값 반환 (낮은 인덱스 선호)
int randomLow(DeterministicRandom rng, int below) {
return math.min(rng.nextInt(below), rng.nextInt(below));
}
/// 리스트에서 랜덤 선택
String pick(List<String> values, DeterministicRandom rng) {
if (values.isEmpty) return '';
return values[rng.nextInt(values.length)];
}
/// 리스트에서 낮은 인덱스 선호하여 선택
String pickLow(List<String> values, DeterministicRandom rng) {
if (values.isEmpty) return '';
return values[randomLow(rng, values.length)];
}
/// 64비트 범위 랜덤 (큰 범위용)
int random64Below(DeterministicRandom rng, int below) {
if (below <= 0) return 0;
final hi = rng.nextUint32();
final lo = rng.nextUint32();
final combined = (hi << 32) | lo;
return (combined % below).toInt();
}
/// 3d6 스탯 롤
int rollStat(DeterministicRandom rng) {
return 3 + rng.nextInt(6) + rng.nextInt(6) + rng.nextInt(6);
}
/// 캐릭터 이름 생성 (원본 NewGuy.pas 기반)
String generateName(DeterministicRandom rng) {
const kParts = [
'br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z',
'a|a|e|e|i|i|o|o|u|u|ae|ie|oo|ou',
'b|ck|d|g|k|m|n|p|t|v|x|z',
];
var result = '';
for (var i = 0; i <= 5; i++) {
result += _pickFromPipe(kParts[i % 3], rng);
}
if (result.isEmpty) return result;
return '${result[0].toUpperCase()}${result.substring(1)}';
}
/// 파이프(|)로 구분된 문자열에서 랜덤 선택
String _pickFromPipe(String pipeSeparated, DeterministicRandom rng) {
final parts = pipeSeparated.split('|');
if (parts.isEmpty) return '';
final idx = rng.nextInt(parts.length);
return parts[idx];
}

View File

@@ -0,0 +1,64 @@
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
import 'package:asciineverdie/src/core/util/pq_random.dart';
/// 스탯 관련 함수들
/// 원본 Main.pas의 스탯 처리 로직을 포팅
/// 스탯 증가 인덱스 결정 (원본 Main.pas:870-883)
int winStatIndex(DeterministicRandom rng, List<int> statValues) {
// 50%: 모든 8개 스탯 중 랜덤
// 50%: 첫 6개(STR~CHA)만 제곱 가중치로 선택
if (rng.nextInt(2) == 0) {
return rng.nextInt(statValues.length);
}
// 첫 6개(STR, CON, DEX, INT, WIS, CHA)만 제곱 가중치 적용
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);
for (var i = 0; i < firstSixCount; i++) {
pickValue -= statValues[i] * statValues[i];
if (pickValue < 0) return i;
}
return firstSixCount - 1;
}
/// 스탯 증가 적용
Stats winStat(Stats stats, DeterministicRandom rng) {
final values = <int>[
stats.str,
stats.con,
stats.dex,
stats.intelligence,
stats.wis,
stats.cha,
stats.hpMax,
stats.mpMax,
];
final idx = winStatIndex(rng, values);
switch (idx) {
case 0:
return stats.copyWith(str: stats.str + 1);
case 1:
return stats.copyWith(con: stats.con + 1);
case 2:
return stats.copyWith(dex: stats.dex + 1);
case 3:
return stats.copyWith(intelligence: stats.intelligence + 1);
case 4:
return stats.copyWith(wis: stats.wis + 1);
case 5:
return stats.copyWith(cha: stats.cha + 1);
case 6:
return stats.copyWith(hpMax: stats.hpMax + 1);
case 7:
return stats.copyWith(mpMax: stats.mpMax + 1);
default:
return stats;
}
}

View File

@@ -0,0 +1,55 @@
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
/// 문자열 유틸리티 함수들
/// 원본 Main.pas의 문자열 처리 함수들을 포팅
/// 단어를 복수형으로 변환 (영문)
String pluralize(String s) {
if (_ends(s, 'y')) return '${s.substring(0, s.length - 1)}ies';
if (_ends(s, 'us')) return '${s.substring(0, s.length - 2)}i';
if (_ends(s, 'ch') || _ends(s, 'x') || _ends(s, 's')) return '${s}es';
if (_ends(s, 'f')) return '${s.substring(0, s.length - 1)}ves';
if (_ends(s, 'man') || _ends(s, 'Man')) {
return '${s.substring(0, s.length - 2)}en';
}
return '${s}s';
}
/// 부정관사 + 명사 (a/an + 단수 또는 수량 + 복수)
String indefinite(String s, int qty) {
if (qty == 1) {
const vowels = 'AEIOUÜaeiouü';
final first = s.isNotEmpty ? s[0] : 'a';
final article = vowels.contains(first) ? 'an' : 'a';
return '$article $s';
}
return '$qty ${pluralize(s)}';
}
/// 정관사 + 명사 (the + 명사)
String definite(String s, int qty) {
if (qty > 1) {
s = pluralize(s);
}
return 'the $s';
}
/// 초 단위 시간을 사람이 읽기 쉬운 형태로 변환 (원본 Main.pas:1265-1271)
/// l10n 지원
String roughTime(int seconds) {
if (seconds < 120) {
return l10n.roughTimeSeconds(seconds);
} else if (seconds < 60 * 120) {
return l10n.roughTimeMinutes(seconds ~/ 60);
} else if (seconds < 60 * 60 * 48) {
return l10n.roughTimeHours(seconds ~/ 3600);
} else {
return l10n.roughTimeDays(seconds ~/ (3600 * 24));
}
}
/// 문자열이 특정 접미사로 끝나는지 확인
bool _ends(String s, String suffix) {
return s.length >= suffix.length &&
s.substring(s.length - suffix.length) == suffix;
}

View File

@@ -0,0 +1,97 @@
import 'dart:collection';
import 'package:asciineverdie/src/core/model/game_state.dart';
/// 태스크/큐/진행 관련 함수들
/// 원본 Main.pas의 태스크 처리 로직을 포팅
/// 레벨업에 필요한 시간 (초)
int levelUpTimeSeconds(int level) {
// Act 진행과 레벨업 동기화 (10시간 완주 목표)
if (level <= 20) {
// Act I: 레벨 1-20, 2시간 (7200초) → 평균 360초/레벨
return 300 + (level * 6);
} else if (level <= 40) {
// Act II: 레벨 21-40, 3시간 (10800초) → 평균 540초/레벨
return 400 + ((level - 20) * 10);
} else if (level <= 60) {
// Act III: 레벨 41-60, 3시간 (10800초) → 평균 540초/레벨
return 400 + ((level - 40) * 10);
} else if (level <= 80) {
// Act IV: 레벨 61-80, 1.5시간 (5400초) → 평균 270초/레벨
return 200 + ((level - 60) * 5);
} else {
// Act V: 레벨 81-100, 30분 (1800초) → 평균 90초/레벨
return 60 + ((level - 80) * 3);
}
}
/// levelUpTimeSeconds의 별칭 (호환성 유지)
int levelUpTime(int level) => levelUpTimeSeconds(level);
/// 태스크 결과
class TaskResult {
const TaskResult({
required this.caption,
required this.durationMillis,
required this.progress,
});
final String caption;
final int durationMillis;
final ProgressState progress;
}
/// 태스크 시작: 태스크 바 리셋 및 캡션 설정
TaskResult startTask(
ProgressState progress,
String caption,
int durationMillis,
) {
final updated = progress.copyWith(
task: ProgressBarState(position: 0, max: durationMillis),
);
return TaskResult(
caption: '$caption...',
durationMillis: durationMillis,
progress: updated,
);
}
/// 큐 처리 결과
class DequeueResult {
const DequeueResult({
required this.progress,
required this.queue,
required this.caption,
required this.taskType,
required this.kind,
});
final ProgressState progress;
final QueueState queue;
final String caption;
final TaskType taskType;
final QueueKind kind;
}
/// 큐에서 다음 태스크 꺼내기
/// 현재 태스크가 완료되면 큐에서 다음 태스크를 가져옴
DequeueResult? dequeue(ProgressState progress, QueueState queue) {
// 태스크 바가 완료되지 않으면 null 반환
if (progress.task.position < progress.task.max) return null;
if (queue.entries.isEmpty) return null;
final entries = Queue<QueueEntry>.from(queue.entries);
if (entries.isEmpty) return null;
final next = entries.removeFirst();
final taskResult = startTask(progress, next.caption, next.durationMillis);
return DequeueResult(
progress: taskResult.progress,
queue: QueueState(entries: entries.toList()),
caption: taskResult.caption,
taskType: next.taskType,
kind: next.kind,
);
}