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:
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
*.spec
|
||||||
|
test_out.png
|
||||||
|
cli_test.png
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
106
README.md
106
README.md
@@ -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
25
build.bat
Normal 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
24
build.sh
Executable 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
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
Pillow>=10.0
|
||||||
|
requests>=2.31
|
||||||
6
run.py
Normal file
6
run.py
Normal 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
2
sephiria_inv/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
"""Sephiria inventory optimizer."""
|
||||||
|
__version__ = "0.1.0"
|
||||||
113
sephiria_inv/__main__.py
Normal file
113
sephiria_inv/__main__.py
Normal 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
257
sephiria_inv/gui.py
Normal 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
222
sephiria_inv/renderer.py
Normal 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
122
sephiria_inv/screenshot.py
Normal 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
914
sephiria_inv/slabs.py
Normal 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
137
sephiria_inv/solver.py
Normal 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)
|
||||||
Reference in New Issue
Block a user