근래에 FFXIV 무인도 컨텐츠의 자동화를 도와주는 플러그인을 발견했기에 당장 써보기로 결정했다.
재미있지도 않은 미친 노가다를 안 할 수 있다니 혹하지 않을 수 있을까.
채집 노드를 알아서 따라가며 지정한 루트는 잘 돌아주지만 다른 문제가 있었으니...
1. 클라이언트 언어의 차이
무인도엔 "워크샵"이라는 컨텐츠가 있고, 각 사이클마다 상품 생산을 예약하고 생산 및 판매해 고유 화폐를 얻는 식으로 구성되어 있다. 최고 효율을 얻는 상품 생산 구성을 매 사이클마다 제공해 주는 디스코드가 있는데, 아이템 이름을 영어로만 제공한다.
저 포맷을 그대로 복사해 플러그인에 붙여 넣기 하면, 플러그인이 알아서 아이템 이름만 뽑아내 게임 데이터와 일치하는지 확인한 후 생산 예약에 등록하는 식으로 작동한다.
# 위는 플러그인을 수정한 상태로 원래대로라면 작동이 되지 않아야 한다.
이것이 문제가 되는 것이다. 플러그인 구현 상 내가 일본어 클라이언트를 사용한다면 영문으로 입력된 정보와 비교가 안되기 때문이다. 아래는 해당 코드다.
private void ImportFromOC(int workshopIndices)
{
List<MJICraftworksObject> rows = new();
foreach (var item in ImGui.GetClipboardText().Split('\n', '\r'))
{
// expected format: ':OC_ItemName: Item Name (4h)'; first part is optional
// strip off everything before last ':' and everything after first '(', then strip off spaces
var actualItem = item.Substring(item.LastIndexOf(':') + 1);
if (actualItem.IndexOf('(') is var tail && tail >= 0)
actualItem = actualItem.Substring(0, tail);
actualItem = actualItem.Trim();
if (actualItem.Length == 0)
continue;
var matchingRows = Service.LuminaGameData.GetExcelSheet<MJICraftworksObject>()!.Where(row => row.Item.Value?.Name.ToString().Contains(actualItem, StringComparison.InvariantCultureIgnoreCase) ?? false).ToList();
if (matchingRows.Count != 1)
{
var error = $"Failed to import schedule: {matchingRows.Count} items matching row '{item}'";
Service.ChatGui.PrintError(error);
Service.Log.Error(error);
return;
}
rows.Add(matchingRows.First());
}
foreach (var row in rows)
AddToSchedule(row, workshopIndices);
}
Lumina
를 통해 MJICraftworksObject
라는 시트를 참조해 아이템 이름과 비교한 후 일치하면 스케줄에 추가하는 식이다.
이 작업을 수행하기 위해 Lumina
라는 라이브러리가 사용되는데, 아직 이 라이브러리에 대해 잘 모르므로 다른 방법을 생각해 냈다.
2. 우회
깔끔한 방법은 아니지만 이런 방법을 사용하기로 했다.
- csv 형식의 데이터를 사용함
- 일어 및 영어 모두의
MJICraftworksObject.csv
를 Export - 고유 키 값이 있으므로 키 값을 비교해 일치하면 텍스트를 일어로 교체
- 교체된 값을 플러그인에서 사용
하면 될거 같았다.
아래는 개념을 확인하기 위한 파이썬 코드이다.
import csv
import re
import pyperclip
def create_dict_from_csv(file_path):
extracted_dict = {}
with open(file_path, newline='', encoding='utf-8') as csvfile:
csv_reader = csv.reader(csvfile)
header = next(csv_reader) # skip header line
for row in csv_reader:
try:
key = int(row[0])
except:
continue
if row[1]:
item_name = row[1]
else:
continue
extracted_dict[key] = item_name
return extracted_dict
extracted_dict_en = create_dict_from_csv('MJICraftworksObject_en.csv')
extracted_dict_ja = create_dict_from_csv('MJICraftworksObject_ja.csv')
clipboard_string = pyperclip.paste()
print(clipboard_string)
pattern = r":OC_(\w+): (.+?) \((\d+h)\)"
matches = re.findall(pattern, clipboard_string)
# clipboard_string을 라인으로 분할
lines = clipboard_string.split('\n')
# 각 라인에 대해 replace 작업 수행
for idx, line in enumerate(lines):
updated_line = line
for match in matches:
emoji = match[0]
english_value = match[1]
japanese_value = ''
for key, value in extracted_dict_en.items():
if english_value in value:
japanese_value = extracted_dict_ja.get(key)
break
# 라인의 끝에 있는 줄임표시를 고려하여 비교
if f":OC_{emoji}: {english_value} ({match[2]})" in line:
updated_line = updated_line.replace(f":OC_{emoji}: {english_value} ({match[2]})", f":OC_{emoji}: {japanese_value} ({match[2]})")
# 업데이트된 라인을 다시 할당
lines[idx] = updated_line
# 라인들을 다시 합치기
clipboard_string = '\n'.join(lines)
print(clipboard_string)
### Input
:OC_CulinaryKnife: Culinary Knife (4h)
:OC_SharkOil: Shark Oil (8h)
:OC_CulinaryKnife: Culinary Knife (4h)
:OC_SharkOil: Shark Oil (8h)
### Output
:OC_CulinaryKnife: アイルクリナリーナイフ (4h)
:OC_SharkOil: 開拓工房のシャークオイル (8h)
:OC_CulinaryKnife: アイルクリナリーナイフ (4h)
:OC_SharkOil: 開拓工房のシャークオイル (8h)
원하는 대로 동작하는 거 같으니 실제 플러그인에 이 로직을 넣어보자.
private Dictionary<int, string> CreateDictionaryFromCsv(string filePath)
{
Dictionary<int, string> extractedDict = new Dictionary<int, string>();
using (StreamReader reader = new StreamReader(filePath))
{
string line;
while ((line = reader.ReadLine()) != null)
{
string[] parts = line.Split(',');
if (int.TryParse(parts[0], out int key) && !string.IsNullOrEmpty(parts[1]))
{
extractedDict[key] = parts[1];
}
}
}
return extractedDict;
}
private string TranslateToJA(string clipboard)
{
Dictionary<int, string> extractedDictEn = CreateDictionaryFromCsv("MJICraftworksObject_en.csv");
Dictionary<int, string> extractedDictJa = CreateDictionaryFromCsv("MJICraftworksObject_ja.csv");
string clipboardString = clipboard;
string pattern = @":OC_(\w+): (.+?) \((\d+h)\)";
MatchCollection matches = Regex.Matches(clipboardString, pattern);
// clipboardString을 라인으로 분할
string[] lines = clipboardString.Split('\n');
// 각 라인에 대해 replace 작업 수행
for (int i = 0; i < lines.Length; i++)
{
string updatedLine = lines[i];
foreach (Match match in matches)
{
string emoji = match.Groups[1].Value;
string englishValue = match.Groups[2].Value;
string duration = match.Groups[3].Value;
string japaneseValue = "";
foreach (var entry in extractedDictEn)
{
if (entry.Value.Contains(englishValue))
{
if (extractedDictJa.TryGetValue(entry.Key, out string jaValue))
{
japaneseValue = jaValue;
Service.Log.Info($"Found {jaValue} in dictionary");
break;
}
}
}
// 라인의 끝에 있는 줄임표시를 고려하여 비교
if (lines[i].Contains($":OC_{emoji}: {englishValue} ({duration})"))
{
updatedLine = updatedLine.Replace($":OC_{emoji}: {englishValue} ({duration})", $":OC_{emoji}: {japaneseValue.Trim('"')} ({duration})");
Service.Log.Info($"Replaced {englishValue} with {japaneseValue}");
}
}
// 업데이트된 라인을 다시 할당
lines[i] = updatedLine;
}
// 라인들을 다시 합치기
clipboardString = string.Join("\n", lines);
return clipboardString;
}
왜인지 모르겟지만 일어 스트링에 큰따옴표가 생겨서 Trim()
으로 제거해 줬다.
이렇게 교체된 스트링은 아래와 같이 ImportFromOC()에 들어간다.
private void ImportFromOC(int workshopIndices)
{
// 교체된 스트링을 대신 넣는다
string clipboardTranslated = TranslateToJA(ImGui.GetClipboardText());
List<MJICraftworksObject> rows = new();
foreach (var item in clipboardTranslated.Split('\n', '\r'))
{
// expected format: ':OC_ItemName: Item Name (4h)'; first part is optional
// strip off everything before last ':' and everything after first '(', then strip off spaces
var actualItem = item.Substring(item.LastIndexOf(':') + 1);
if (actualItem.IndexOf('(') is var tail && tail >= 0)
actualItem = actualItem.Substring(0, tail);
actualItem = actualItem.Trim();
if (actualItem.Length == 0)
continue;
// ....
이렇게 빌드해서 플러그인을 실행하고, 필요한 csv 파일도 ffxiv_dx11.exe
가 있는 경로에 대충 던져두면 끝.
3. 결과
위의 이미지 재탕이긴 한데, 여하튼 Overseas Casuals에서 제공하는 영문 아이템 명을 그대로 가져오더라도
실행에 전혀 문제가 없는 것을 확인할 수 있다.
영클라를 쓰면 이런 과정 없이 해결될 문제지만 일클라를 버릴 순 없지.
4. 생각해볼 점
플러그인 내부에서 Lumina
만을 활용하여 해당 기능이 동작해야 맞는 게 아닐까?
Lumina
를 통해 해당 기능을 구현할 수 있는진 모르겠다.
'Study > C++ & C#' 카테고리의 다른 글
[C#] 심플한 게임 런처 (0) | 2024.03.06 |
---|---|
[C#] ###Clicker (0) | 2024.02.29 |
[C++ / Python / DB] ProcedureGenerator (0) | 2023.09.01 |
[C++ / DB] ORM (0) | 2023.09.01 |
[C++ / DB] XML Parser (0) | 2023.08.31 |