Sephiria inventory optimizer v0.1.0

- Slab catalog and effect handlers ported from WhiteDog1004/sephiria
- Hill-climbing solver maximizes effect sum on slab-occupied cells
- PIL renderer outputs PNG with effects overlay; downloads + caches slab
  images from img.sephiria.wiki on demand
- Tkinter GUI for picking slabs by tier; CLI also available
- Screenshot recognizer (template matching, beta)
- build.bat / build.sh for portable single-file builds via PyInstaller
This commit is contained in:
tkrmagid
2026-05-13 22:12:49 +09:00
parent 88f46eb146
commit 3cb8140cfa
13 changed files with 1940 additions and 0 deletions

2
sephiria_inv/__init__.py Normal file
View File

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

113
sephiria_inv/__main__.py Normal file
View File

@@ -0,0 +1,113 @@
"""Entry point: GUI by default, CLI when --cli is passed."""
from __future__ import annotations
import argparse
import sys
from typing import List
from .renderer import save_solution
from .slabs import SLABS, SLABS_BY_VALUE
from .solver import solve
def _cli(argv: List[str]) -> int:
p = argparse.ArgumentParser(
prog="sephiria_inv",
description="Optimize Sephiria slab placement and render the result.",
)
p.add_argument(
"--cli", action="store_true",
help="Run in CLI mode (no GUI).",
)
p.add_argument(
"-s", "--slab", action="append", default=[],
help="Slab value to include. Use value (e.g. 'harvesting') with optional ':N' "
"multiplier (e.g. 'harvesting:3'). Repeat as needed.",
)
p.add_argument(
"--slots", type=int, default=34, help="Inventory slot count (18..60).",
)
p.add_argument("--seed", type=int, default=None)
p.add_argument(
"--time-limit", type=float, default=4.0,
help="Solver time budget in seconds.",
)
p.add_argument(
"-o", "--output", default="sephiria_layout.png",
help="Output PNG path.",
)
p.add_argument(
"--list", action="store_true",
help="List known slab values and exit.",
)
p.add_argument(
"--no-download", action="store_true",
help="Skip CDN download (text-only render).",
)
p.add_argument(
"--screenshot", default=None,
help="Read slab list from a game screenshot (PNG/JPG).",
)
p.add_argument(
"--bbox", default=None,
help="Required with --screenshot. Pixel bbox of the inventory grid as "
"'left,top,right,bottom'.",
)
args = p.parse_args(argv)
if args.list:
for s in SLABS:
print(f" {s.value:<16s} {s.ko_label:<6s} ({s.tier})")
return 0
basket: List[str] = []
if args.screenshot:
if not args.bbox:
print("--screenshot 사용 시 --bbox 'l,t,r,b' 가 필요합니다.", file=sys.stderr)
return 2
try:
l, t, r, b = (int(v) for v in args.bbox.split(","))
except ValueError:
print("--bbox 형식: left,top,right,bottom (정수)", file=sys.stderr)
return 2
from .screenshot import recognize, recognized_values
recs = recognize(args.screenshot, (l, t, r, b), slot_num=args.slots)
basket.extend(recognized_values(recs))
print(f"인식된 석판: {len(basket)}개 from screenshot")
for raw in args.slab:
if ":" in raw:
v, n = raw.split(":", 1)
try:
count = int(n)
except ValueError:
print(f"잘못된 개수: {raw}", file=sys.stderr)
return 2
else:
v, count = raw, 1
if v not in SLABS_BY_VALUE:
print(f"알 수 없는 석판 value: {v} (use --list)", file=sys.stderr)
return 2
basket.extend([v] * count)
if not basket:
print("최소 하나 이상의 --slab 을 지정하세요.", file=sys.stderr)
return 2
sol = solve(basket, slot_num=args.slots, time_limit=args.time_limit, seed=args.seed)
save_solution(sol, args.output, download=not args.no_download)
print(f"score={sol.score} placed={len(sol.placements)}{args.output}")
return 0
def main(argv: List[str] | None = None) -> int:
argv = list(argv if argv is not None else sys.argv[1:])
if "--cli" in argv or "--list" in argv or any(a.startswith("-s") or a.startswith("--slab") for a in argv):
return _cli(argv)
# Fall back to GUI
from .gui import main as gui_main
return gui_main()
if __name__ == "__main__":
raise SystemExit(main())

257
sephiria_inv/gui.py Normal file
View File

@@ -0,0 +1,257 @@
"""Tkinter GUI for picking slabs and rendering the optimal layout.
Layout (left-to-right):
1. Tier-grouped slab catalog with [+] [] count controls
2. Current basket summary
3. Slot count slider + Solve button + result preview
The preview is rendered to a temp PNG via renderer and shown via PhotoImage.
"""
from __future__ import annotations
import os
import sys
import tempfile
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, ttk
from typing import Dict, List
from PIL import Image, ImageTk
from .renderer import render_solution
from .slabs import SLABS, SLABS_BY_VALUE, TIER_LABEL, TIER_ORDER
from .solver import solve
class App(tk.Tk):
def __init__(self) -> None:
super().__init__()
self.title("Sephiria Inventory Optimizer")
self.geometry("1280x800")
self.minsize(1000, 600)
# counts: value -> int
self.counts: Dict[str, int] = {s.value: 0 for s in SLABS}
self.count_labels: Dict[str, tk.Label] = {}
self.summary_var = tk.StringVar(value="선택된 석판: 0개")
self.score_var = tk.StringVar(value="score: -")
self.slot_var = tk.IntVar(value=34)
self.solving = False
self.preview_image: ImageTk.PhotoImage | None = None
self.last_solution = None
self._build()
# -------------------------------------------------------------- layout
def _build(self) -> None:
root = ttk.Frame(self, padding=8)
root.pack(fill="both", expand=True)
root.columnconfigure(0, weight=2)
root.columnconfigure(1, weight=3)
root.rowconfigure(0, weight=1)
# Left: catalog
left = ttk.LabelFrame(root, text="석판 목록 (보유 개수)", padding=6)
left.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
self._build_catalog(left)
# Right: controls + preview
right = ttk.Frame(root)
right.grid(row=0, column=1, sticky="nsew")
right.columnconfigure(0, weight=1)
right.rowconfigure(2, weight=1)
ctl = ttk.LabelFrame(right, text="옵션", padding=8)
ctl.grid(row=0, column=0, sticky="ew")
ttk.Label(ctl, text="슬롯 수").grid(row=0, column=0, sticky="w")
slot_scale = ttk.Scale(
ctl, from_=18, to=60, orient="horizontal",
variable=self.slot_var, command=self._on_slot_change,
)
slot_scale.grid(row=0, column=1, sticky="ew", padx=6)
ctl.columnconfigure(1, weight=1)
self.slot_label = ttk.Label(ctl, text="34")
self.slot_label.grid(row=0, column=2, sticky="w")
ttk.Button(ctl, text="모두 비우기", command=self._clear).grid(
row=1, column=0, pady=(8, 0), sticky="w"
)
self.solve_btn = ttk.Button(ctl, text="최적 배치 계산", command=self._solve)
self.solve_btn.grid(row=1, column=1, pady=(8, 0), sticky="ew", padx=6)
ttk.Button(ctl, text="이미지 저장…", command=self._save).grid(
row=1, column=2, pady=(8, 0), sticky="e"
)
summary = ttk.Frame(right)
summary.grid(row=1, column=0, sticky="ew", pady=(6, 0))
ttk.Label(summary, textvariable=self.summary_var).pack(side="left")
ttk.Label(summary, textvariable=self.score_var).pack(side="right")
preview_frame = ttk.LabelFrame(right, text="결과 미리보기", padding=4)
preview_frame.grid(row=2, column=0, sticky="nsew", pady=(6, 0))
self.preview = ttk.Label(
preview_frame, anchor="center",
text="좌측에서 석판을 추가하고 '최적 배치 계산'을 눌러주세요.",
)
self.preview.pack(fill="both", expand=True)
def _build_catalog(self, parent: tk.Widget) -> None:
canvas = tk.Canvas(parent, highlightthickness=0)
scroll = ttk.Scrollbar(parent, orient="vertical", command=canvas.yview)
inner = ttk.Frame(canvas)
inner.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
)
canvas.create_window((0, 0), window=inner, anchor="nw")
canvas.configure(yscrollcommand=scroll.set)
canvas.pack(side="left", fill="both", expand=True)
scroll.pack(side="right", fill="y")
# mousewheel
def _wheel(e):
canvas.yview_scroll(int(-e.delta / 120), "units")
canvas.bind_all("<MouseWheel>", _wheel)
canvas.bind_all("<Button-4>", lambda e: canvas.yview_scroll(-1, "units"))
canvas.bind_all("<Button-5>", lambda e: canvas.yview_scroll(1, "units"))
# group by tier
by_tier: Dict[str, List] = {t: [] for t in TIER_ORDER}
for s in SLABS:
by_tier.setdefault(s.tier, []).append(s)
row = 0
for tier in sorted(by_tier, key=lambda t: TIER_ORDER.get(t, 99)):
slabs = by_tier[tier]
if not slabs:
continue
ttk.Label(
inner,
text=TIER_LABEL.get(tier, tier),
font=("TkDefaultFont", 10, "bold"),
).grid(row=row, column=0, columnspan=4, sticky="w", pady=(8, 2))
row += 1
for slab in slabs:
ttk.Label(inner, text=slab.ko_label, width=8).grid(
row=row, column=0, sticky="w"
)
ttk.Button(
inner, text="", width=2,
command=lambda v=slab.value: self._change_count(v, -1),
).grid(row=row, column=1)
lbl = ttk.Label(inner, text="0", width=3, anchor="center")
lbl.grid(row=row, column=2)
self.count_labels[slab.value] = lbl
ttk.Button(
inner, text="+", width=2,
command=lambda v=slab.value: self._change_count(v, 1),
).grid(row=row, column=3)
row += 1
# -------------------------------------------------------------- handlers
def _change_count(self, value: str, delta: int) -> None:
n = max(0, self.counts.get(value, 0) + delta)
self.counts[value] = n
self.count_labels[value]["text"] = str(n)
self._update_summary()
def _clear(self) -> None:
for v in self.counts:
self.counts[v] = 0
self.count_labels[v]["text"] = "0"
self._update_summary()
def _update_summary(self) -> None:
total = sum(self.counts.values())
self.summary_var.set(f"선택된 석판: {total}")
def _on_slot_change(self, _evt) -> None:
v = int(round(self.slot_var.get()))
self.slot_var.set(v)
self.slot_label["text"] = str(v)
def _expand_basket(self) -> List[str]:
basket: List[str] = []
for v, n in self.counts.items():
basket.extend([v] * n)
return basket
def _solve(self) -> None:
if self.solving:
return
basket = self._expand_basket()
if not basket:
messagebox.showinfo("안내", "먼저 보유한 석판을 추가하세요.")
return
slot_num = int(round(self.slot_var.get()))
if len(basket) > slot_num:
if not messagebox.askyesno(
"확인",
f"슬롯({slot_num})보다 석판({len(basket)})이 많습니다.\n"
"초과분은 무시하고 계산할까요?",
):
return
self.solving = True
self.solve_btn["state"] = "disabled"
self.score_var.set("score: 계산 중…")
threading.Thread(
target=self._solve_worker, args=(basket, slot_num), daemon=True
).start()
def _solve_worker(self, basket: List[str], slot_num: int) -> None:
try:
sol = solve(basket, slot_num=slot_num, time_limit=4.0)
img = render_solution(sol, download=True)
self.after(0, self._show_result, sol, img)
except Exception as e:
self.after(0, lambda: messagebox.showerror("오류", str(e)))
finally:
self.after(0, self._solve_done)
def _solve_done(self) -> None:
self.solving = False
self.solve_btn["state"] = "normal"
def _show_result(self, sol, img: Image.Image) -> None:
self.last_solution = sol
# fit preview width
w = max(self.preview.winfo_width(), 600)
scale = min(1.0, w / img.width)
if scale < 1.0:
img = img.resize((int(img.width * scale), int(img.height * scale)))
self.preview_image = ImageTk.PhotoImage(img)
self.preview.configure(image=self.preview_image, text="")
self.score_var.set(f"score: {sol.score}")
def _save(self) -> None:
if self.last_solution is None:
messagebox.showinfo("안내", "먼저 계산을 실행하세요.")
return
path = filedialog.asksaveasfilename(
defaultextension=".png",
filetypes=[("PNG", "*.png")],
initialfile="sephiria_layout.png",
)
if not path:
return
img = render_solution(self.last_solution, download=True)
img.save(path)
messagebox.showinfo("완료", f"저장됨: {path}")
def main() -> int:
try:
app = App()
except tk.TclError as e:
print(f"GUI를 띄울 수 없습니다 ({e}). CLI 모드를 시도하세요:", file=sys.stderr)
print(" python -m sephiria_inv --help", file=sys.stderr)
return 1
app.mainloop()
return 0
if __name__ == "__main__":
raise SystemExit(main())

222
sephiria_inv/renderer.py Normal file
View File

@@ -0,0 +1,222 @@
"""Render a Solution to a PNG image.
The renderer draws a grid mirroring the in-game inventory: each cell is a
fixed-size square; cells with slabs show the slab image (downloaded from the
CDN on first run and cached locally). Effect numbers from `compute_effects`
are overlaid on each cell.
"""
from __future__ import annotations
import os
from io import BytesIO
from typing import Dict, Optional
from PIL import Image, ImageDraw, ImageFont
from .slabs import (
GRID_COLS,
Placement,
SLABS_BY_VALUE,
compute_effects,
)
from .solver import Solution
CACHE_DIR = os.path.join(
os.environ.get("LOCALAPPDATA")
or os.environ.get("XDG_CACHE_HOME")
or os.path.expanduser("~/.cache"),
"sephiria_inv",
"slabs",
)
CDN_BASE = "https://img.sephiria.wiki"
CELL = 96 # pixels per slot
PAD = 12
HEADER = 56
FOOTER = 40
BG_COLOR = (29, 16, 27)
CELL_BG = (50, 30, 50)
CELL_BORDER = (88, 60, 90)
POS_COLOR = (132, 220, 132)
NEG_COLOR = (240, 110, 110)
ZERO_COLOR = (160, 160, 160)
IGNORE_COLOR = (160, 160, 160)
TEXT_COLOR = (236, 226, 232)
# ---------------------------------------------------------------------------
# Asset cache
# ---------------------------------------------------------------------------
def _ensure_cache_dir() -> None:
os.makedirs(CACHE_DIR, exist_ok=True)
def _local_path(slab_image: str) -> str:
# slab.image is like "slabs/foo.png"; flatten to filename
return os.path.join(CACHE_DIR, os.path.basename(slab_image))
def fetch_slab_image(slab_image: str, timeout: float = 10.0) -> Optional[Image.Image]:
"""Return a PIL Image for the slab, downloading + caching if needed.
Returns None if download fails — caller draws a placeholder.
"""
_ensure_cache_dir()
path = _local_path(slab_image)
if os.path.exists(path):
try:
return Image.open(path).convert("RGBA")
except Exception:
pass
try:
import requests # lazy import; allow renderer use without network if cached
url = f"{CDN_BASE}/{slab_image.lstrip('/')}"
r = requests.get(url, timeout=timeout)
if r.status_code != 200:
return None
with open(path, "wb") as f:
f.write(r.content)
return Image.open(BytesIO(r.content)).convert("RGBA")
except Exception:
return None
# ---------------------------------------------------------------------------
# Font
# ---------------------------------------------------------------------------
def _load_font(size: int) -> ImageFont.ImageFont:
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
"C:\\Windows\\Fonts\\malgun.ttf",
"C:\\Windows\\Fonts\\arial.ttf",
"/System/Library/Fonts/Helvetica.ttc",
]
for c in candidates:
if os.path.exists(c):
try:
return ImageFont.truetype(c, size)
except Exception:
continue
return ImageFont.load_default()
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def render_solution(
sol: Solution,
*,
download: bool = True,
title: Optional[str] = None,
) -> Image.Image:
"""Render the solution to a PIL Image."""
grid = sol.grid
rows = len(grid)
width = PAD * 2 + GRID_COLS * CELL
height = PAD * 2 + HEADER + rows * CELL + FOOTER
img = Image.new("RGB", (width, height), BG_COLOR)
draw = ImageDraw.Draw(img)
title_font = _load_font(22)
cell_font = _load_font(20)
eff_font = _load_font(28)
foot_font = _load_font(16)
# Title
txt = title or "Sephiria Inventory Optimizer"
draw.text((PAD, PAD), txt, fill=TEXT_COLOR, font=title_font)
draw.text(
(PAD, PAD + 26),
f"slots {sol.slot_num} · placed {len(sol.placements)} · score {sol.score}",
fill=(180, 180, 180),
font=foot_font,
)
# Effects map
effects, flags = compute_effects(sol.placements, grid)
by_slot: Dict[str, Placement] = {p.slot_id: p for p in sol.placements}
grid_top = PAD + HEADER
for row_cfg in grid:
y = row_cfg["rows"]
for x in range(row_cfg["cols"]):
cx0 = PAD + x * CELL
cy0 = grid_top + y * CELL
cx1 = cx0 + CELL - 4
cy1 = cy0 + CELL - 4
# cell background
draw.rounded_rectangle(
(cx0, cy0, cx1, cy1), radius=8, fill=CELL_BG, outline=CELL_BORDER
)
slot_id = f"{y}-{x}"
p = by_slot.get(slot_id)
if p is not None:
slab = SLABS_BY_VALUE.get(p.value)
placed = False
if slab is not None and download:
img_s = fetch_slab_image(slab.image)
if img_s is not None:
img_s = img_s.resize((CELL - 16, CELL - 16))
if p.rotation:
# PIL rotates counter-clockwise; sephiria rotation
# is 90°-CW per step.
img_s = img_s.rotate(-90 * p.rotation, expand=False)
img.paste(img_s, (cx0 + 6, cy0 + 6), img_s)
placed = True
if not placed and slab is not None:
# text fallback
draw.text(
(cx0 + 6, cy0 + 6),
slab.ko_label,
fill=TEXT_COLOR,
font=cell_font,
)
# effect overlay (top-right)
v = effects.get(slot_id, 0)
if flags.get(slot_id) == "ignore":
label = ""
color = IGNORE_COLOR
else:
label = f"{v:+d}" if v else "0"
color = (
POS_COLOR if v > 0 else NEG_COLOR if v < 0 else ZERO_COLOR
)
tw = draw.textlength(label, font=eff_font)
draw.text(
(cx1 - tw - 6, cy0 + 4),
label,
fill=color,
font=eff_font,
)
# Footer
draw.text(
(PAD, height - FOOTER + 8),
"Generated by sephiria_inv_program · data from img.sephiria.wiki",
fill=(120, 120, 120),
font=foot_font,
)
return img
def save_solution(sol: Solution, out_path: str, **kwargs) -> str:
img = render_solution(sol, **kwargs)
img.save(out_path)
return out_path

122
sephiria_inv/screenshot.py Normal file
View File

@@ -0,0 +1,122 @@
"""Recognize slabs from a screenshot of the in-game inventory.
Approach: template matching against the cached CDN images. Given a screenshot
and the inventory bounding box, we divide it into a grid and compare each cell
against every slab template (resized to the cell). Mean absolute error in RGB
picks the best match; cells above a threshold are treated as empty.
This is a best-effort fallback. Accuracy depends heavily on the screenshot
resolution and the slab images matching the in-game render style. The CDN
images are the same pixel-art assets the game uses, so accuracy is usually
fine when the screenshot is sharp.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import List, Optional, Tuple
from PIL import Image
from .renderer import fetch_slab_image
from .slabs import GRID_COLS, SLABS, generate_grid_config
@dataclass
class Recognition:
slot_id: str
value: Optional[str] # None = empty
score: float # lower = better match
def _mae(a: Image.Image, b: Image.Image) -> float:
"""Mean absolute error in RGB. Both images must be the same size."""
if a.size != b.size:
b = b.resize(a.size)
a_rgb = a.convert("RGB")
b_rgb = b.convert("RGB")
pa = list(a_rgb.getdata())
pb = list(b_rgb.getdata())
n = len(pa)
if n == 0:
return 1e9
total = 0
for (ar, ag, ab), (br, bg, bb) in zip(pa, pb):
total += abs(ar - br) + abs(ag - bg) + abs(ab - bb)
return total / (n * 3)
def _alpha_composite_on_dark(img: Image.Image) -> Image.Image:
"""Slab templates are RGBA on transparent. Composite onto dark BG for fairer compare."""
if img.mode != "RGBA":
return img.convert("RGB")
bg = Image.new("RGBA", img.size, (50, 30, 50, 255))
bg.alpha_composite(img)
return bg.convert("RGB")
def recognize(
screenshot_path: str,
bbox: Tuple[int, int, int, int],
slot_num: int = 34,
empty_threshold: float = 35.0,
) -> List[Recognition]:
"""Recognize slabs in the inventory area of a screenshot.
Args:
screenshot_path: Path to the game screenshot (PNG/JPG).
bbox: (left, top, right, bottom) pixel coords of the inventory grid.
Must enclose only the slot grid, not the surrounding UI.
slot_num: Total slot count (18..60). Used to compute row layout.
empty_threshold: MAE above this counts as empty.
Returns:
List of Recognition entries, one per slot in row-major order.
"""
img = Image.open(screenshot_path).convert("RGB")
left, top, right, bottom = bbox
img = img.crop((left, top, right, bottom))
grid = generate_grid_config(slot_num)
if not grid:
return []
rows = len(grid)
cell_w = (right - left) // GRID_COLS
cell_h = (bottom - top) // rows
template_size = (min(cell_w, cell_h), min(cell_w, cell_h))
# Pre-load and downscale templates
templates: List[Tuple[str, Image.Image]] = []
for slab in SLABS:
t = fetch_slab_image(slab.image)
if t is None:
continue
t = _alpha_composite_on_dark(t).resize(template_size)
templates.append((slab.value, t))
results: List[Recognition] = []
for row in grid:
y = row["rows"]
for x in range(row["cols"]):
cx0 = x * cell_w
cy0 = y * cell_h
cell = img.crop((cx0, cy0, cx0 + cell_w, cy0 + cell_h)).resize(template_size)
best_value: Optional[str] = None
best_score = 1e9
for v, t in templates:
s = _mae(cell, t)
if s < best_score:
best_score = s
best_value = v
if best_score > empty_threshold:
results.append(Recognition(f"{y}-{x}", None, best_score))
else:
results.append(Recognition(f"{y}-{x}", best_value, best_score))
return results
def recognized_values(recognitions: List[Recognition]) -> List[str]:
"""Helper: extract just the non-empty slab values."""
return [r.value for r in recognitions if r.value is not None]

914
sephiria_inv/slabs.py Normal file
View File

@@ -0,0 +1,914 @@
"""Slab catalog and effect handlers.
Ported from WhiteDog1004/sephiria
src/features/simulator/config/slabsLists.ts
src/features/simulator/config/getSlabsEffect.ts
src/features/simulator/lib/calculateEffects.ts
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Dict, List, Optional, Tuple
GridConfig = List[Dict[str, int]] # [{"rows": int, "cols": int}, ...]
Effects = Dict[str, int]
Flags = Dict[str, Optional[str]]
# ---------------------------------------------------------------------------
# Catalog
# ---------------------------------------------------------------------------
@dataclass
class Slab:
value: str
tier: str
ko_label: str
eng_label: str
image: str # CDN path, e.g. "slabs/chivalry.png"
rotate: bool = False
@property
def image_url(self) -> str:
return f"https://img.sephiria.wiki/{self.image.lstrip('/')}"
# tier -> ordering weight (for display)
TIER_ORDER = {"common": 0, "advanced": 1, "rare": 2, "legend": 3, "solid": 4}
TIER_LABEL = {
"common": "일반",
"advanced": "고급",
"rare": "희귀",
"legend": "전설",
"solid": "결속",
}
# All slabs from slabsLists.ts. image path is stripped of leading "/".
SLABS: List[Slab] = [
# COMMON
Slab("chivalry", "common", "기사도", "chivalry", "slabs/chivalry.png", True),
Slab("dry", "common", "건조", "dry", "slabs/dry.png"),
Slab("approximation", "common", "근사", "approximation", "slabs/approximation.png", True),
Slab("advent", "common", "도래", "advent", "slabs/advent.png", True),
Slab("linear", "common", "선의", "linear", "slabs/linear.png"),
Slab("sight", "common", "시선", "sight", "slabs/sight.png", True),
Slab("handshake", "common", "악수", "handshake", "slabs/handshake.png", True),
Slab("fate", "common", "운명", "fate", "slabs/fate.webp"),
Slab("wit", "common", "재치", "wit", "slabs/wit.png", True),
Slab("exploitation", "common", "착취", "exploitation", "slabs/exploitation.png", True),
Slab("unity", "common", "화합", "unity", "slabs/unity.png", True),
Slab("cheer", "common", "환호", "cheer", "slabs/cheer.webp"),
Slab("hope", "common", "희망", "hope", "slabs/hope.png", True),
# ADVANCED
Slab("compete", "advanced", "경쟁", "compete", "slabs/compete.png", True),
Slab("beating", "advanced", "고동", "beating", "slabs/beating.png", True),
Slab("home_town", "advanced", "고양", "home_town", "slabs/home-town.png", True),
Slab("past", "advanced", "과거", "past", "slabs/past.png", True),
Slab("future", "advanced", "미래", "future", "slabs/future.png", True),
Slab("distribution", "advanced", "분배", "distribution", "slabs/distribution.png"),
Slab("triceps", "advanced", "삼두", "triceps", "slabs/triceps.png"),
Slab("harvesting", "advanced", "수확", "harvesting", "slabs/harvesting.png", True),
Slab("binary_star", "advanced", "쌍성", "binary_star", "slabs/binary_star.png", True),
Slab("nurture", "advanced", "양육", "nurture", "slabs/nurture.png", True),
Slab("yearning", "advanced", "열망", "yearning", "slabs/yearning.png"),
Slab("agglutination", "advanced", "응집", "agglutination", "slabs/agglutination.png", True),
Slab("entrance", "advanced", "입구", "entrance", "slabs/entrance.png"),
Slab("joke", "advanced", "장난", "joke", "slabs/joke.png", True),
Slab("load", "advanced", "적재", "load", "slabs/load.png", True),
Slab("transition", "advanced", "전이", "transition", "slabs/transition.png", True),
Slab("advance", "advanced", "전진", "advance", "slabs/advance.png", True),
Slab("justice", "advanced", "정의", "justice", "slabs/justice.png"),
Slab("preparation", "advanced", "준비", "preparation", "slabs/preparation.png", True),
Slab("exit", "advanced", "출구", "exit", "slabs/exit.png"),
Slab("tide", "advanced", "파도", "tide", "slabs/tide.png", True),
Slab("dedication", "advanced", "헌정", "dedication", "slabs/dedication.png"),
Slab("honor", "advanced", "명예", "honor", "slabs/honor.png", True),
# RARE
Slab("base", "rare", "기반", "base", "slabs/base.png"),
Slab("warrant", "rare", "권능", "warrant", "slabs/warrant.png", True),
Slab("disconnection", "rare", "단절", "disconnection", "slabs/disconnection.png"),
Slab("concurrency", "rare", "동시성", "concurrency", "slabs/concurrency.png"),
Slab("vow", "rare", "맹세", "vow", "slabs/vow.png", True),
Slab("rebellion", "rare", "반항", "rebellion", "slabs/rebellion.png", True),
Slab("connection", "rare", "이음", "connection", "slabs/connection.png", True),
Slab("shade", "rare", "차양", "shade", "slabs/shade.png"),
# LEGEND
Slab("thorn", "legend", "가시", "thorn", "slabs/thorn.png"),
Slab("boundary", "legend", "경계", "boundary", "slabs/boundary.png"),
Slab("sheen", "legend", "광휘", "sheen", "slabs/sheen.png", True),
Slab("miracle", "legend", "기적", "miracle", "slabs/miracle.png"),
Slab("daydream", "legend", "백일몽", "daydream", "slabs/daydream.png", True),
Slab("compression", "legend", "압축", "compression", "slabs/compression.png", True),
Slab("certitude", "legend", "확신", "certitude", "slabs/certitude.png", True),
Slab("hospitality", "legend", "환대", "hospitality", "slabs/hospitality.png"),
Slab("courage", "legend", "용기", "courage", "slabs/courage.png", True),
Slab("peace", "legend", "평화", "peace", "slabs/peace.png", True),
]
SLABS_BY_VALUE: Dict[str, Slab] = {s.value: s for s in SLABS}
# ---------------------------------------------------------------------------
# Grid helpers
# ---------------------------------------------------------------------------
GRID_COLS = 6
def generate_grid_config(total_slots: int = 34) -> GridConfig:
"""Mirror of generateGridConfig() in SlotComponent.tsx."""
if total_slots <= 0:
return []
config: GridConfig = []
full_rows = total_slots // GRID_COLS
last_row_cols = total_slots % GRID_COLS
for i in range(full_rows):
config.append({"rows": i, "cols": GRID_COLS})
if last_row_cols > 0:
config.append({"rows": full_rows, "cols": last_row_cols})
return config
def all_slot_ids(grid: GridConfig) -> List[str]:
return [f"{row['rows']}-{c}" for row in grid for c in range(row["cols"])]
def cols_in_row(grid: GridConfig, y: int) -> int:
for row in grid:
if row["rows"] == y:
return row["cols"]
return 0
# ---------------------------------------------------------------------------
# Rotation helpers
# ---------------------------------------------------------------------------
def rotate_offset(dx: int, dy: int, rotation: int) -> Tuple[int, int]:
if rotation == 1:
return -dy, dx
if rotation == 2:
return -dx, -dy
if rotation == 3:
return dy, -dx
return dx, dy
def apply_offsets(
offsets: List[Dict],
x: int,
y: int,
effects: Effects,
rotation: int = 0,
flags: Optional[Flags] = None,
) -> None:
for o in offsets:
ndx, ndy = rotate_offset(o["dx"], o["dy"], rotation)
target = f"{y + ndy}-{x + ndx}"
if target in effects:
effects[target] += o.get("value", 1)
if flags is not None and o.get("flag") == "ignore" and target in flags:
flags[target] = "ignore"
# ---------------------------------------------------------------------------
# Effect handlers
# Signature: handler(x, y, slot_id, rotation, effects, flags, grid_config)
# ---------------------------------------------------------------------------
Handler = Callable[[int, int, str, int, Effects, Flags, GridConfig], None]
def _no_op(*_args, **_kwargs) -> None:
pass
# COMMON
def _approximation(x, y, _s, rot, e, _f, _g):
apply_offsets([{"dx": 0, "dy": -1}, {"dx": 1, "dy": 0}], x, y, e, rot)
def _dry(x, y, _s, _rot, e, _f, _g):
apply_offsets([{"dx": 0, "dy": -1}, {"dx": 0, "dy": 1}], x, y, e, 0)
def _chivalry(x, y, _s, rot, e, _f, _g):
apply_offsets([{"dx": -1, "dy": -2}], x, y, e, rot)
def _advent(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": 0, "dy": -1},
{"dx": 0, "dy": -2},
{"dx": 0, "dy": 1, "value": -1},
{"dx": 0, "dy": 2, "value": -1},
],
x,
y,
e,
rot,
)
def _linear(x, y, _s, _rot, e, _f, g):
if not g:
return
last_row_index = g[-1]["rows"]
if y == last_row_index:
for target in (f"{y}-{x - 1}", f"{y}-{x + 1}"):
if target in e:
e[target] += 1
def _sight(x, y, _s, rot, e, _f, _g):
apply_offsets(
[{"dx": -1, "dy": -1}, {"dx": 1, "dy": 1, "value": -1}], x, y, e, rot
)
def _handshake(x, y, _s, rot, e, _f, _g):
apply_offsets([{"dx": 0, "dy": -1}, {"dx": 0, "dy": 1}], x, y, e, rot)
def _fate(x, y, _s, _rot, e, _f, _g):
apply_offsets([{"dx": 0, "dy": 1}], x, y, e, 0)
def _wit(x, y, _s, rot, e, _f, _g):
apply_offsets([{"dx": -1, "dy": -1}], x, y, e, rot)
def _exploitation(x, y, _s, rot, e, _f, _g):
apply_offsets(
[{"dx": 0, "dy": -1}, {"dx": 0, "dy": 1, "value": -1}], x, y, e, rot
)
def _unity(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": 1, "dy": 0},
{"dx": 0, "dy": 1},
{"dx": 0, "dy": -1, "value": -1},
{"dx": -1, "dy": 0, "value": -1},
],
x,
y,
e,
rot,
)
def _cheer(x, y, _s, _rot, e, _f, _g):
apply_offsets([{"dx": 0, "dy": -1}], x, y, e, 0)
def _hope(x, y, _s, rot, e, _f, _g):
apply_offsets([{"dx": 1, "dy": 0}], x, y, e, rot)
# ADVANCED
def _compete(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": 0, "dy": 1, "value": 2},
{"dx": 0, "dy": -1, "value": -1},
{"dx": -1, "dy": -1, "value": -1},
],
x,
y,
e,
rot,
)
def _beating(x, y, _s, rot, e, _f, _g):
apply_offsets([{"dx": 0, "dy": -2, "value": 2}], x, y, e, rot)
def _home_town(x, y, _s, rot, _e, f, _g):
ndx, ndy = rotate_offset(1, 0, rot)
target = f"{y + ndy}-{x + ndx}"
if f is not None and target in f:
f[target] = "ignore"
def _past(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": -1, "dy": -1},
{"dx": 0, "dy": -1},
{"dx": 1, "dy": -1},
{"dx": 1, "dy": 0},
],
x,
y,
e,
rot,
)
def _future(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": -1, "dy": -1},
{"dx": 0, "dy": -1},
{"dx": 1, "dy": -1},
{"dx": -1, "dy": 0},
],
x,
y,
e,
rot,
)
def _distribution(x, y, _s, _rot, e, _f, _g):
apply_offsets(
[
{"dx": 0, "dy": -1},
{"dx": -1, "dy": 0},
{"dx": 1, "dy": 0},
{"dx": 0, "dy": 1},
],
x,
y,
e,
0,
)
def _triceps(x, y, _s, _rot, e, _f, _g):
apply_offsets(
[{"dx": 0, "dy": -1}, {"dx": -1, "dy": 0}, {"dx": 1, "dy": 0}], x, y, e, 0
)
def _harvesting(x, y, _s, rot, e, _f, _g):
apply_offsets(
[{"dx": 0, "dy": 1, "value": 2}, {"dx": 0, "dy": -1, "value": 2}],
x,
y,
e,
rot,
)
def _binary_star(x, y, _s, rot, e, _f, _g):
apply_offsets(
[{"dx": 0, "dy": 2, "value": 2}, {"dx": 0, "dy": -2, "value": 2}],
x,
y,
e,
rot,
)
def _nurture(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": -1, "dy": -1},
{"dx": 0, "dy": -1},
{"dx": 1, "dy": -1},
{"dx": 0, "dy": 1, "value": -1},
{"dx": 0, "dy": 2, "value": -1},
],
x,
y,
e,
rot,
)
def _yearning(x, y, _s, rot, e, _f, _g):
apply_offsets([{"dx": 0, "dy": -1, "value": 2}], x, y, e, rot)
def _agglutination(x, y, slot, rot, e, _f, g):
if not g:
return
if rot in (1, 3):
for row in g:
if x < row["cols"]:
target = f"{row['rows']}-{x}"
if target != slot and target in e:
e[target] += -1
else:
c = cols_in_row(g, y)
for i in range(c):
target = f"{y}-{i}"
if target != slot and target in e:
e[target] += -1
apply_offsets([{"dx": 0, "dy": -1, "value": 3}], x, y, e, rot)
def _entrance(x, y, _s, _rot, e, _f, _g):
apply_offsets(
[
{"dx": 0, "dy": -1, "value": 2},
{"dx": -1, "dy": -1, "value": 1},
{"dx": 1, "dy": -1, "value": 1},
],
x,
y,
e,
0,
)
def _joke(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": 0, "dy": -1},
{"dx": 1, "dy": -1},
{"dx": -1, "dy": -1},
{"dx": -1, "dy": 0, "value": -1},
{"dx": 1, "dy": 0, "value": -1},
],
x,
y,
e,
rot,
)
def _load(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": 0, "dy": -1},
{"dx": -1, "dy": -1},
{"dx": 0, "dy": -2},
{"dx": -1, "dy": -2},
],
x,
y,
e,
rot,
)
def _transition(x, y, slot, rot, e, _f, g):
if not g:
return
if rot in (1, 3):
horizontal_value, vertical_value = -1, 1
else:
horizontal_value, vertical_value = 1, -1
c = cols_in_row(g, y)
for i in range(c):
target = f"{y}-{i}"
if target != slot and target in e:
e[target] += horizontal_value
for row in g:
if x < row["cols"]:
target = f"{row['rows']}-{x}"
if target != slot and target in e:
e[target] += vertical_value
def _advance(x, y, _s, rot, e, _f, _g):
apply_offsets(
[{"dx": 0, "dy": -1}, {"dx": 0, "dy": -2}, {"dx": 0, "dy": -3}], x, y, e, rot
)
def _justice(x, y, slot, _rot, e, _f, g):
if not g:
return
c = cols_in_row(g, y)
is_edge = x == 0 or x == c - 1
if not is_edge:
return
for row in g:
if x < row["cols"]:
target = f"{row['rows']}-{x}"
if target != slot and target in e:
e[target] += 1
def _preparation(x, y, _s, rot, e, _f, _g):
apply_offsets(
[{"dx": -1, "dy": -1}, {"dx": 1, "dy": 1}], x, y, e, rot
)
def _exit(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": -1, "dy": 1},
{"dx": 0, "dy": 1, "value": 2},
{"dx": 1, "dy": 1},
],
x,
y,
e,
rot,
)
def _tide(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": 1, "dy": -1, "value": 2},
{"dx": 0, "dy": -1, "value": -1},
{"dx": 1, "dy": 0, "value": -1},
],
x,
y,
e,
rot,
)
def _dedication(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": 1, "dy": -1},
{"dx": -1, "dy": -1},
{"dx": 1, "dy": 1},
{"dx": -1, "dy": 1},
],
x,
y,
e,
rot,
)
def _honor(x, y, _s, rot, e, _f, _g):
apply_offsets(
[{"dx": 0, "dy": -1, "value": 2}, {"dx": -1, "dy": -2}], x, y, e, rot
)
# RARE
def _base(_x, y, slot, _rot, e, _f, g):
if not g:
return
c = cols_in_row(g, y)
for i in range(c):
target = f"{y}-{i}"
if target != slot and target in e:
e[target] += 1
def _warrant(x, y, _s, rot, e, _f, _g):
apply_offsets([{"dx": 0, "dy": -1, "value": 3}], x, y, e, rot)
def _disconnection(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": 0, "dy": -1, "value": 3},
{"dx": 0, "dy": 1, "value": 3},
{"dx": 1, "dy": 0, "value": -1},
{"dx": -1, "dy": 0, "value": -1},
],
x,
y,
e,
rot,
)
def _concurrency(x, _y, slot, _rot, e, _f, g):
if not g:
return
for row in g:
if x < row["cols"]:
target = f"{row['rows']}-{x}"
if target != slot and target in e:
e[target] += 1
def _vow(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": 0, "dy": -2, "value": 2},
{"dx": 0, "dy": 1},
{"dx": 0, "dy": -1},
{"dx": -1, "dy": 0},
{"dx": 1, "dy": 0},
],
x,
y,
e,
rot,
)
def _rebellion(x, y, _s, rot, e, _f, _g):
cx = -1 if rot in (1, 3) else 1
directions = [(cx, -1), (-cx, 1)]
for dx, dy in directions:
cur_x, cur_y = x, y
while True:
cur_x += dx
cur_y += dy
target = f"{cur_y}-{cur_x}"
if target in e:
e[target] += 1
else:
break
def _connection(x, y, _s, rot, e, f, _g):
# value
ndx, ndy = rotate_offset(0, -1, rot)
target_v = f"{y + ndy}-{x + ndx}"
if target_v in e:
e[target_v] += 2
# ignore
ndx, ndy = rotate_offset(0, 1, rot)
target_i = f"{y + ndy}-{x + ndx}"
if f is not None and target_i in f:
f[target_i] = "ignore"
def _shade(_x, y, _s, _rot, e, _f, g):
if not g:
return
if y != 0 or len(g) < 2:
return
last_row = g[-1]
second_to_last = g[-2]
last_row_index = last_row["rows"]
cols_last = last_row["cols"]
second_to_last_index = second_to_last["rows"]
cols_second = second_to_last["cols"]
for col_idx in range(cols_last):
target = f"{last_row_index}-{col_idx}"
if target in e:
e[target] += 1
if cols_second > cols_last:
for col_idx in range(cols_last, cols_second):
target = f"{second_to_last_index}-{col_idx}"
if target in e:
e[target] += 1
# LEGEND
def _thorn(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": 0, "dy": -1, "value": 2},
{"dx": 0, "dy": 1, "value": 2},
{"dx": -1, "dy": -1},
{"dx": -1, "dy": 0},
{"dx": -1, "dy": 1},
{"dx": 1, "dy": -1},
{"dx": 1, "dy": 0},
{"dx": 1, "dy": 1},
],
x,
y,
e,
rot,
)
def _boundary(_x, _y, _s, _rot, e, _f, g):
if not g:
return
def apply_row(row):
for col_idx in range(row["cols"]):
target = f"{row['rows']}-{col_idx}"
if target in e:
e[target] += 1
apply_row(g[0])
if len(g) > 1:
last = g[-1]
second = g[-2]
apply_row(last)
if second["cols"] > last["cols"]:
for col_idx in range(last["cols"], second["cols"]):
target = f"{second['rows']}-{col_idx}"
if target in e:
e[target] += 1
def _sheen(x, y, slot, rot, e, _f, g):
if not g:
return
if rot in (1, 3):
for row in g:
if x < row["cols"]:
target = f"{row['rows']}-{x}"
if target != slot and target in e:
e[target] += 1
else:
c = cols_in_row(g, y)
for i in range(c):
target = f"{y}-{i}"
if target != slot and target in e:
e[target] += 1
apply_offsets(
[{"dx": 0, "dy": -1, "value": 2}, {"dx": 0, "dy": 1, "value": 2}],
x,
y,
e,
rot,
)
def _miracle(x, y, slot, _rot, e, _f, g):
if not g:
return
for row in g:
if x < row["cols"]:
target = f"{row['rows']}-{x}"
if target != slot and target in e:
e[target] += 1
c = cols_in_row(g, y)
for i in range(c):
target = f"{y}-{i}"
if target != slot and target in e:
e[target] += 1
def _daydream(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": -1, "dy": -1},
{"dx": -1, "dy": -2},
{"dx": 1, "dy": -1},
{"dx": 1, "dy": -2},
{"dx": -1, "dy": 1},
{"dx": -1, "dy": 2},
{"dx": 1, "dy": 1},
{"dx": 1, "dy": 2},
],
x,
y,
e,
rot,
)
def _compression(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": 0, "dy": -1, "value": 3},
{"dx": 0, "dy": -2, "value": 2},
{"dx": 0, "dy": -3},
],
x,
y,
e,
rot,
)
def _certitude(x, y, _s, rot, e, _f, _g):
apply_offsets([{"dx": 0, "dy": -1, "value": 5}], x, y, e, rot)
def _hospitality(x, y, _s, rot, e, f, _g):
apply_offsets(
[
{"dx": 0, "dy": -1, "value": 1, "flag": "ignore"},
{"dx": -1, "dy": 0, "value": 2, "flag": "ignore"},
],
x,
y,
e,
rot,
f,
)
def _peace(x, y, _s, rot, e, _f, _g):
apply_offsets(
[{"dx": -1, "dy": 0, "value": 3}, {"dx": 1, "dy": 0, "value": 3}], x, y, e, rot
)
def _courage(x, y, _s, rot, e, _f, _g):
apply_offsets(
[
{"dx": -3, "dy": -3},
{"dx": -2, "dy": -2},
{"dx": -1, "dy": -1},
{"dx": 1, "dy": 1},
{"dx": 2, "dy": 2},
{"dx": 1, "dy": -1, "value": 2},
{"dx": -1, "dy": 1, "value": 2},
],
x,
y,
e,
rot,
)
HANDLERS: Dict[str, Handler] = {
# COMMON
"approximation": _approximation,
"dry": _dry,
"chivalry": _chivalry,
"advent": _advent,
"linear": _linear,
"sight": _sight,
"handshake": _handshake,
"fate": _fate,
"wit": _wit,
"exploitation": _exploitation,
"unity": _unity,
"cheer": _cheer,
"hope": _hope,
# ADVANCED
"compete": _compete,
"beating": _beating,
"home_town": _home_town,
"past": _past,
"future": _future,
"distribution": _distribution,
"triceps": _triceps,
"harvesting": _harvesting,
"binary_star": _binary_star,
"nurture": _nurture,
"yearning": _yearning,
"agglutination": _agglutination,
"entrance": _entrance,
"joke": _joke,
"load": _load,
"transition": _transition,
"advance": _advance,
"justice": _justice,
"preparation": _preparation,
"exit": _exit,
"tide": _tide,
"dedication": _dedication,
"honor": _honor,
# RARE
"base": _base,
"warrant": _warrant,
"disconnection": _disconnection,
"concurrency": _concurrency,
"vow": _vow,
"rebellion": _rebellion,
"connection": _connection,
"shade": _shade,
# LEGEND
"thorn": _thorn,
"boundary": _boundary,
"sheen": _sheen,
"miracle": _miracle,
"daydream": _daydream,
"compression": _compression,
"certitude": _certitude,
"hospitality": _hospitality,
"peace": _peace,
"courage": _courage,
}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
@dataclass
class Placement:
value: str # slab value e.g. "chivalry"
slot_id: str # e.g. "2-3"
rotation: int = 0
def compute_effects(
placements: List[Placement], grid: GridConfig
) -> Tuple[Effects, Flags]:
"""Apply all placements' handlers to the grid and return resulting effects + flags."""
effects: Effects = {sid: 0 for sid in all_slot_ids(grid)}
flags: Flags = {sid: None for sid in all_slot_ids(grid)}
for p in placements:
y, x = (int(v) for v in p.slot_id.split("-"))
handler = HANDLERS.get(p.value)
if handler is None:
continue
handler(x, y, p.slot_id, p.rotation, effects, flags, grid)
return effects, flags
def score(placements: List[Placement], grid: GridConfig) -> int:
"""Score = sum of effects on slab-occupied cells (ignoring "ignore" flagged cells).
This represents the boost an artifact placed on each slab-cell would get.
Higher = better layout. Empty cells don't count.
"""
effects, flags = compute_effects(placements, grid)
total = 0
for p in placements:
if flags.get(p.slot_id) == "ignore":
continue
total += effects.get(p.slot_id, 0)
return total

137
sephiria_inv/solver.py Normal file
View File

@@ -0,0 +1,137 @@
"""Hill-climbing solver for slab placement.
We don't try to enumerate the full N! search space — for a 34-slot grid the
number of placements is enormous. Instead we use multi-start random + first-
improvement hill climbing with swap/rotate/move moves. This converges quickly
to good local optima and is plenty fast for typical inputs (≤ 60 slabs).
"""
from __future__ import annotations
import random
import time
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Tuple
from .slabs import (
GRID_COLS,
GridConfig,
Placement,
SLABS_BY_VALUE,
all_slot_ids,
generate_grid_config,
score,
)
@dataclass
class Solution:
placements: List[Placement] = field(default_factory=list)
grid: GridConfig = field(default_factory=list)
score: int = 0
slot_num: int = 34
def _initial(
slab_values: List[str], grid: GridConfig, rng: random.Random
) -> List[Placement]:
slots = all_slot_ids(grid)
rng.shuffle(slots)
placements: List[Placement] = []
for v, sid in zip(slab_values, slots):
slab = SLABS_BY_VALUE.get(v)
rot = rng.randint(0, 3) if slab and slab.rotate else 0
placements.append(Placement(value=v, slot_id=sid, rotation=rot))
return placements
def _hill_climb(
placements: List[Placement],
grid: GridConfig,
rng: random.Random,
max_iters: int,
time_limit: float,
) -> Tuple[List[Placement], int]:
cur = [Placement(p.value, p.slot_id, p.rotation) for p in placements]
cur_score = score(cur, grid)
used = {p.slot_id for p in cur}
free = [sid for sid in all_slot_ids(grid) if sid not in used]
start = time.monotonic()
for _ in range(max_iters):
if time.monotonic() - start > time_limit:
break
move = rng.random()
if move < 0.45 and len(cur) > 1:
# swap two placements
i, j = rng.sample(range(len(cur)), 2)
cur[i].slot_id, cur[j].slot_id = cur[j].slot_id, cur[i].slot_id
ns = score(cur, grid)
if ns > cur_score:
cur_score = ns
else:
cur[i].slot_id, cur[j].slot_id = cur[j].slot_id, cur[i].slot_id
elif move < 0.75 and free:
# move one placement to a free slot
i = rng.randint(0, len(cur) - 1)
old = cur[i].slot_id
new_idx = rng.randint(0, len(free) - 1)
new_slot = free[new_idx]
cur[i].slot_id = new_slot
ns = score(cur, grid)
if ns > cur_score:
cur_score = ns
free[new_idx] = old
else:
cur[i].slot_id = old
else:
# rotate one placement
i = rng.randint(0, len(cur) - 1)
slab = SLABS_BY_VALUE.get(cur[i].value)
if not slab or not slab.rotate:
continue
old_rot = cur[i].rotation
cur[i].rotation = (old_rot + rng.choice([1, 2, 3])) % 4
ns = score(cur, grid)
if ns > cur_score:
cur_score = ns
else:
cur[i].rotation = old_rot
return cur, cur_score
def solve(
slab_values: List[str],
slot_num: int = 34,
*,
restarts: int = 6,
iters_per_restart: int = 8000,
time_limit: float = 4.0,
seed: Optional[int] = None,
) -> Solution:
"""Find a good placement for the given slabs.
`slab_values` is a list of slab `value` strings (duplicates allowed).
Slabs that don't fit (more slabs than slots) are dropped from the tail.
"""
grid = generate_grid_config(slot_num)
capacity = sum(r["cols"] for r in grid)
if len(slab_values) > capacity:
slab_values = slab_values[:capacity]
if not slab_values:
return Solution([], grid, 0, slot_num)
rng = random.Random(seed)
best: List[Placement] = []
best_score = -10**9
per_restart_budget = time_limit / max(restarts, 1)
for _ in range(restarts):
init = _initial(slab_values, grid, rng)
sol, sc = _hill_climb(init, grid, rng, iters_per_restart, per_restart_budget)
if sc > best_score:
best_score = sc
best = [Placement(p.value, p.slot_id, p.rotation) for p in sol]
return Solution(best, grid, best_score, slot_num)