From 3cb8140cfaa0ca67298b029979e6b65e52ace619 Mon Sep 17 00:00:00 2001 From: tkrmagid Date: Wed, 13 May 2026 22:12:49 +0900 Subject: [PATCH] Sephiria inventory optimizer v0.1.0 - Slab catalog and effect handlers ported from WhiteDog1004/sephiria - Hill-climbing solver maximizes effect sum on slab-occupied cells - PIL renderer outputs PNG with effects overlay; downloads + caches slab images from img.sephiria.wiki on demand - Tkinter GUI for picking slabs by tier; CLI also available - Screenshot recognizer (template matching, beta) - build.bat / build.sh for portable single-file builds via PyInstaller --- .gitignore | 10 + README.md | 106 +++++ build.bat | 25 + build.sh | 24 + requirements.txt | 2 + run.py | 6 + sephiria_inv/__init__.py | 2 + sephiria_inv/__main__.py | 113 +++++ sephiria_inv/gui.py | 257 +++++++++++ sephiria_inv/renderer.py | 222 +++++++++ sephiria_inv/screenshot.py | 122 +++++ sephiria_inv/slabs.py | 914 +++++++++++++++++++++++++++++++++++++ sephiria_inv/solver.py | 137 ++++++ 13 files changed, 1940 insertions(+) create mode 100644 .gitignore create mode 100644 build.bat create mode 100755 build.sh create mode 100644 requirements.txt create mode 100644 run.py create mode 100644 sephiria_inv/__init__.py create mode 100644 sephiria_inv/__main__.py create mode 100644 sephiria_inv/gui.py create mode 100644 sephiria_inv/renderer.py create mode 100644 sephiria_inv/screenshot.py create mode 100644 sephiria_inv/slabs.py create mode 100644 sephiria_inv/solver.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea76c80 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +.venv/ +build/ +dist/ +*.spec +test_out.png +cli_test.png +.idea/ +.vscode/ diff --git a/README.md b/README.md index 17f7cc6..1d45379 100644 --- a/README.md +++ b/README.md @@ -1 +1,107 @@ # sephiria_inv_program + +세피리아(Sephiria) 인벤토리 석판 배치 최적화 도구. + +내가 보유한 석판 목록을 넣으면, 인벤토리 격자에 어떻게 배치해야 효과 합이 +가장 높아지는지 계산하고 결과 이미지를 만들어 줍니다. + +## 기능 + +- 보유 석판 입력 → 효과 합이 최대가 되는 슬롯 배치 자동 계산 +- 결과를 PNG 이미지로 저장 (각 셀의 효과 수치를 함께 표시) +- 회전 가능한 석판은 회전까지 포함해 탐색 +- GUI(Tkinter) + CLI 둘 다 제공 +- 게임 스크린샷에서 보유 석판 자동 인식 (베타, 템플릿 매칭) +- 단일 파일 포터블 EXE 빌드 지원 (PyInstaller) + +## 데이터 출처 + +석판 카탈로그와 효과 식은 오픈소스 팬위키 +[WhiteDog1004/sephiria](https://github.com/WhiteDog1004/sephiria) 의 +`src/features/simulator/config/` 에 있는 정의를 그대로 포팅한 것이며, +석판 이미지는 같은 사이트가 사용하는 CDN(`https://img.sephiria.wiki`)에서 +필요할 때 받아 캐시합니다. 게임 내부 메모리에서 데이터를 직접 읽어오지는 +않습니다 (공개 API가 없음). + +## 빠른 사용 - GUI + +```bash +python -m sephiria_inv +``` + +좌측 카탈로그에서 보유한 석판 옆 `+` 버튼으로 개수를 올리고 +`최적 배치 계산`을 누르면 됩니다. 결과 이미지는 `이미지 저장…` 으로 +PNG 저장이 가능합니다. + +## CLI + +```bash +# 일반/고급/희귀/전설 석판 목록 보기 +python -m sephiria_inv --list + +# 직접 지정 (value:count 형식) +python -m sephiria_inv --cli \ + -s harvesting:2 -s binary_star -s thorn -s sheen -s base \ + --slots 24 --seed 7 -o layout.png + +# 스크린샷에서 인식 (베타) +python -m sephiria_inv --cli \ + --screenshot ./game.png --bbox 320,180,1024,720 \ + --slots 34 -o layout.png +``` + +`--bbox` 는 인벤토리 격자 영역의 픽셀 좌표(left,top,right,bottom)입니다. +정확도는 스크린샷 해상도/UI 스타일에 따라 다릅니다 — 잘못 인식되는 셀이 +있으면 GUI에서 보정해 주세요. + +## 포터블 EXE 빌드 + +Windows 게임 PC 에서: + +```bat +build.bat +``` + +Python 3.10 이상이 PATH 에 있어야 합니다. 결과물은 `dist\sephiria_inv.exe` +한 파일이며, 다른 의존성 없이 그대로 실행됩니다. + +리눅스 테스트용 단일 바이너리: + +```bash +./build.sh +``` + +## 점수 함수 + +기본 점수는 `Σ effects[slab_cell]` 입니다 — 즉 "내가 놓은 석판의 칸에 다른 +석판들이 만들어주는 효과 합". 게임에서 아티팩트가 그 칸에 있을 때 받는 +보너스와 동일한 양입니다. 어느 칸에 아티팩트를 놓을지 모르므로, 모든 +석판 칸을 잠재 아티팩트 위치로 가정합니다. + +`flag = "ignore"` 가 적용된 칸(예: 환대, 이음, 고양)은 합에서 제외합니다. + +## 솔버 + +순열 전체 탐색은 슬롯 수가 30 을 넘으면 불가능하므로, 다음과 같이 동작합니다: + +1. 슬롯에 석판을 랜덤 배치 +2. swap/move/rotate 세 가지 작은 변화 중 하나를 시도 +3. 점수가 오르면 채택, 아니면 되돌림 (1차 개선 힐 클라이밍) +4. 시간 한도 안에서 여러 번 재시작해 최고 해를 기록 + +기본 4초 정도면 슬롯 34 / 석판 20 규모는 충분히 수렴합니다. + +## 한계 + +- 게임 메모리 / 세이브 파일에서 직접 데이터 읽기는 지원하지 않음 +- 스크린샷 인식은 템플릿 매칭이라 흐릿하거나 압축이 강한 이미지에서는 + 오인식이 생길 수 있음 +- 아티팩트 효과(레벨 보너스, 조건부 효과)는 점수에 반영하지 않음 — 순수 + 석판 효과 합만 본다. 향후 v2 에 아티팩트 슬롯 지정 옵션을 넣을 예정 +- "석판 합치기"(같은 등급 석판 결합) 기능은 향후 작업 + +## 라이선스 + +석판 이름/효과식/이미지 자산의 저작권은 원작 게임 *Sephiria* 의 개발자에게 +있습니다. 본 도구는 팬 메이드 보조 도구이며, 데이터 사용 가이드라인은 +WhiteDog1004/sephiria 의 정책을 따릅니다. diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..0adaa04 --- /dev/null +++ b/build.bat @@ -0,0 +1,25 @@ +@echo off +REM Build a portable single-file Windows EXE. +REM Requires Python 3.10+ on PATH. +setlocal + +if not exist .venv ( + python -m venv .venv +) +call .venv\Scripts\activate.bat + +pip install --upgrade pip +pip install -r requirements.txt +pip install pyinstaller + +pyinstaller ^ + --onefile ^ + --noconsole ^ + --name sephiria_inv ^ + --collect-submodules sephiria_inv ^ + -p . ^ + run.py + +echo. +echo Build done. Output: dist\sephiria_inv.exe +endlocal diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..49594ef --- /dev/null +++ b/build.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Build a Linux single-file binary for local testing. +# For a Windows .exe run build.bat on Windows. +set -euo pipefail + +cd "$(dirname "$0")" + +python3 -m venv .venv 2>/dev/null || true +# shellcheck disable=SC1091 +source .venv/bin/activate || true + +python3 -m pip install --upgrade pip +python3 -m pip install -r requirements.txt +python3 -m pip install pyinstaller + +pyinstaller \ + --onefile \ + --name sephiria_inv \ + --collect-submodules sephiria_inv \ + -p . \ + run.py + +echo +echo "Build done. Output: dist/sephiria_inv" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..009d936 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Pillow>=10.0 +requests>=2.31 diff --git a/run.py b/run.py new file mode 100644 index 0000000..b102868 --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +"""PyInstaller entry shim.""" + +from sephiria_inv.__main__ import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sephiria_inv/__init__.py b/sephiria_inv/__init__.py new file mode 100644 index 0000000..810faf7 --- /dev/null +++ b/sephiria_inv/__init__.py @@ -0,0 +1,2 @@ +"""Sephiria inventory optimizer.""" +__version__ = "0.1.0" diff --git a/sephiria_inv/__main__.py b/sephiria_inv/__main__.py new file mode 100644 index 0000000..d581b4f --- /dev/null +++ b/sephiria_inv/__main__.py @@ -0,0 +1,113 @@ +"""Entry point: GUI by default, CLI when --cli is passed.""" + +from __future__ import annotations + +import argparse +import sys +from typing import List + +from .renderer import save_solution +from .slabs import SLABS, SLABS_BY_VALUE +from .solver import solve + + +def _cli(argv: List[str]) -> int: + p = argparse.ArgumentParser( + prog="sephiria_inv", + description="Optimize Sephiria slab placement and render the result.", + ) + p.add_argument( + "--cli", action="store_true", + help="Run in CLI mode (no GUI).", + ) + p.add_argument( + "-s", "--slab", action="append", default=[], + help="Slab value to include. Use value (e.g. 'harvesting') with optional ':N' " + "multiplier (e.g. 'harvesting:3'). Repeat as needed.", + ) + p.add_argument( + "--slots", type=int, default=34, help="Inventory slot count (18..60).", + ) + p.add_argument("--seed", type=int, default=None) + p.add_argument( + "--time-limit", type=float, default=4.0, + help="Solver time budget in seconds.", + ) + p.add_argument( + "-o", "--output", default="sephiria_layout.png", + help="Output PNG path.", + ) + p.add_argument( + "--list", action="store_true", + help="List known slab values and exit.", + ) + p.add_argument( + "--no-download", action="store_true", + help="Skip CDN download (text-only render).", + ) + p.add_argument( + "--screenshot", default=None, + help="Read slab list from a game screenshot (PNG/JPG).", + ) + p.add_argument( + "--bbox", default=None, + help="Required with --screenshot. Pixel bbox of the inventory grid as " + "'left,top,right,bottom'.", + ) + args = p.parse_args(argv) + + if args.list: + for s in SLABS: + print(f" {s.value:<16s} {s.ko_label:<6s} ({s.tier})") + return 0 + + basket: List[str] = [] + if args.screenshot: + if not args.bbox: + print("--screenshot 사용 시 --bbox 'l,t,r,b' 가 필요합니다.", file=sys.stderr) + return 2 + try: + l, t, r, b = (int(v) for v in args.bbox.split(",")) + except ValueError: + print("--bbox 형식: left,top,right,bottom (정수)", file=sys.stderr) + return 2 + from .screenshot import recognize, recognized_values + recs = recognize(args.screenshot, (l, t, r, b), slot_num=args.slots) + basket.extend(recognized_values(recs)) + print(f"인식된 석판: {len(basket)}개 from screenshot") + for raw in args.slab: + if ":" in raw: + v, n = raw.split(":", 1) + try: + count = int(n) + except ValueError: + print(f"잘못된 개수: {raw}", file=sys.stderr) + return 2 + else: + v, count = raw, 1 + if v not in SLABS_BY_VALUE: + print(f"알 수 없는 석판 value: {v} (use --list)", file=sys.stderr) + return 2 + basket.extend([v] * count) + + if not basket: + print("최소 하나 이상의 --slab 을 지정하세요.", file=sys.stderr) + return 2 + + sol = solve(basket, slot_num=args.slots, time_limit=args.time_limit, seed=args.seed) + save_solution(sol, args.output, download=not args.no_download) + print(f"score={sol.score} placed={len(sol.placements)} → {args.output}") + return 0 + + +def main(argv: List[str] | None = None) -> int: + argv = list(argv if argv is not None else sys.argv[1:]) + if "--cli" in argv or "--list" in argv or any(a.startswith("-s") or a.startswith("--slab") for a in argv): + return _cli(argv) + # Fall back to GUI + from .gui import main as gui_main + return gui_main() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sephiria_inv/gui.py b/sephiria_inv/gui.py new file mode 100644 index 0000000..1ab6a4f --- /dev/null +++ b/sephiria_inv/gui.py @@ -0,0 +1,257 @@ +"""Tkinter GUI for picking slabs and rendering the optimal layout. + +Layout (left-to-right): + 1. Tier-grouped slab catalog with [+] [−] count controls + 2. Current basket summary + 3. Slot count slider + Solve button + result preview + +The preview is rendered to a temp PNG via renderer and shown via PhotoImage. +""" + +from __future__ import annotations + +import os +import sys +import tempfile +import threading +import tkinter as tk +from tkinter import filedialog, messagebox, ttk +from typing import Dict, List + +from PIL import Image, ImageTk + +from .renderer import render_solution +from .slabs import SLABS, SLABS_BY_VALUE, TIER_LABEL, TIER_ORDER +from .solver import solve + + +class App(tk.Tk): + def __init__(self) -> None: + super().__init__() + self.title("Sephiria Inventory Optimizer") + self.geometry("1280x800") + self.minsize(1000, 600) + + # counts: value -> int + self.counts: Dict[str, int] = {s.value: 0 for s in SLABS} + self.count_labels: Dict[str, tk.Label] = {} + self.summary_var = tk.StringVar(value="선택된 석판: 0개") + self.score_var = tk.StringVar(value="score: -") + self.slot_var = tk.IntVar(value=34) + self.solving = False + self.preview_image: ImageTk.PhotoImage | None = None + self.last_solution = None + + self._build() + + # -------------------------------------------------------------- layout + def _build(self) -> None: + root = ttk.Frame(self, padding=8) + root.pack(fill="both", expand=True) + root.columnconfigure(0, weight=2) + root.columnconfigure(1, weight=3) + root.rowconfigure(0, weight=1) + + # Left: catalog + left = ttk.LabelFrame(root, text="석판 목록 (보유 개수)", padding=6) + left.grid(row=0, column=0, sticky="nsew", padx=(0, 6)) + self._build_catalog(left) + + # Right: controls + preview + right = ttk.Frame(root) + right.grid(row=0, column=1, sticky="nsew") + right.columnconfigure(0, weight=1) + right.rowconfigure(2, weight=1) + + ctl = ttk.LabelFrame(right, text="옵션", padding=8) + ctl.grid(row=0, column=0, sticky="ew") + ttk.Label(ctl, text="슬롯 수").grid(row=0, column=0, sticky="w") + slot_scale = ttk.Scale( + ctl, from_=18, to=60, orient="horizontal", + variable=self.slot_var, command=self._on_slot_change, + ) + slot_scale.grid(row=0, column=1, sticky="ew", padx=6) + ctl.columnconfigure(1, weight=1) + self.slot_label = ttk.Label(ctl, text="34") + self.slot_label.grid(row=0, column=2, sticky="w") + + ttk.Button(ctl, text="모두 비우기", command=self._clear).grid( + row=1, column=0, pady=(8, 0), sticky="w" + ) + self.solve_btn = ttk.Button(ctl, text="최적 배치 계산", command=self._solve) + self.solve_btn.grid(row=1, column=1, pady=(8, 0), sticky="ew", padx=6) + ttk.Button(ctl, text="이미지 저장…", command=self._save).grid( + row=1, column=2, pady=(8, 0), sticky="e" + ) + + summary = ttk.Frame(right) + summary.grid(row=1, column=0, sticky="ew", pady=(6, 0)) + ttk.Label(summary, textvariable=self.summary_var).pack(side="left") + ttk.Label(summary, textvariable=self.score_var).pack(side="right") + + preview_frame = ttk.LabelFrame(right, text="결과 미리보기", padding=4) + preview_frame.grid(row=2, column=0, sticky="nsew", pady=(6, 0)) + self.preview = ttk.Label( + preview_frame, anchor="center", + text="좌측에서 석판을 추가하고 '최적 배치 계산'을 눌러주세요.", + ) + self.preview.pack(fill="both", expand=True) + + def _build_catalog(self, parent: tk.Widget) -> None: + canvas = tk.Canvas(parent, highlightthickness=0) + scroll = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview) + inner = ttk.Frame(canvas) + inner.bind( + "", + lambda e: canvas.configure(scrollregion=canvas.bbox("all")), + ) + canvas.create_window((0, 0), window=inner, anchor="nw") + canvas.configure(yscrollcommand=scroll.set) + canvas.pack(side="left", fill="both", expand=True) + scroll.pack(side="right", fill="y") + + # mousewheel + def _wheel(e): + canvas.yview_scroll(int(-e.delta / 120), "units") + + canvas.bind_all("", _wheel) + canvas.bind_all("", lambda e: canvas.yview_scroll(-1, "units")) + canvas.bind_all("", lambda e: canvas.yview_scroll(1, "units")) + + # group by tier + by_tier: Dict[str, List] = {t: [] for t in TIER_ORDER} + for s in SLABS: + by_tier.setdefault(s.tier, []).append(s) + + row = 0 + for tier in sorted(by_tier, key=lambda t: TIER_ORDER.get(t, 99)): + slabs = by_tier[tier] + if not slabs: + continue + ttk.Label( + inner, + text=TIER_LABEL.get(tier, tier), + font=("TkDefaultFont", 10, "bold"), + ).grid(row=row, column=0, columnspan=4, sticky="w", pady=(8, 2)) + row += 1 + for slab in slabs: + ttk.Label(inner, text=slab.ko_label, width=8).grid( + row=row, column=0, sticky="w" + ) + ttk.Button( + inner, text="−", width=2, + command=lambda v=slab.value: self._change_count(v, -1), + ).grid(row=row, column=1) + lbl = ttk.Label(inner, text="0", width=3, anchor="center") + lbl.grid(row=row, column=2) + self.count_labels[slab.value] = lbl + ttk.Button( + inner, text="+", width=2, + command=lambda v=slab.value: self._change_count(v, 1), + ).grid(row=row, column=3) + row += 1 + + # -------------------------------------------------------------- handlers + def _change_count(self, value: str, delta: int) -> None: + n = max(0, self.counts.get(value, 0) + delta) + self.counts[value] = n + self.count_labels[value]["text"] = str(n) + self._update_summary() + + def _clear(self) -> None: + for v in self.counts: + self.counts[v] = 0 + self.count_labels[v]["text"] = "0" + self._update_summary() + + def _update_summary(self) -> None: + total = sum(self.counts.values()) + self.summary_var.set(f"선택된 석판: {total}개") + + def _on_slot_change(self, _evt) -> None: + v = int(round(self.slot_var.get())) + self.slot_var.set(v) + self.slot_label["text"] = str(v) + + def _expand_basket(self) -> List[str]: + basket: List[str] = [] + for v, n in self.counts.items(): + basket.extend([v] * n) + return basket + + def _solve(self) -> None: + if self.solving: + return + basket = self._expand_basket() + if not basket: + messagebox.showinfo("안내", "먼저 보유한 석판을 추가하세요.") + return + slot_num = int(round(self.slot_var.get())) + if len(basket) > slot_num: + if not messagebox.askyesno( + "확인", + f"슬롯({slot_num})보다 석판({len(basket)})이 많습니다.\n" + "초과분은 무시하고 계산할까요?", + ): + return + self.solving = True + self.solve_btn["state"] = "disabled" + self.score_var.set("score: 계산 중…") + threading.Thread( + target=self._solve_worker, args=(basket, slot_num), daemon=True + ).start() + + def _solve_worker(self, basket: List[str], slot_num: int) -> None: + try: + sol = solve(basket, slot_num=slot_num, time_limit=4.0) + img = render_solution(sol, download=True) + self.after(0, self._show_result, sol, img) + except Exception as e: + self.after(0, lambda: messagebox.showerror("오류", str(e))) + finally: + self.after(0, self._solve_done) + + def _solve_done(self) -> None: + self.solving = False + self.solve_btn["state"] = "normal" + + def _show_result(self, sol, img: Image.Image) -> None: + self.last_solution = sol + # fit preview width + w = max(self.preview.winfo_width(), 600) + scale = min(1.0, w / img.width) + if scale < 1.0: + img = img.resize((int(img.width * scale), int(img.height * scale))) + self.preview_image = ImageTk.PhotoImage(img) + self.preview.configure(image=self.preview_image, text="") + self.score_var.set(f"score: {sol.score}") + + def _save(self) -> None: + if self.last_solution is None: + messagebox.showinfo("안내", "먼저 계산을 실행하세요.") + return + path = filedialog.asksaveasfilename( + defaultextension=".png", + filetypes=[("PNG", "*.png")], + initialfile="sephiria_layout.png", + ) + if not path: + return + img = render_solution(self.last_solution, download=True) + img.save(path) + messagebox.showinfo("완료", f"저장됨: {path}") + + +def main() -> int: + try: + app = App() + except tk.TclError as e: + print(f"GUI를 띄울 수 없습니다 ({e}). CLI 모드를 시도하세요:", file=sys.stderr) + print(" python -m sephiria_inv --help", file=sys.stderr) + return 1 + app.mainloop() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/sephiria_inv/renderer.py b/sephiria_inv/renderer.py new file mode 100644 index 0000000..81e9669 --- /dev/null +++ b/sephiria_inv/renderer.py @@ -0,0 +1,222 @@ +"""Render a Solution to a PNG image. + +The renderer draws a grid mirroring the in-game inventory: each cell is a +fixed-size square; cells with slabs show the slab image (downloaded from the +CDN on first run and cached locally). Effect numbers from `compute_effects` +are overlaid on each cell. +""" + +from __future__ import annotations + +import os +from io import BytesIO +from typing import Dict, Optional + +from PIL import Image, ImageDraw, ImageFont + +from .slabs import ( + GRID_COLS, + Placement, + SLABS_BY_VALUE, + compute_effects, +) +from .solver import Solution + + +CACHE_DIR = os.path.join( + os.environ.get("LOCALAPPDATA") + or os.environ.get("XDG_CACHE_HOME") + or os.path.expanduser("~/.cache"), + "sephiria_inv", + "slabs", +) + +CDN_BASE = "https://img.sephiria.wiki" + +CELL = 96 # pixels per slot +PAD = 12 +HEADER = 56 +FOOTER = 40 + +BG_COLOR = (29, 16, 27) +CELL_BG = (50, 30, 50) +CELL_BORDER = (88, 60, 90) +POS_COLOR = (132, 220, 132) +NEG_COLOR = (240, 110, 110) +ZERO_COLOR = (160, 160, 160) +IGNORE_COLOR = (160, 160, 160) +TEXT_COLOR = (236, 226, 232) + + +# --------------------------------------------------------------------------- +# Asset cache +# --------------------------------------------------------------------------- + + +def _ensure_cache_dir() -> None: + os.makedirs(CACHE_DIR, exist_ok=True) + + +def _local_path(slab_image: str) -> str: + # slab.image is like "slabs/foo.png"; flatten to filename + return os.path.join(CACHE_DIR, os.path.basename(slab_image)) + + +def fetch_slab_image(slab_image: str, timeout: float = 10.0) -> Optional[Image.Image]: + """Return a PIL Image for the slab, downloading + caching if needed. + + Returns None if download fails — caller draws a placeholder. + """ + _ensure_cache_dir() + path = _local_path(slab_image) + if os.path.exists(path): + try: + return Image.open(path).convert("RGBA") + except Exception: + pass + try: + import requests # lazy import; allow renderer use without network if cached + + url = f"{CDN_BASE}/{slab_image.lstrip('/')}" + r = requests.get(url, timeout=timeout) + if r.status_code != 200: + return None + with open(path, "wb") as f: + f.write(r.content) + return Image.open(BytesIO(r.content)).convert("RGBA") + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Font +# --------------------------------------------------------------------------- + + +def _load_font(size: int) -> ImageFont.ImageFont: + candidates = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + "C:\\Windows\\Fonts\\malgun.ttf", + "C:\\Windows\\Fonts\\arial.ttf", + "/System/Library/Fonts/Helvetica.ttc", + ] + for c in candidates: + if os.path.exists(c): + try: + return ImageFont.truetype(c, size) + except Exception: + continue + return ImageFont.load_default() + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def render_solution( + sol: Solution, + *, + download: bool = True, + title: Optional[str] = None, +) -> Image.Image: + """Render the solution to a PIL Image.""" + grid = sol.grid + rows = len(grid) + width = PAD * 2 + GRID_COLS * CELL + height = PAD * 2 + HEADER + rows * CELL + FOOTER + + img = Image.new("RGB", (width, height), BG_COLOR) + draw = ImageDraw.Draw(img) + + title_font = _load_font(22) + cell_font = _load_font(20) + eff_font = _load_font(28) + foot_font = _load_font(16) + + # Title + txt = title or "Sephiria Inventory Optimizer" + draw.text((PAD, PAD), txt, fill=TEXT_COLOR, font=title_font) + draw.text( + (PAD, PAD + 26), + f"slots {sol.slot_num} · placed {len(sol.placements)} · score {sol.score}", + fill=(180, 180, 180), + font=foot_font, + ) + + # Effects map + effects, flags = compute_effects(sol.placements, grid) + by_slot: Dict[str, Placement] = {p.slot_id: p for p in sol.placements} + + grid_top = PAD + HEADER + for row_cfg in grid: + y = row_cfg["rows"] + for x in range(row_cfg["cols"]): + cx0 = PAD + x * CELL + cy0 = grid_top + y * CELL + cx1 = cx0 + CELL - 4 + cy1 = cy0 + CELL - 4 + # cell background + draw.rounded_rectangle( + (cx0, cy0, cx1, cy1), radius=8, fill=CELL_BG, outline=CELL_BORDER + ) + + slot_id = f"{y}-{x}" + p = by_slot.get(slot_id) + if p is not None: + slab = SLABS_BY_VALUE.get(p.value) + placed = False + if slab is not None and download: + img_s = fetch_slab_image(slab.image) + if img_s is not None: + img_s = img_s.resize((CELL - 16, CELL - 16)) + if p.rotation: + # PIL rotates counter-clockwise; sephiria rotation + # is 90°-CW per step. + img_s = img_s.rotate(-90 * p.rotation, expand=False) + img.paste(img_s, (cx0 + 6, cy0 + 6), img_s) + placed = True + if not placed and slab is not None: + # text fallback + draw.text( + (cx0 + 6, cy0 + 6), + slab.ko_label, + fill=TEXT_COLOR, + font=cell_font, + ) + + # effect overlay (top-right) + v = effects.get(slot_id, 0) + if flags.get(slot_id) == "ignore": + label = "—" + color = IGNORE_COLOR + else: + label = f"{v:+d}" if v else "0" + color = ( + POS_COLOR if v > 0 else NEG_COLOR if v < 0 else ZERO_COLOR + ) + tw = draw.textlength(label, font=eff_font) + draw.text( + (cx1 - tw - 6, cy0 + 4), + label, + fill=color, + font=eff_font, + ) + + # Footer + draw.text( + (PAD, height - FOOTER + 8), + "Generated by sephiria_inv_program · data from img.sephiria.wiki", + fill=(120, 120, 120), + font=foot_font, + ) + + return img + + +def save_solution(sol: Solution, out_path: str, **kwargs) -> str: + img = render_solution(sol, **kwargs) + img.save(out_path) + return out_path diff --git a/sephiria_inv/screenshot.py b/sephiria_inv/screenshot.py new file mode 100644 index 0000000..99f2126 --- /dev/null +++ b/sephiria_inv/screenshot.py @@ -0,0 +1,122 @@ +"""Recognize slabs from a screenshot of the in-game inventory. + +Approach: template matching against the cached CDN images. Given a screenshot +and the inventory bounding box, we divide it into a grid and compare each cell +against every slab template (resized to the cell). Mean absolute error in RGB +picks the best match; cells above a threshold are treated as empty. + +This is a best-effort fallback. Accuracy depends heavily on the screenshot +resolution and the slab images matching the in-game render style. The CDN +images are the same pixel-art assets the game uses, so accuracy is usually +fine when the screenshot is sharp. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import List, Optional, Tuple + +from PIL import Image + +from .renderer import fetch_slab_image +from .slabs import GRID_COLS, SLABS, generate_grid_config + + +@dataclass +class Recognition: + slot_id: str + value: Optional[str] # None = empty + score: float # lower = better match + + +def _mae(a: Image.Image, b: Image.Image) -> float: + """Mean absolute error in RGB. Both images must be the same size.""" + if a.size != b.size: + b = b.resize(a.size) + a_rgb = a.convert("RGB") + b_rgb = b.convert("RGB") + pa = list(a_rgb.getdata()) + pb = list(b_rgb.getdata()) + n = len(pa) + if n == 0: + return 1e9 + total = 0 + for (ar, ag, ab), (br, bg, bb) in zip(pa, pb): + total += abs(ar - br) + abs(ag - bg) + abs(ab - bb) + return total / (n * 3) + + +def _alpha_composite_on_dark(img: Image.Image) -> Image.Image: + """Slab templates are RGBA on transparent. Composite onto dark BG for fairer compare.""" + if img.mode != "RGBA": + return img.convert("RGB") + bg = Image.new("RGBA", img.size, (50, 30, 50, 255)) + bg.alpha_composite(img) + return bg.convert("RGB") + + +def recognize( + screenshot_path: str, + bbox: Tuple[int, int, int, int], + slot_num: int = 34, + empty_threshold: float = 35.0, +) -> List[Recognition]: + """Recognize slabs in the inventory area of a screenshot. + + Args: + screenshot_path: Path to the game screenshot (PNG/JPG). + bbox: (left, top, right, bottom) pixel coords of the inventory grid. + Must enclose only the slot grid, not the surrounding UI. + slot_num: Total slot count (18..60). Used to compute row layout. + empty_threshold: MAE above this counts as empty. + + Returns: + List of Recognition entries, one per slot in row-major order. + """ + img = Image.open(screenshot_path).convert("RGB") + left, top, right, bottom = bbox + img = img.crop((left, top, right, bottom)) + + grid = generate_grid_config(slot_num) + if not grid: + return [] + rows = len(grid) + cell_w = (right - left) // GRID_COLS + cell_h = (bottom - top) // rows + template_size = (min(cell_w, cell_h), min(cell_w, cell_h)) + + # Pre-load and downscale templates + templates: List[Tuple[str, Image.Image]] = [] + for slab in SLABS: + t = fetch_slab_image(slab.image) + if t is None: + continue + t = _alpha_composite_on_dark(t).resize(template_size) + templates.append((slab.value, t)) + + results: List[Recognition] = [] + for row in grid: + y = row["rows"] + for x in range(row["cols"]): + cx0 = x * cell_w + cy0 = y * cell_h + cell = img.crop((cx0, cy0, cx0 + cell_w, cy0 + cell_h)).resize(template_size) + best_value: Optional[str] = None + best_score = 1e9 + for v, t in templates: + s = _mae(cell, t) + if s < best_score: + best_score = s + best_value = v + if best_score > empty_threshold: + results.append(Recognition(f"{y}-{x}", None, best_score)) + else: + results.append(Recognition(f"{y}-{x}", best_value, best_score)) + + return results + + +def recognized_values(recognitions: List[Recognition]) -> List[str]: + """Helper: extract just the non-empty slab values.""" + return [r.value for r in recognitions if r.value is not None] diff --git a/sephiria_inv/slabs.py b/sephiria_inv/slabs.py new file mode 100644 index 0000000..5886875 --- /dev/null +++ b/sephiria_inv/slabs.py @@ -0,0 +1,914 @@ +"""Slab catalog and effect handlers. + +Ported from WhiteDog1004/sephiria + src/features/simulator/config/slabsLists.ts + src/features/simulator/config/getSlabsEffect.ts + src/features/simulator/lib/calculateEffects.ts +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Dict, List, Optional, Tuple + + +GridConfig = List[Dict[str, int]] # [{"rows": int, "cols": int}, ...] +Effects = Dict[str, int] +Flags = Dict[str, Optional[str]] + + +# --------------------------------------------------------------------------- +# Catalog +# --------------------------------------------------------------------------- + + +@dataclass +class Slab: + value: str + tier: str + ko_label: str + eng_label: str + image: str # CDN path, e.g. "slabs/chivalry.png" + rotate: bool = False + + @property + def image_url(self) -> str: + return f"https://img.sephiria.wiki/{self.image.lstrip('/')}" + + +# tier -> ordering weight (for display) +TIER_ORDER = {"common": 0, "advanced": 1, "rare": 2, "legend": 3, "solid": 4} +TIER_LABEL = { + "common": "일반", + "advanced": "고급", + "rare": "희귀", + "legend": "전설", + "solid": "결속", +} + + +# All slabs from slabsLists.ts. image path is stripped of leading "/". +SLABS: List[Slab] = [ + # COMMON + Slab("chivalry", "common", "기사도", "chivalry", "slabs/chivalry.png", True), + Slab("dry", "common", "건조", "dry", "slabs/dry.png"), + Slab("approximation", "common", "근사", "approximation", "slabs/approximation.png", True), + Slab("advent", "common", "도래", "advent", "slabs/advent.png", True), + Slab("linear", "common", "선의", "linear", "slabs/linear.png"), + Slab("sight", "common", "시선", "sight", "slabs/sight.png", True), + Slab("handshake", "common", "악수", "handshake", "slabs/handshake.png", True), + Slab("fate", "common", "운명", "fate", "slabs/fate.webp"), + Slab("wit", "common", "재치", "wit", "slabs/wit.png", True), + Slab("exploitation", "common", "착취", "exploitation", "slabs/exploitation.png", True), + Slab("unity", "common", "화합", "unity", "slabs/unity.png", True), + Slab("cheer", "common", "환호", "cheer", "slabs/cheer.webp"), + Slab("hope", "common", "희망", "hope", "slabs/hope.png", True), + # ADVANCED + Slab("compete", "advanced", "경쟁", "compete", "slabs/compete.png", True), + Slab("beating", "advanced", "고동", "beating", "slabs/beating.png", True), + Slab("home_town", "advanced", "고양", "home_town", "slabs/home-town.png", True), + Slab("past", "advanced", "과거", "past", "slabs/past.png", True), + Slab("future", "advanced", "미래", "future", "slabs/future.png", True), + Slab("distribution", "advanced", "분배", "distribution", "slabs/distribution.png"), + Slab("triceps", "advanced", "삼두", "triceps", "slabs/triceps.png"), + Slab("harvesting", "advanced", "수확", "harvesting", "slabs/harvesting.png", True), + Slab("binary_star", "advanced", "쌍성", "binary_star", "slabs/binary_star.png", True), + Slab("nurture", "advanced", "양육", "nurture", "slabs/nurture.png", True), + Slab("yearning", "advanced", "열망", "yearning", "slabs/yearning.png"), + Slab("agglutination", "advanced", "응집", "agglutination", "slabs/agglutination.png", True), + Slab("entrance", "advanced", "입구", "entrance", "slabs/entrance.png"), + Slab("joke", "advanced", "장난", "joke", "slabs/joke.png", True), + Slab("load", "advanced", "적재", "load", "slabs/load.png", True), + Slab("transition", "advanced", "전이", "transition", "slabs/transition.png", True), + Slab("advance", "advanced", "전진", "advance", "slabs/advance.png", True), + Slab("justice", "advanced", "정의", "justice", "slabs/justice.png"), + Slab("preparation", "advanced", "준비", "preparation", "slabs/preparation.png", True), + Slab("exit", "advanced", "출구", "exit", "slabs/exit.png"), + Slab("tide", "advanced", "파도", "tide", "slabs/tide.png", True), + Slab("dedication", "advanced", "헌정", "dedication", "slabs/dedication.png"), + Slab("honor", "advanced", "명예", "honor", "slabs/honor.png", True), + # RARE + Slab("base", "rare", "기반", "base", "slabs/base.png"), + Slab("warrant", "rare", "권능", "warrant", "slabs/warrant.png", True), + Slab("disconnection", "rare", "단절", "disconnection", "slabs/disconnection.png"), + Slab("concurrency", "rare", "동시성", "concurrency", "slabs/concurrency.png"), + Slab("vow", "rare", "맹세", "vow", "slabs/vow.png", True), + Slab("rebellion", "rare", "반항", "rebellion", "slabs/rebellion.png", True), + Slab("connection", "rare", "이음", "connection", "slabs/connection.png", True), + Slab("shade", "rare", "차양", "shade", "slabs/shade.png"), + # LEGEND + Slab("thorn", "legend", "가시", "thorn", "slabs/thorn.png"), + Slab("boundary", "legend", "경계", "boundary", "slabs/boundary.png"), + Slab("sheen", "legend", "광휘", "sheen", "slabs/sheen.png", True), + Slab("miracle", "legend", "기적", "miracle", "slabs/miracle.png"), + Slab("daydream", "legend", "백일몽", "daydream", "slabs/daydream.png", True), + Slab("compression", "legend", "압축", "compression", "slabs/compression.png", True), + Slab("certitude", "legend", "확신", "certitude", "slabs/certitude.png", True), + Slab("hospitality", "legend", "환대", "hospitality", "slabs/hospitality.png"), + Slab("courage", "legend", "용기", "courage", "slabs/courage.png", True), + Slab("peace", "legend", "평화", "peace", "slabs/peace.png", True), +] + +SLABS_BY_VALUE: Dict[str, Slab] = {s.value: s for s in SLABS} + + +# --------------------------------------------------------------------------- +# Grid helpers +# --------------------------------------------------------------------------- + +GRID_COLS = 6 + + +def generate_grid_config(total_slots: int = 34) -> GridConfig: + """Mirror of generateGridConfig() in SlotComponent.tsx.""" + if total_slots <= 0: + return [] + config: GridConfig = [] + full_rows = total_slots // GRID_COLS + last_row_cols = total_slots % GRID_COLS + for i in range(full_rows): + config.append({"rows": i, "cols": GRID_COLS}) + if last_row_cols > 0: + config.append({"rows": full_rows, "cols": last_row_cols}) + return config + + +def all_slot_ids(grid: GridConfig) -> List[str]: + return [f"{row['rows']}-{c}" for row in grid for c in range(row["cols"])] + + +def cols_in_row(grid: GridConfig, y: int) -> int: + for row in grid: + if row["rows"] == y: + return row["cols"] + return 0 + + +# --------------------------------------------------------------------------- +# Rotation helpers +# --------------------------------------------------------------------------- + + +def rotate_offset(dx: int, dy: int, rotation: int) -> Tuple[int, int]: + if rotation == 1: + return -dy, dx + if rotation == 2: + return -dx, -dy + if rotation == 3: + return dy, -dx + return dx, dy + + +def apply_offsets( + offsets: List[Dict], + x: int, + y: int, + effects: Effects, + rotation: int = 0, + flags: Optional[Flags] = None, +) -> None: + for o in offsets: + ndx, ndy = rotate_offset(o["dx"], o["dy"], rotation) + target = f"{y + ndy}-{x + ndx}" + if target in effects: + effects[target] += o.get("value", 1) + if flags is not None and o.get("flag") == "ignore" and target in flags: + flags[target] = "ignore" + + +# --------------------------------------------------------------------------- +# Effect handlers +# Signature: handler(x, y, slot_id, rotation, effects, flags, grid_config) +# --------------------------------------------------------------------------- + + +Handler = Callable[[int, int, str, int, Effects, Flags, GridConfig], None] + + +def _no_op(*_args, **_kwargs) -> None: + pass + + +# COMMON +def _approximation(x, y, _s, rot, e, _f, _g): + apply_offsets([{"dx": 0, "dy": -1}, {"dx": 1, "dy": 0}], x, y, e, rot) + + +def _dry(x, y, _s, _rot, e, _f, _g): + apply_offsets([{"dx": 0, "dy": -1}, {"dx": 0, "dy": 1}], x, y, e, 0) + + +def _chivalry(x, y, _s, rot, e, _f, _g): + apply_offsets([{"dx": -1, "dy": -2}], x, y, e, rot) + + +def _advent(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": 0, "dy": -1}, + {"dx": 0, "dy": -2}, + {"dx": 0, "dy": 1, "value": -1}, + {"dx": 0, "dy": 2, "value": -1}, + ], + x, + y, + e, + rot, + ) + + +def _linear(x, y, _s, _rot, e, _f, g): + if not g: + return + last_row_index = g[-1]["rows"] + if y == last_row_index: + for target in (f"{y}-{x - 1}", f"{y}-{x + 1}"): + if target in e: + e[target] += 1 + + +def _sight(x, y, _s, rot, e, _f, _g): + apply_offsets( + [{"dx": -1, "dy": -1}, {"dx": 1, "dy": 1, "value": -1}], x, y, e, rot + ) + + +def _handshake(x, y, _s, rot, e, _f, _g): + apply_offsets([{"dx": 0, "dy": -1}, {"dx": 0, "dy": 1}], x, y, e, rot) + + +def _fate(x, y, _s, _rot, e, _f, _g): + apply_offsets([{"dx": 0, "dy": 1}], x, y, e, 0) + + +def _wit(x, y, _s, rot, e, _f, _g): + apply_offsets([{"dx": -1, "dy": -1}], x, y, e, rot) + + +def _exploitation(x, y, _s, rot, e, _f, _g): + apply_offsets( + [{"dx": 0, "dy": -1}, {"dx": 0, "dy": 1, "value": -1}], x, y, e, rot + ) + + +def _unity(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": 1, "dy": 0}, + {"dx": 0, "dy": 1}, + {"dx": 0, "dy": -1, "value": -1}, + {"dx": -1, "dy": 0, "value": -1}, + ], + x, + y, + e, + rot, + ) + + +def _cheer(x, y, _s, _rot, e, _f, _g): + apply_offsets([{"dx": 0, "dy": -1}], x, y, e, 0) + + +def _hope(x, y, _s, rot, e, _f, _g): + apply_offsets([{"dx": 1, "dy": 0}], x, y, e, rot) + + +# ADVANCED +def _compete(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": 0, "dy": 1, "value": 2}, + {"dx": 0, "dy": -1, "value": -1}, + {"dx": -1, "dy": -1, "value": -1}, + ], + x, + y, + e, + rot, + ) + + +def _beating(x, y, _s, rot, e, _f, _g): + apply_offsets([{"dx": 0, "dy": -2, "value": 2}], x, y, e, rot) + + +def _home_town(x, y, _s, rot, _e, f, _g): + ndx, ndy = rotate_offset(1, 0, rot) + target = f"{y + ndy}-{x + ndx}" + if f is not None and target in f: + f[target] = "ignore" + + +def _past(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": -1, "dy": -1}, + {"dx": 0, "dy": -1}, + {"dx": 1, "dy": -1}, + {"dx": 1, "dy": 0}, + ], + x, + y, + e, + rot, + ) + + +def _future(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": -1, "dy": -1}, + {"dx": 0, "dy": -1}, + {"dx": 1, "dy": -1}, + {"dx": -1, "dy": 0}, + ], + x, + y, + e, + rot, + ) + + +def _distribution(x, y, _s, _rot, e, _f, _g): + apply_offsets( + [ + {"dx": 0, "dy": -1}, + {"dx": -1, "dy": 0}, + {"dx": 1, "dy": 0}, + {"dx": 0, "dy": 1}, + ], + x, + y, + e, + 0, + ) + + +def _triceps(x, y, _s, _rot, e, _f, _g): + apply_offsets( + [{"dx": 0, "dy": -1}, {"dx": -1, "dy": 0}, {"dx": 1, "dy": 0}], x, y, e, 0 + ) + + +def _harvesting(x, y, _s, rot, e, _f, _g): + apply_offsets( + [{"dx": 0, "dy": 1, "value": 2}, {"dx": 0, "dy": -1, "value": 2}], + x, + y, + e, + rot, + ) + + +def _binary_star(x, y, _s, rot, e, _f, _g): + apply_offsets( + [{"dx": 0, "dy": 2, "value": 2}, {"dx": 0, "dy": -2, "value": 2}], + x, + y, + e, + rot, + ) + + +def _nurture(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": -1, "dy": -1}, + {"dx": 0, "dy": -1}, + {"dx": 1, "dy": -1}, + {"dx": 0, "dy": 1, "value": -1}, + {"dx": 0, "dy": 2, "value": -1}, + ], + x, + y, + e, + rot, + ) + + +def _yearning(x, y, _s, rot, e, _f, _g): + apply_offsets([{"dx": 0, "dy": -1, "value": 2}], x, y, e, rot) + + +def _agglutination(x, y, slot, rot, e, _f, g): + if not g: + return + if rot in (1, 3): + for row in g: + if x < row["cols"]: + target = f"{row['rows']}-{x}" + if target != slot and target in e: + e[target] += -1 + else: + c = cols_in_row(g, y) + for i in range(c): + target = f"{y}-{i}" + if target != slot and target in e: + e[target] += -1 + apply_offsets([{"dx": 0, "dy": -1, "value": 3}], x, y, e, rot) + + +def _entrance(x, y, _s, _rot, e, _f, _g): + apply_offsets( + [ + {"dx": 0, "dy": -1, "value": 2}, + {"dx": -1, "dy": -1, "value": 1}, + {"dx": 1, "dy": -1, "value": 1}, + ], + x, + y, + e, + 0, + ) + + +def _joke(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": 0, "dy": -1}, + {"dx": 1, "dy": -1}, + {"dx": -1, "dy": -1}, + {"dx": -1, "dy": 0, "value": -1}, + {"dx": 1, "dy": 0, "value": -1}, + ], + x, + y, + e, + rot, + ) + + +def _load(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": 0, "dy": -1}, + {"dx": -1, "dy": -1}, + {"dx": 0, "dy": -2}, + {"dx": -1, "dy": -2}, + ], + x, + y, + e, + rot, + ) + + +def _transition(x, y, slot, rot, e, _f, g): + if not g: + return + if rot in (1, 3): + horizontal_value, vertical_value = -1, 1 + else: + horizontal_value, vertical_value = 1, -1 + c = cols_in_row(g, y) + for i in range(c): + target = f"{y}-{i}" + if target != slot and target in e: + e[target] += horizontal_value + for row in g: + if x < row["cols"]: + target = f"{row['rows']}-{x}" + if target != slot and target in e: + e[target] += vertical_value + + +def _advance(x, y, _s, rot, e, _f, _g): + apply_offsets( + [{"dx": 0, "dy": -1}, {"dx": 0, "dy": -2}, {"dx": 0, "dy": -3}], x, y, e, rot + ) + + +def _justice(x, y, slot, _rot, e, _f, g): + if not g: + return + c = cols_in_row(g, y) + is_edge = x == 0 or x == c - 1 + if not is_edge: + return + for row in g: + if x < row["cols"]: + target = f"{row['rows']}-{x}" + if target != slot and target in e: + e[target] += 1 + + +def _preparation(x, y, _s, rot, e, _f, _g): + apply_offsets( + [{"dx": -1, "dy": -1}, {"dx": 1, "dy": 1}], x, y, e, rot + ) + + +def _exit(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": -1, "dy": 1}, + {"dx": 0, "dy": 1, "value": 2}, + {"dx": 1, "dy": 1}, + ], + x, + y, + e, + rot, + ) + + +def _tide(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": 1, "dy": -1, "value": 2}, + {"dx": 0, "dy": -1, "value": -1}, + {"dx": 1, "dy": 0, "value": -1}, + ], + x, + y, + e, + rot, + ) + + +def _dedication(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": 1, "dy": -1}, + {"dx": -1, "dy": -1}, + {"dx": 1, "dy": 1}, + {"dx": -1, "dy": 1}, + ], + x, + y, + e, + rot, + ) + + +def _honor(x, y, _s, rot, e, _f, _g): + apply_offsets( + [{"dx": 0, "dy": -1, "value": 2}, {"dx": -1, "dy": -2}], x, y, e, rot + ) + + +# RARE +def _base(_x, y, slot, _rot, e, _f, g): + if not g: + return + c = cols_in_row(g, y) + for i in range(c): + target = f"{y}-{i}" + if target != slot and target in e: + e[target] += 1 + + +def _warrant(x, y, _s, rot, e, _f, _g): + apply_offsets([{"dx": 0, "dy": -1, "value": 3}], x, y, e, rot) + + +def _disconnection(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": 0, "dy": -1, "value": 3}, + {"dx": 0, "dy": 1, "value": 3}, + {"dx": 1, "dy": 0, "value": -1}, + {"dx": -1, "dy": 0, "value": -1}, + ], + x, + y, + e, + rot, + ) + + +def _concurrency(x, _y, slot, _rot, e, _f, g): + if not g: + return + for row in g: + if x < row["cols"]: + target = f"{row['rows']}-{x}" + if target != slot and target in e: + e[target] += 1 + + +def _vow(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": 0, "dy": -2, "value": 2}, + {"dx": 0, "dy": 1}, + {"dx": 0, "dy": -1}, + {"dx": -1, "dy": 0}, + {"dx": 1, "dy": 0}, + ], + x, + y, + e, + rot, + ) + + +def _rebellion(x, y, _s, rot, e, _f, _g): + cx = -1 if rot in (1, 3) else 1 + directions = [(cx, -1), (-cx, 1)] + for dx, dy in directions: + cur_x, cur_y = x, y + while True: + cur_x += dx + cur_y += dy + target = f"{cur_y}-{cur_x}" + if target in e: + e[target] += 1 + else: + break + + +def _connection(x, y, _s, rot, e, f, _g): + # value + ndx, ndy = rotate_offset(0, -1, rot) + target_v = f"{y + ndy}-{x + ndx}" + if target_v in e: + e[target_v] += 2 + # ignore + ndx, ndy = rotate_offset(0, 1, rot) + target_i = f"{y + ndy}-{x + ndx}" + if f is not None and target_i in f: + f[target_i] = "ignore" + + +def _shade(_x, y, _s, _rot, e, _f, g): + if not g: + return + if y != 0 or len(g) < 2: + return + last_row = g[-1] + second_to_last = g[-2] + last_row_index = last_row["rows"] + cols_last = last_row["cols"] + second_to_last_index = second_to_last["rows"] + cols_second = second_to_last["cols"] + for col_idx in range(cols_last): + target = f"{last_row_index}-{col_idx}" + if target in e: + e[target] += 1 + if cols_second > cols_last: + for col_idx in range(cols_last, cols_second): + target = f"{second_to_last_index}-{col_idx}" + if target in e: + e[target] += 1 + + +# LEGEND +def _thorn(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": 0, "dy": -1, "value": 2}, + {"dx": 0, "dy": 1, "value": 2}, + {"dx": -1, "dy": -1}, + {"dx": -1, "dy": 0}, + {"dx": -1, "dy": 1}, + {"dx": 1, "dy": -1}, + {"dx": 1, "dy": 0}, + {"dx": 1, "dy": 1}, + ], + x, + y, + e, + rot, + ) + + +def _boundary(_x, _y, _s, _rot, e, _f, g): + if not g: + return + + def apply_row(row): + for col_idx in range(row["cols"]): + target = f"{row['rows']}-{col_idx}" + if target in e: + e[target] += 1 + + apply_row(g[0]) + if len(g) > 1: + last = g[-1] + second = g[-2] + apply_row(last) + if second["cols"] > last["cols"]: + for col_idx in range(last["cols"], second["cols"]): + target = f"{second['rows']}-{col_idx}" + if target in e: + e[target] += 1 + + +def _sheen(x, y, slot, rot, e, _f, g): + if not g: + return + if rot in (1, 3): + for row in g: + if x < row["cols"]: + target = f"{row['rows']}-{x}" + if target != slot and target in e: + e[target] += 1 + else: + c = cols_in_row(g, y) + for i in range(c): + target = f"{y}-{i}" + if target != slot and target in e: + e[target] += 1 + apply_offsets( + [{"dx": 0, "dy": -1, "value": 2}, {"dx": 0, "dy": 1, "value": 2}], + x, + y, + e, + rot, + ) + + +def _miracle(x, y, slot, _rot, e, _f, g): + if not g: + return + for row in g: + if x < row["cols"]: + target = f"{row['rows']}-{x}" + if target != slot and target in e: + e[target] += 1 + c = cols_in_row(g, y) + for i in range(c): + target = f"{y}-{i}" + if target != slot and target in e: + e[target] += 1 + + +def _daydream(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": -1, "dy": -1}, + {"dx": -1, "dy": -2}, + {"dx": 1, "dy": -1}, + {"dx": 1, "dy": -2}, + {"dx": -1, "dy": 1}, + {"dx": -1, "dy": 2}, + {"dx": 1, "dy": 1}, + {"dx": 1, "dy": 2}, + ], + x, + y, + e, + rot, + ) + + +def _compression(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": 0, "dy": -1, "value": 3}, + {"dx": 0, "dy": -2, "value": 2}, + {"dx": 0, "dy": -3}, + ], + x, + y, + e, + rot, + ) + + +def _certitude(x, y, _s, rot, e, _f, _g): + apply_offsets([{"dx": 0, "dy": -1, "value": 5}], x, y, e, rot) + + +def _hospitality(x, y, _s, rot, e, f, _g): + apply_offsets( + [ + {"dx": 0, "dy": -1, "value": 1, "flag": "ignore"}, + {"dx": -1, "dy": 0, "value": 2, "flag": "ignore"}, + ], + x, + y, + e, + rot, + f, + ) + + +def _peace(x, y, _s, rot, e, _f, _g): + apply_offsets( + [{"dx": -1, "dy": 0, "value": 3}, {"dx": 1, "dy": 0, "value": 3}], x, y, e, rot + ) + + +def _courage(x, y, _s, rot, e, _f, _g): + apply_offsets( + [ + {"dx": -3, "dy": -3}, + {"dx": -2, "dy": -2}, + {"dx": -1, "dy": -1}, + {"dx": 1, "dy": 1}, + {"dx": 2, "dy": 2}, + {"dx": 1, "dy": -1, "value": 2}, + {"dx": -1, "dy": 1, "value": 2}, + ], + x, + y, + e, + rot, + ) + + +HANDLERS: Dict[str, Handler] = { + # COMMON + "approximation": _approximation, + "dry": _dry, + "chivalry": _chivalry, + "advent": _advent, + "linear": _linear, + "sight": _sight, + "handshake": _handshake, + "fate": _fate, + "wit": _wit, + "exploitation": _exploitation, + "unity": _unity, + "cheer": _cheer, + "hope": _hope, + # ADVANCED + "compete": _compete, + "beating": _beating, + "home_town": _home_town, + "past": _past, + "future": _future, + "distribution": _distribution, + "triceps": _triceps, + "harvesting": _harvesting, + "binary_star": _binary_star, + "nurture": _nurture, + "yearning": _yearning, + "agglutination": _agglutination, + "entrance": _entrance, + "joke": _joke, + "load": _load, + "transition": _transition, + "advance": _advance, + "justice": _justice, + "preparation": _preparation, + "exit": _exit, + "tide": _tide, + "dedication": _dedication, + "honor": _honor, + # RARE + "base": _base, + "warrant": _warrant, + "disconnection": _disconnection, + "concurrency": _concurrency, + "vow": _vow, + "rebellion": _rebellion, + "connection": _connection, + "shade": _shade, + # LEGEND + "thorn": _thorn, + "boundary": _boundary, + "sheen": _sheen, + "miracle": _miracle, + "daydream": _daydream, + "compression": _compression, + "certitude": _certitude, + "hospitality": _hospitality, + "peace": _peace, + "courage": _courage, +} + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +@dataclass +class Placement: + value: str # slab value e.g. "chivalry" + slot_id: str # e.g. "2-3" + rotation: int = 0 + + +def compute_effects( + placements: List[Placement], grid: GridConfig +) -> Tuple[Effects, Flags]: + """Apply all placements' handlers to the grid and return resulting effects + flags.""" + effects: Effects = {sid: 0 for sid in all_slot_ids(grid)} + flags: Flags = {sid: None for sid in all_slot_ids(grid)} + for p in placements: + y, x = (int(v) for v in p.slot_id.split("-")) + handler = HANDLERS.get(p.value) + if handler is None: + continue + handler(x, y, p.slot_id, p.rotation, effects, flags, grid) + return effects, flags + + +def score(placements: List[Placement], grid: GridConfig) -> int: + """Score = sum of effects on slab-occupied cells (ignoring "ignore" flagged cells). + + This represents the boost an artifact placed on each slab-cell would get. + Higher = better layout. Empty cells don't count. + """ + effects, flags = compute_effects(placements, grid) + total = 0 + for p in placements: + if flags.get(p.slot_id) == "ignore": + continue + total += effects.get(p.slot_id, 0) + return total diff --git a/sephiria_inv/solver.py b/sephiria_inv/solver.py new file mode 100644 index 0000000..655a4d6 --- /dev/null +++ b/sephiria_inv/solver.py @@ -0,0 +1,137 @@ +"""Hill-climbing solver for slab placement. + +We don't try to enumerate the full N! search space — for a 34-slot grid the +number of placements is enormous. Instead we use multi-start random + first- +improvement hill climbing with swap/rotate/move moves. This converges quickly +to good local optima and is plenty fast for typical inputs (≤ 60 slabs). +""" + +from __future__ import annotations + +import random +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +from .slabs import ( + GRID_COLS, + GridConfig, + Placement, + SLABS_BY_VALUE, + all_slot_ids, + generate_grid_config, + score, +) + + +@dataclass +class Solution: + placements: List[Placement] = field(default_factory=list) + grid: GridConfig = field(default_factory=list) + score: int = 0 + slot_num: int = 34 + + +def _initial( + slab_values: List[str], grid: GridConfig, rng: random.Random +) -> List[Placement]: + slots = all_slot_ids(grid) + rng.shuffle(slots) + placements: List[Placement] = [] + for v, sid in zip(slab_values, slots): + slab = SLABS_BY_VALUE.get(v) + rot = rng.randint(0, 3) if slab and slab.rotate else 0 + placements.append(Placement(value=v, slot_id=sid, rotation=rot)) + return placements + + +def _hill_climb( + placements: List[Placement], + grid: GridConfig, + rng: random.Random, + max_iters: int, + time_limit: float, +) -> Tuple[List[Placement], int]: + cur = [Placement(p.value, p.slot_id, p.rotation) for p in placements] + cur_score = score(cur, grid) + used = {p.slot_id for p in cur} + free = [sid for sid in all_slot_ids(grid) if sid not in used] + start = time.monotonic() + + for _ in range(max_iters): + if time.monotonic() - start > time_limit: + break + move = rng.random() + if move < 0.45 and len(cur) > 1: + # swap two placements + i, j = rng.sample(range(len(cur)), 2) + cur[i].slot_id, cur[j].slot_id = cur[j].slot_id, cur[i].slot_id + ns = score(cur, grid) + if ns > cur_score: + cur_score = ns + else: + cur[i].slot_id, cur[j].slot_id = cur[j].slot_id, cur[i].slot_id + elif move < 0.75 and free: + # move one placement to a free slot + i = rng.randint(0, len(cur) - 1) + old = cur[i].slot_id + new_idx = rng.randint(0, len(free) - 1) + new_slot = free[new_idx] + cur[i].slot_id = new_slot + ns = score(cur, grid) + if ns > cur_score: + cur_score = ns + free[new_idx] = old + else: + cur[i].slot_id = old + else: + # rotate one placement + i = rng.randint(0, len(cur) - 1) + slab = SLABS_BY_VALUE.get(cur[i].value) + if not slab or not slab.rotate: + continue + old_rot = cur[i].rotation + cur[i].rotation = (old_rot + rng.choice([1, 2, 3])) % 4 + ns = score(cur, grid) + if ns > cur_score: + cur_score = ns + else: + cur[i].rotation = old_rot + + return cur, cur_score + + +def solve( + slab_values: List[str], + slot_num: int = 34, + *, + restarts: int = 6, + iters_per_restart: int = 8000, + time_limit: float = 4.0, + seed: Optional[int] = None, +) -> Solution: + """Find a good placement for the given slabs. + + `slab_values` is a list of slab `value` strings (duplicates allowed). + Slabs that don't fit (more slabs than slots) are dropped from the tail. + """ + grid = generate_grid_config(slot_num) + capacity = sum(r["cols"] for r in grid) + if len(slab_values) > capacity: + slab_values = slab_values[:capacity] + if not slab_values: + return Solution([], grid, 0, slot_num) + + rng = random.Random(seed) + best: List[Placement] = [] + best_score = -10**9 + + per_restart_budget = time_limit / max(restarts, 1) + for _ in range(restarts): + init = _initial(slab_values, grid, rng) + sol, sc = _hill_climb(init, grid, rng, iters_per_restart, per_restart_budget) + if sc > best_score: + best_score = sc + best = [Placement(p.value, p.slot_id, p.rotation) for p in sol] + + return Solution(best, grid, best_score, slot_num)