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
This commit is contained in:
tkrmagid
2026-05-13 22:12:49 +09:00
parent 88f46eb146
commit 3cb8140cfa
13 changed files with 1940 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
__pycache__/
*.pyc
.venv/
build/
dist/
*.spec
test_out.png
cli_test.png
.idea/
.vscode/

106
README.md
View File

@@ -1 +1,107 @@
# sephiria_inv_program # 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 의 정책을 따릅니다.

25
build.bat Normal file
View File

@@ -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

24
build.sh Executable file
View File

@@ -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"

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
Pillow>=10.0
requests>=2.31

6
run.py Normal file
View File

@@ -0,0 +1,6 @@
"""PyInstaller entry shim."""
from sephiria_inv.__main__ import main
if __name__ == "__main__":
raise SystemExit(main())

2
sephiria_inv/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
"""Sephiria inventory optimizer."""
__version__ = "0.1.0"

113
sephiria_inv/__main__.py Normal file
View File

@@ -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())

257
sephiria_inv/gui.py Normal file
View File

@@ -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(
"<Configure>",
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("<MouseWheel>", _wheel)
canvas.bind_all("<Button-4>", lambda e: canvas.yview_scroll(-1, "units"))
canvas.bind_all("<Button-5>", 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())

222
sephiria_inv/renderer.py Normal file
View File

@@ -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

122
sephiria_inv/screenshot.py Normal file
View File

@@ -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]

914
sephiria_inv/slabs.py Normal file
View File

@@ -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

137
sephiria_inv/solver.py Normal file
View File

@@ -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)