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