feat: 1단계 — JSON 시나리오 로더 + 스모크 테스트

- ScenarioLoader: JSON 시나리오/창고 레이아웃 → 런타임 ScriptableObject 변환
- warehouse_basic.json: 로케이션 마스터 (해피패스 + 존위반/용량초과 함정)
- InboundManager.StartMission(scenario) 오버로드 (로더 결과 주입용)
- Editor 스모크 테스트: 로더 + 입고 로직 12항목 검증 (12 PASS)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jklee
2026-06-14 19:19:55 +09:00
parent 1051958641
commit de5a40678e
9 changed files with 329 additions and 1 deletions
+152
View File
@@ -0,0 +1,152 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using SinoWms.Data;
namespace SinoWms.Core
{
/// <summary>
/// JSON 시나리오/창고 레이아웃을 읽어 런타임 ScriptableObject 인스턴스로 변환한다.
/// 교육 콘텐츠를 코드와 분리하기 위한 진입점. UI/매니저는 이 결과를 받아 미션을 시작한다.
/// </summary>
public static class ScenarioLoader
{
/// <summary>로더 결과 묶음: 미션 시나리오 + 해당 창고의 로케이션 마스터.</summary>
public class LoadedScenario
{
public InboundScenario scenario;
public List<LocationDefinition> locations = new List<LocationDefinition>();
}
/// <summary>
/// Resources 폴더에서 시나리오/창고 JSON(TextAsset)을 읽어 로드한다.
/// 경로는 확장자 없이 Resources 기준 상대경로 (예: "Scenarios/inbound_basic").
/// </summary>
public static LoadedScenario LoadFromResources(string scenarioPath, string warehousePath)
{
var scenarioAsset = Resources.Load<TextAsset>(scenarioPath);
if (scenarioAsset == null)
throw new InvalidOperationException($"시나리오 JSON을 찾을 수 없습니다: Resources/{scenarioPath}");
var warehouseAsset = Resources.Load<TextAsset>(warehousePath);
if (warehouseAsset == null)
throw new InvalidOperationException($"창고 JSON을 찾을 수 없습니다: Resources/{warehousePath}");
return Load(scenarioAsset.text, warehouseAsset.text);
}
/// <summary>JSON 문자열로부터 직접 로드한다(테스트/외부 소스용).</summary>
public static LoadedScenario Load(string scenarioJson, string warehouseJson)
{
var warehouseDto = JsonUtility.FromJson<WarehouseDto>(warehouseJson);
if (warehouseDto?.locations == null)
throw new InvalidOperationException("창고 JSON 파싱 실패: locations가 없습니다.");
var result = new LoadedScenario();
var byCode = new Dictionary<string, LocationDefinition>();
foreach (var dto in warehouseDto.locations)
{
var loc = ScriptableObject.CreateInstance<LocationDefinition>();
loc.name = dto.locationCode;
loc.locationCode = dto.locationCode;
loc.zone = ParseZone(dto.zone, dto.locationCode);
loc.capacity = dto.capacity;
loc.gridPosition = new Vector2Int(dto.gridX, dto.gridY);
result.locations.Add(loc);
byCode[loc.locationCode] = loc;
}
var scenarioDto = JsonUtility.FromJson<ScenarioDto>(scenarioJson);
if (scenarioDto?.lines == null)
throw new InvalidOperationException("시나리오 JSON 파싱 실패: lines가 없습니다.");
var scenario = ScriptableObject.CreateInstance<InboundScenario>();
scenario.name = scenarioDto.scenarioId;
scenario.scenarioId = scenarioDto.scenarioId;
scenario.title = scenarioDto.title;
scenario.timeLimitSeconds = scenarioDto.timeLimitSeconds;
foreach (var lineDto in scenarioDto.lines)
{
var item = ScriptableObject.CreateInstance<ItemDefinition>();
item.name = lineDto.sku;
item.sku = lineDto.sku;
item.displayName = lineDto.displayName;
item.barcode = lineDto.barcode;
item.requiredZone = ParseZone(lineDto.requiredZone, lineDto.sku);
item.unitVolume = lineDto.unitVolume;
LocationDefinition suggested = null;
if (!string.IsNullOrEmpty(lineDto.suggestedLocation))
{
if (!byCode.TryGetValue(lineDto.suggestedLocation, out suggested))
Debug.LogWarning($"[ScenarioLoader] {lineDto.sku}의 정답 로케이션 " +
$"'{lineDto.suggestedLocation}'이(가) 창고에 없습니다.");
}
scenario.lines.Add(new InboundLine
{
item = item,
expectedQty = lineDto.expectedQty,
actualQty = lineDto.actualQty,
suggestedLocation = suggested
});
}
result.scenario = scenario;
return result;
}
private static StorageZone ParseZone(string raw, string ownerForLog)
{
if (Enum.TryParse(raw, ignoreCase: true, out StorageZone zone))
return zone;
Debug.LogWarning($"[ScenarioLoader] 알 수 없는 존 '{raw}' ({ownerForLog}) → Ambient로 대체.");
return StorageZone.Ambient;
}
// ---- JSON 매핑용 DTO (JsonUtility는 public 필드 + [Serializable] 필요) ----
[Serializable]
private class ScenarioDto
{
public string scenarioId;
public string title;
public float timeLimitSeconds;
public LineDto[] lines;
}
[Serializable]
private class LineDto
{
public string sku;
public string displayName;
public string barcode;
public string requiredZone;
public int unitVolume;
public int expectedQty;
public int actualQty;
public string suggestedLocation;
}
[Serializable]
private class WarehouseDto
{
public string warehouseId;
public string title;
public LocationDto[] locations;
}
[Serializable]
private class LocationDto
{
public string locationCode;
public string zone;
public int capacity;
public int gridX;
public int gridY;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: fe252208b8d334ce181dd57a84601eb1