"""납품 인박스 → 게임 반영 (파일 작업만 · Unity 쪽은 끝에 출력하는 명령으로). PD 2026-11-05: 「매번 수신하지 않아도, 요청한 작업이 완전히 완료된 경우 해당 리소스만 전달받는 체계」. 제작 세션은 묶음이 **완전히** 끝났을 때만 아래 json 한 장을 인박스에 쓴다(중간 보고 없음 · 채팅 메시지 없음). 인박스 = C:\\ArtRnD\\samguk_game\\deliveries\\_.json { "batch": "bg" | "hgj" | "heroes_back" | "dz" | "cc" | "wei" | …, "kind": "bg" | "poses" | "worldmap" | "ui", "qa": "PASS", "session": "<만든 세션 이름>", # kind == "bg" "file": "C:/ArtRnD/samguk_game/bg/battlefield_01.png", "md5": "<파일 md5>", # kind == "poses" "root": "C:/ArtRnD/samguk_game/poses", # //.png "arts": ["dz_infantry", "dz_spear", …], "clips": ["front_idle", "front_ready", "front_action"], # back_* 가 있으면 함께 "md5_list": "C:/ArtRnD/samguk_game/poses/md5.txt" # 선택 · 기준 상대 경로 · md5sum 형식 # kind == "worldmap" (챕터 월드맵 기획 확정판 · 챕터맵 보강 세션 · 2026-11-05) "root": "C:/ArtRnD/samguk_chapmap/plan", # 선택(없으면 이 기본값) · maps[].map·anchors 의 기준 "maps": [{"chapter": 3, "id": "ch03", "ko": "군웅할거", "nodes": 15, "map": "maps/ch03.png", "map_md5": "", "anchors": "anchors/ch03.json", "anchors_md5": ""}] } # kind == "ui" (UI 리뉴얼 B 신규 제작 · 세션 tks-ui-art · PD #1100 · 2026-11-06) "root": "C:/ArtRnD/samguk_ui/", # files 의 기준 "files": ["cur_gold.png", …] 또는 [{"file": "cur_gold.png", "md5": ""}, …], # 파일 이름(확장자 앞) = 테마 id "md5_list": "C:/ArtRnD/samguk_ui//md5.txt" # 선택 · 기준 상대 경로 · md5sum 형식 } ui = / → Assets/UI/Samguk//.png (group = batch 의 「ui_」 뒤 · 없으면 root 폴더 이름) · 기믹 그림(id 가 gim_ 로 시작) → Assets/Resources/Gimmicks/Art/.png(GimmickView 가 읽는 자리) · 치환 기록 = Assets/UI/Samguk/ui_delivered.csv(id · group · 경로 · md5 · batch) · 테마 치환 테이블(Resources/UI/UITheme.asset)은 Unity 가 Samguk PNG 임포트 때 자동 갱신(Editor/UIThemeImporter · 수동 = 메뉴 TKS/Import UI Theme) — 화면 코드 변경 없음. worldmap = 맵 PNG → Assets/Resources/ChapterMap/Maps/.png · 좌표 json → Data/.json(게임 노드 = stages[].at) · Data/world.json 의 같은 id 항목(길 roadX·roadY · 장소)을 좌표 json 으로 교체. 게임 챕터 → 맵 연결 = Assets/Data/Stages/chapters.csv map 열. 같은 맵 id 를 더 늦게 다시 납품한 json(_r2 등)이 미반영으로 있으면 앞의 것은 반영하지 않고 .superseded.json 으로 바꾼다. 실행: -I -B -X utf8 Tools/apply_delivery.py list -I -B -X utf8 Tools/apply_delivery.py apply (반영 뒤 .applied.json 으로 이름 바꿈) -I -B -X utf8 Tools/apply_delivery.py apply --all (미반영 전부) -I -B -X utf8 Tools/apply_delivery.py apply --all --kind worldmap (그 kind 만) 반영 뒤 Unity: 메뉴 재임포트 → 비동기 테스트 → 커밋 (끝에 출력). """ import argparse import hashlib import json import os import shutil import subprocess import sys PROJECT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) INBOX = r"C:\ArtRnD\samguk_game\deliveries" EXPORTER = r"C:\Ai\NerdNavisAi\.claude\skills\skeletal-anim-pipeline\files\scripts\rig_frames_export.py" UNITS = os.path.join(PROJECT, "Assets", "Resources", "Units") BOARD = os.path.join(PROJECT, "Assets", "Resources", "Board", "battlefield_01.png") CHAPMAP = os.path.join(PROJECT, "Assets", "Resources", "ChapterMap") WORLDMAP_ROOT = r"C:\ArtRnD\samguk_chapmap\plan" SAMGUK_UI = os.path.join(PROJECT, "Assets", "UI", "Samguk") GIMMICK_ART = os.path.join(PROJECT, "Assets", "Resources", "Gimmicks", "Art") THEME_CSV = os.path.join(PROJECT, "Assets", "Data", "UI", "ui_theme.csv") def md5_of(path): h = hashlib.md5() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1 << 20), b""): h.update(chunk) return h.hexdigest() def pending(): os.makedirs(INBOX, exist_ok=True) return sorted(f for f in os.listdir(INBOX) if f.endswith(".json") and not f.endswith(".applied.json") and not f.endswith(".superseded.json")) def superseded(files): """worldmap 납품 중 같은 맵 id 를 모두 더 늦은 미반영 납품이 덮는 것(파일명 = 시각순).""" maps = {} for f in files: d = json.load(open(os.path.join(INBOX, f), encoding="utf-8")) if d.get("kind") == "worldmap": maps[f] = {m["id"] for m in d.get("maps", [])} out = set() for f, ids in maps.items(): later = set().union(*[v for g, v in maps.items() if g > f] or [set()]) if ids and ids <= later: out.add(f) return out def check_md5_list(root, md5_list): """md5sum 형식( <상대경로>) 목록 검증 → (ok, bad).""" ok, bad = 0, [] for line in open(md5_list, encoding="utf-8"): line = line.strip() if not line: continue digest, rel = line.split(None, 1) rel = rel.lstrip("*").replace("/", os.sep) p = os.path.join(root, rel) if not os.path.exists(p) or md5_of(p) != digest.lower(): bad.append(rel) else: ok += 1 return ok, bad def apply_bg(d): src = d["file"] if d.get("md5") and md5_of(src) != d["md5"].lower(): sys.exit("배경 md5 불일치: " + src) os.makedirs(os.path.dirname(BOARD), exist_ok=True) shutil.copyfile(src, BOARD) print("bg ->", BOARD, "md5", md5_of(BOARD)) def apply_poses(d): root = d["root"] if d.get("md5_list"): ok, bad = check_md5_list(root, d["md5_list"]) if bad: sys.exit("md5 불일치 %d: %s" % (len(bad), ", ".join(bad[:5]))) print("md5 OK", ok) clips = d.get("clips") or ["front_idle", "front_ready", "front_action"] for art in d["arts"]: src = os.path.join(root, art) have = [c for c in clips if os.path.exists(os.path.join(src, c + ".png"))] if not have: sys.exit("포즈 없음: " + src) out = os.path.join(UNITS, art) if os.path.isdir(out): shutil.rmtree(out) # 구판(리그 프레임·정지 그림) 제거 — git 에 있음 r = subprocess.run([sys.executable, "-I", "-B", "-X", "utf8", EXPORTER, "poseset", src, "--out", out, "--clips", ",".join(have)], capture_output=True, text=True, encoding="utf-8") if r.returncode != 0: sys.exit("poseset 실패 " + art + "\n" + r.stdout + r.stderr) print(r.stdout.strip().splitlines()[-1]) def apply_worldmap(d): root = d.get("root") or WORLDMAP_ROOT world_path = os.path.join(CHAPMAP, "Data", "world.json") world = json.load(open(world_path, encoding="utf-8")) spec = os.path.join(root, "spec", "chapters.json") lm_scale = json.load(open(spec, encoding="utf-8")).get("lm_scale", 1.0) if os.path.exists(spec) else 1.0 for m in d["maps"]: mid = m["id"] pairs = [(m["map"], m.get("map_md5"), os.path.join(CHAPMAP, "Maps", mid + ".png")), (m["anchors"], m.get("anchors_md5"), os.path.join(CHAPMAP, "Data", mid + ".json"))] for rel, digest, dst in pairs: src = os.path.join(root, rel.replace("/", os.sep)) if digest and md5_of(src) != digest.lower(): sys.exit("worldmap md5 불일치: " + src) for rel, digest, dst in pairs: shutil.copyfile(os.path.join(root, rel.replace("/", os.sep)), dst) if digest and md5_of(dst) != digest.lower(): sys.exit("복사 뒤 md5 불일치: " + dst) an = json.load(open(pairs[1][2], encoding="utf-8")) # 장소 이름판 자리 = 장소 아래(chap_install.py 와 같은 식) · showLabels 가 꺼져 있으면 쓰지 않음 lms = [{"ko": L["ko"], "x": L["at"][0], "y": round(L["at"][1] + 120 * L.get("s", 1.0) * lm_scale, 1), "role": L.get("role", "")} for L in an.get("landmarks", []) if L.get("ko")] entry = {"id": mid, "no": m.get("chapter", an.get("no", 0)), "ko": m.get("ko", an.get("ko", "")), "years": "", "map": mid, "roadX": [p[0] for p in an["road"]], "roadY": [p[1] for p in an["road"]], "landmarks": lms} chapters = [c for c in world["chapters"] if c["id"] != mid] + [entry] world["chapters"] = sorted(chapters, key=lambda c: (c["no"], c["id"])) print("worldmap", mid, "->", pairs[0][2], "· 노드", len(an.get("stages", [])), "· md5 OK") world["_doc"] = ("게임이 읽는 챕터 맵 데이터 · 맵별 길·장소 = Data/.json(좌표 json) 에서 옮김 · 기획 확정판 맵은 " "Tools/apply_delivery.py worldmap 이 갱신(원화 세션 가안 chap_install.py 를 다시 돌리면 가안으로 덮이니 쓰지 않는다) · " "좌표 = 맵 px(왼쪽 위 원점 · y 아래로) · nodes·stageLink 는 게임이 읽지 않음(노드 = 스테이지 표 + Data/.json stages[].at)") open(world_path, "w", encoding="utf-8").write(json.dumps(world, ensure_ascii=False, indent=1)) def apply_ui(d): root = d["root"] if d.get("md5_list"): ok, bad = check_md5_list(root, d["md5_list"]) if bad: sys.exit("md5 불일치 %d: %s" % (len(bad), ", ".join(bad[:5]))) print("md5 OK", ok) batch = str(d.get("batch", "")) group = batch[3:] if batch.startswith("ui_") else os.path.basename(os.path.normpath(root)) known = set() if os.path.exists(THEME_CSV): for line in open(THEME_CSV, encoding="utf-8-sig"): f = line.split(",") if len(f) > 1 and f[0] in ("skin", "icon"): known.add(f[1]) log_path = os.path.join(SAMGUK_UI, "ui_delivered.csv") rows = {} if os.path.exists(log_path): for line in open(log_path, encoding="utf-8-sig").read().splitlines()[1:]: if line.strip(): rows[line.split(",")[0]] = line replaced, added = [], [] for item in d["files"]: rel, digest = (item, None) if isinstance(item, str) else (item["file"], item.get("md5")) src = os.path.join(root, rel.replace("/", os.sep)) if not rel.lower().endswith(".png"): sys.exit("PNG 가 아님: " + rel) if digest and md5_of(src) != digest.lower(): sys.exit("ui md5 불일치: " + src) uid = os.path.splitext(os.path.basename(rel))[0] dst = os.path.join(GIMMICK_ART, uid + ".png") if uid.startswith("gim_") else os.path.join(SAMGUK_UI, group, uid + ".png") os.makedirs(os.path.dirname(dst), exist_ok=True) shutil.copyfile(src, dst) m = md5_of(dst) if digest and m != digest.lower(): sys.exit("복사 뒤 md5 불일치: " + dst) (replaced if uid in known else added).append(uid) try: shown = os.path.relpath(dst, PROJECT) except ValueError: # 다른 드라이브(시험) shown = dst rows[uid] = ",".join([uid, group, shown.replace(os.sep, "/"), m, batch]) os.makedirs(SAMGUK_UI, exist_ok=True) with open(log_path, "w", encoding="utf-8-sig", newline="\n") as f: f.write("id,group,path,md5,batch\n") for k in sorted(rows): f.write(rows[k] + "\n") print("ui", group, "· 팩 치환", len(replaced), "· 새 id", len(added), (added[:8] if added else ""), "→", log_path) def cmd_list(a): files = pending() old = superseded(files) print("미반영 납품:", len(files)) for f in files: d = json.load(open(os.path.join(INBOX, f), encoding="utf-8")) print(" ", f, "|", d.get("kind"), d.get("batch"), "|", d.get("qa"), "|", d.get("session", ""), "| (뒤 납품으로 대체됨)" if f in old else "") def cmd_apply(a): files = pending() targets = files if a.all else [os.path.basename(a.json)] if a.kind: targets = [f for f in targets if json.load(open(os.path.join(INBOX, f), encoding="utf-8")).get("kind") == a.kind] if not targets: print("반영할 납품 없음") return old = superseded(files) for f in targets: p = os.path.join(INBOX, f) d = json.load(open(p, encoding="utf-8")) if f in old: print("대체됨(같은 맵의 뒤 납품이 있음) → .superseded.json:", f) os.replace(p, p[:-5] + ".superseded.json") continue # QA 문구 뒤에 설명이 붙은 경우(「PASS(…)」) 도 통과로 본다 if not str(d.get("qa", "PASS")).startswith("PASS"): print("건너뜀(QA 미통과):", f) continue print("==", f, d.get("kind"), d.get("batch")) if d["kind"] == "bg": apply_bg(d) elif d["kind"] == "poses": apply_poses(d) elif d["kind"] == "worldmap": apply_worldmap(d) elif d["kind"] == "ui": apply_ui(d) else: sys.exit("모르는 kind: " + str(d.get("kind"))) os.replace(p, p[:-5] + ".applied.json") print("\n다음(Unity · PD 에디터 Play 중이면 먼저 editor.playmode stop):") print(" unity command --project-path . editor invoke_menu --path 'TKS/Reimport Unit Art (유닛 아트 재임포트)' --result-only (Units·Board·ChapterMap)") print(" unity command --project-path . editor invoke_menu --path 'TKS/Import UI Theme (UI 테마 임포트)' --result-only (ui · Samguk PNG 임포트 때 자동 · 확인용)") print(" unity command --project-path . test run --mode playmode --async-tests --timeout 600 --result-only ; unity command --project-path . test status --result-only") print(" git add -A && git commit (끝줄 Co-Authored-By: <실제 모델> )") if __name__ == "__main__": p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sp = p.add_subparsers(dest="cmd", required=True) l = sp.add_parser("list") l.set_defaults(fn=cmd_list) ap = sp.add_parser("apply") ap.add_argument("json", nargs="?", default="") ap.add_argument("--all", action="store_true") ap.add_argument("--kind", default="", help="이 kind 만(bg · poses · worldmap · ui)") ap.set_defaults(fn=cmd_apply) a = p.parse_args() if a.cmd == "apply" and not a.all and not a.json: sys.exit("json 경로 또는 --all") a.fn(a)