Update Healer Man server

This commit is contained in:
phenom committed 2026-09-26 17:51:56 -04:00
1 parent 9c5fb4c614
commit bd073fb00f
113 files changed
+4902 -273

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const base = process.env.RACE_TEST_URL || 'http://127.0.0.1:5173';
const nonce = Date.now().toString(36);
async function createPlayer(browser, suffix) {
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
const page = await context.newPage();
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.getByLabel('Username').fill(`Race${suffix}${nonce}`.slice(0, 20));
await page.getByLabel('Password').fill('raceTestPassword123');
await page.getByRole('button', { name: /Create account/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill(`Racer${suffix}`);
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue|Enter world/ }).first().click({ force: true });
await page.getByRole('button', { name: 'Continue', exact: true }).click({ force: true });
try { await page.locator('.main-menu-screen').waitFor(); }
catch (error) { console.error((await page.locator('body').innerText()).slice(0, 2500)); throw error; }
await page.getByRole('button', { name: /Dungeon Race/ }).click();
await page.locator('.dungeon-race-screen').waitFor();
assert.equal(await page.getByRole('button', { name: 'Online ranked' }).getAttribute('aria-pressed'), 'true');
return { page, context, errors };
}
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const players = [];
try {
players.push(await createPlayer(browser, 'A'));
players.push(await createPlayer(browser, 'B'));
await Promise.all(players.map(({ page }) => page.getByRole('button', { name: 'Enter race queue' }).click()));
await Promise.all(players.map(({ page }) => page.locator('.game-shell').waitFor({ timeout: 120_000 })));
const states = await Promise.all(players.map(async ({ page }) => page.evaluate(async () => {
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
const race = useDungeonRaceStore.getState();
return { matchId: race.matchId, dungeonId: race.dungeonId, seed: race.seed,
opponentType: race.opponent?.type, opponentName: race.opponent?.name };
})));
assert.equal(states[0].matchId, states[1].matchId);
assert.equal(states[0].dungeonId, states[1].dungeonId);
assert.equal(states[0].seed, states[1].seed);
assert.equal(states[0].opponentType, 'human');
assert.equal(states[1].opponentType, 'human');
assert.deepEqual(players.flatMap(({ errors }) => errors), []);
await Promise.all(players.map(({ context }) => context.close()));
const solo = await createPlayer(browser, 'Solo');
players.push(solo);
await solo.page.getByRole('button', { name: 'Enter race queue' }).click();
await solo.page.locator('.game-shell').waitFor({ timeout: 120_000 });
const fallback = await solo.page.evaluate(async () => {
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
const race = useDungeonRaceStore.getState();
return { matchId: race.matchId, opponentType: race.opponent?.type,
seats: race.opponent?.type === 'bot' ? race.opponent.roster.length : 0 };
});
assert.equal(fallback.opponentType, 'bot');
assert.equal(fallback.seats, 5);
assert.deepEqual(solo.errors, []);
console.log(JSON.stringify({ ok: true, states, fallback }, null, 2));
} finally {
await Promise.all(players.map(({ context }) => context.close()));
await browser.close();
}
})().catch((error) => { console.error(error); process.exitCode = 1; });
+89
View File
@@ -0,0 +1,89 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const out = path.resolve('playtest-artifacts/dungeon-race');
fs.mkdirSync(out, { recursive: true });
const base = process.env.RACE_TEST_URL || 'http://127.0.0.1:5173';
(async () => {
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
try {
await page.goto(base, { waitUntil: 'domcontentloaded', timeout: 120_000 });
await page.getByRole('button', { name: /Play offline/ }).click();
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('RaceTester');
await page.getByRole('button', { name: /Create character/ }).click();
await page.getByRole('button', { name: /Continue|Enter world/ }).first().click({ force: true });
await page.getByRole('button', { name: 'Continue', exact: true }).click();
await page.locator('.main-menu-screen').waitFor();
await page.getByRole('button', { name: /Dungeon Race/ }).click();
await page.locator('.dungeon-race-screen').waitFor();
await page.screenshot({ path: path.join(out, '01-setup-desktop.png') });
assert.equal(await page.getByRole('button', { name: 'Offline practice' }).getAttribute('aria-pressed'), 'true');
await page.locator('.dungeon-race-screen__setup select').first()
.selectOption('bite-sized-stormwind-stockade');
await page.getByRole('button', { name: 'Start practice' }).click();
await page.locator('.game-shell').waitFor({ timeout: 120_000 });
await page.locator('.dungeon-race-hud').waitFor({ timeout: 120_000 });
await page.locator('.dungeon-boss-intro').waitFor({ state: 'visible', timeout: 120_000 });
await page.locator('.dungeon-boss-intro').waitFor({ state: 'hidden', timeout: 120_000 });
await page.locator('.dungeon-race-countdown').waitFor({ state: 'visible', timeout: 30_000 });
await page.locator('.dungeon-race-countdown').waitFor({ state: 'hidden', timeout: 30_000 });
await page.screenshot({ path: path.join(out, '02-active-desktop.png') });
const state = await page.evaluate(async () => {
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
const { useGameStore } = await import('/src/game/store.ts');
const race = useDungeonRaceStore.getState();
return { mode: race.mode, status: race.status, inGame: race.inGame,
dungeonId: race.dungeonId, activeDungeonId: useGameStore.getState().activeDungeonId,
opponentSeats: race.opponent?.type === 'bot' ? race.opponent.roster.length : 0 };
});
assert.equal(state.mode, 'practice');
assert.equal(state.status, 'active');
assert.equal(state.inGame, true);
assert.equal(state.opponentSeats, 5);
assert.equal(state.activeDungeonId, state.dungeonId);
await page.evaluate(async () => {
const { useDungeonRaceStore } = await import('/src/app/dungeonRaceStore.ts');
const race = useDungeonRaceStore.getState();
useDungeonRaceStore.setState({ startAt: Date.now() - race.opponent.finishMs - 1000, result: null });
useDungeonRaceStore.getState().tick();
});
await page.locator('.dungeon-race-toast--loss').waitFor();
assert.equal(await page.locator('.game-shell').count(), 1);
await page.screenshot({ path: path.join(out, '03-loss-continues-desktop.png') });
await page.setViewportSize({ width: 960, height: 540 });
await page.screenshot({ path: path.join(out, '04-loss-thor-size.png') });
const card = await page.evaluate(async () => {
const { useCombatStore } = await import('/src/game/combatStore.ts');
const { BOSS_CARD_CATALOG } = await import('/src/game/bossCardCatalog.ts');
const definition = BOSS_CARD_CATALOG.find((entry) => entry.dungeons.some((dungeon) => dungeon.id === 'stormwind-stockade'));
const mob = Object.values(useCombatStore.getState().mobs)[0];
if (!definition || !mob) throw new Error(`Boss-card fixture is unavailable: definition=${Boolean(definition)} mob=${Boolean(mob)}.`);
const before = useCombatStore.getState().bossCards.counts[definition.key]?.normal ?? 0;
useCombatStore.setState((state) => ({ mobs: { ...state.mobs,
[mob.id]: { ...mob, name: definition.name, boss: true, dead: true, hasLoot: true,
looted: false, despawned: false, pendingLoot: [],
bossCardDrop: { bossKey: definition.key, spawnId: mob.id,
claimId: `${Date.now()}:1`, rarities: ['normal'] } } } }));
const looted = useCombatStore.getState().lootMob(mob.id);
return { looted, before, after: useCombatStore.getState().bossCards.counts[definition.key]?.normal ?? 0 };
});
console.log('Post-loss boss-card result:', card);
assert.equal(card.after, card.before + 1);
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(out, '05-card-after-loss-fixture.png') });
assert.deepEqual(errors, []);
console.log(JSON.stringify({ ok: true, state, card, screenshots: out }, null, 2));
} catch (error) {
await page.screenshot({ path: path.join(out, 'failure.png') });
console.error((await page.locator('body').innerText()).slice(0, 5000));
throw error;
} finally { await browser.close(); }
})().catch((error) => { console.error(error); process.exitCode = 1; });
+89
View File
@@ -0,0 +1,89 @@
const { chromium } = require('C:/Users/phenom/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const directory = path.resolve('artifacts/playtest/runewaker-maps');
const port = 4197;
const base = `http://127.0.0.1:${port}`;
fs.mkdirSync(directory, { recursive: true });
const runtimeData = fs.mkdtempSync(path.join(os.tmpdir(), 'healer-man-runewaker-maps-'));
(async () => {
const { createGameServer } = await import('../server/server.mjs');
const runtime = createGameServer({ dataDir: runtimeData, enableShelvedModes: false });
await new Promise((resolve) => runtime.server.listen(port, '127.0.0.1', resolve));
const browser = await chromium.launch({ channel: 'msedge', headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await context.newPage();
page.setDefaultTimeout(180_000);
const pageErrors = [];
page.on('pageerror', (error) => pageErrors.push(error.message));
try {
await page.goto(base, { waitUntil: 'domcontentloaded' });
await page.getByLabel('Username').fill('RunewakerMapQA');
await page.getByLabel('Password').fill('runewaker-map-playtest');
await page.getByRole('button', { name: /Create account/ }).click();
await page.locator('.character-select-screen').waitFor();
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.creator-category-tabs button').filter({ hasText: 'RoM' }).click();
await page.locator('.class-option').filter({ hasText: 'Rogue' }).click();
await page.getByRole('button', { name: /Customize & name/ }).click();
await page.getByLabel('Character name').fill('RunewakerMapQA');
await page.getByRole('button', { name: /Create character/ }).click();
await page.locator('.character-select-screen').waitFor();
await page.locator('.roster-enter').evaluate((button) => button.click());
await page.locator('.main-menu-screen').waitFor();
await page.getByRole('button', { name: /Dungeons/ }).click();
await page.locator('.dungeon-category-tab').filter({ hasText: 'RoM' }).click();
await page.locator('.dungeon-select-row').filter({ hasText: 'Forsaken Abbey' }).click();
console.log('Selected Forsaken Abbey; installing its content pack');
await page.getByRole('button', { name: 'Download and continue' }).click();
await page.locator('.dungeon-role-picker').waitFor();
console.log('Content installed; entering as damage');
await page.locator('.dungeon-role-option--damage').click();
await page.locator('.hud-minimap svg').waitFor();
await page.locator('.loading-screen').waitFor({ state: 'detached' });
await page.locator('.dungeon-boss-intro').waitFor({ state: 'detached' });
await page.locator('.hud-minimap svg image').waitFor();
await page.screenshot({ path: path.join(directory, 'forsaken-minimap.png') });
const minimapArt = await page.locator('.hud-minimap svg image').getAttribute('href');
assert.match(minimapArt, /forsaken-abbey\/maps\/runewaker-map\.png/);
const playerPosition = async () => {
const marker = page.locator('.hud-minimap .map-player');
return [Number(await marker.getAttribute('cx')), Number(await marker.getAttribute('cy'))];
};
const beforeMovement = await playerPosition();
await page.keyboard.down('s');
await page.waitForTimeout(1000);
await page.keyboard.up('s');
await page.waitForTimeout(250);
const afterMovement = await playerPosition();
assert.ok(Math.hypot(afterMovement[0] - beforeMovement[0], afterMovement[1] - beforeMovement[1]) > 0.1,
`Minimap marker did not move: ${beforeMovement} -> ${afterMovement}`);
await page.keyboard.press('m');
await page.locator('.map-panel svg image').waitFor();
await page.locator('.map-panel svg[data-artwork="ready"]').waitFor();
await page.screenshot({ path: path.join(directory, 'forsaken-full-map.png') });
assert.match(await page.locator('.map-panel svg image').getAttribute('href'), /forsaken-abbey\/maps\/runewaker-map\.png/);
const marker = page.locator('.map-panel .map-player');
assert.ok(Number.isFinite(Number(await marker.getAttribute('cx'))));
assert.ok(Number.isFinite(Number(await marker.getAttribute('cy'))));
await page.keyboard.press('m');
await page.locator('.map-panel').waitFor({ state: 'detached' });
assert.deepEqual(pageErrors, []);
console.log(`RuneWaker minimap and M-key screenshots: ${directory}`);
} finally {
await browser.close();
await new Promise((resolve) => runtime.server.close(resolve));
const resolved = path.resolve(runtimeData);
assert.ok(resolved.startsWith(path.resolve(os.tmpdir()) + path.sep));
fs.rmSync(resolved, { recursive: true, force: true });
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+245 -115
View File
@@ -555,13 +555,13 @@ def launch_gui(*, smoke_test: bool = False) -> int:
from tkinter import filedialog, messagebox, scrolledtext, ttk
class ReleaseManagerApp:
BG = "#0c1515"
PANEL = "#122222"
PANEL_2 = "#172b2a"
TEXT = "#e9f4ef"
MUTED = "#91aaa2"
ACCENT = "#63d9a4"
ACCENT_DARK = "#183b31"
BG = "#0b1220"
PANEL = "#172337"
PANEL_2 = "#22344d"
TEXT = "#f2f6fc"
MUTED = "#aabbd0"
ACCENT = "#63dfb5"
ACCENT_DARK = "#214a47"
WARNING = "#f2c66d"
ERROR = "#ff8b83"
@@ -580,63 +580,66 @@ def launch_gui(*, smoke_test: bool = False) -> int:
def _configure_window(self) -> None:
self.root.title("Healer Man Release Manager")
self.root.geometry("1120x820")
self.root.minsize(960, 700)
width = min(1100, max(700, self.root.winfo_screenwidth() - 60))
height = min(740, max(500, self.root.winfo_screenheight() - 100))
self.root.geometry(f"{width}x{height}")
self.root.minsize(700, 500)
self.root.configure(bg=self.BG)
style = ttk.Style(self.root)
try:
style.theme_use("clam")
except tk.TclError:
pass
style.configure(".", background=self.BG, foreground=self.TEXT)
style.configure(".", background=self.BG, foreground=self.TEXT, font=("Segoe UI", 10))
style.configure("TFrame", background=self.BG)
style.configure("Panel.TFrame", background=self.PANEL)
style.configure(
"Title.TLabel",
background=self.BG,
foreground=self.TEXT,
font=("Segoe UI Semibold", 20),
)
style.configure(
"Sub.TLabel",
background=self.BG,
foreground=self.MUTED,
font=("Segoe UI", 10),
)
style.configure(
"Panel.TLabel", background=self.PANEL, foreground=self.TEXT
)
style.configure(
"Muted.Panel.TLabel", background=self.PANEL, foreground=self.MUTED
)
style.configure(
"Section.Panel.TLabel",
background=self.PANEL,
foreground=self.TEXT,
font=("Segoe UI Semibold", 13),
)
style.configure(
"Accent.TButton",
background=self.ACCENT,
foreground="#06110d",
foreground="#082019",
font=("Segoe UI Semibold", 10),
padding=(13, 9),
padding=(16, 11),
)
style.map("Accent.TButton", background=[("active", "#84e7bb")])
style.configure("TButton", padding=(10, 7))
style.map("Accent.TButton", background=[("active", "#8af1cc"), ("disabled", self.PANEL_2)])
style.configure("TButton", background=self.PANEL_2, foreground=self.TEXT, padding=(12, 8))
style.map("TButton", background=[("active", "#314866")], foreground=[("disabled", self.MUTED)])
style.configure("Panel.TCheckbutton", background=self.PANEL, foreground=self.TEXT)
style.map("Panel.TCheckbutton", background=[("active", self.PANEL)])
style.configure("Panel.TRadiobutton", background=self.PANEL, foreground=self.TEXT)
style.map("Panel.TRadiobutton", background=[("active", self.PANEL)])
style.configure(
"TEntry",
fieldbackground="#0a1716",
fieldbackground="#101b2b",
foreground=self.TEXT,
insertcolor=self.TEXT,
bordercolor="#31504a",
bordercolor="#3b5068",
padding=6,
)
style.configure(
"TCombobox",
fieldbackground="#0a1716",
fieldbackground="#101b2b",
foreground=self.TEXT,
arrowcolor=self.TEXT,
)
style.configure("Horizontal.TProgressbar", background=self.ACCENT, troughcolor=self.PANEL_2)
style.configure("TNotebook", background=self.PANEL, borderwidth=0)
style.configure(
"TNotebook.Tab",
background=self.PANEL_2,
foreground=self.MUTED,
padding=(12, 7),
padding=(17, 9),
)
style.map(
"TNotebook.Tab",
@@ -645,33 +648,81 @@ def launch_gui(*, smoke_test: bool = False) -> int:
)
def _build_ui(self) -> None:
shell = ttk.Frame(self.root, padding=18)
shell = ttk.Frame(self.root, padding=10)
shell.pack(fill="both", expand=True)
shell.columnconfigure(0, weight=3)
shell.columnconfigure(1, weight=2)
shell.columnconfigure(0, weight=1)
shell.rowconfigure(2, weight=1)
title = ttk.Frame(shell)
title.grid(row=0, column=0, columnspan=2, sticky="ew", pady=(0, 14))
title.columnconfigure(0, weight=1)
ttk.Label(title, text="Healer Man Release Manager", style="Title.TLabel").grid(
row=0, column=0, sticky="w"
mode_panel = ttk.Frame(shell, style="Panel.TFrame", padding=(10, 6))
mode_panel.grid(row=0, column=0, sticky="ew", pady=(0, 8))
mode_panel.columnconfigure(3, weight=1)
ttk.Label(mode_panel, text="Mode", style="Muted.Panel.TLabel").grid(
row=0, column=0, sticky="w", padx=(0, 12)
)
ttk.Label(
title,
text="Validate → inspect → commit → push → deploy, with optional signed APK",
style="Sub.TLabel",
).grid(row=1, column=0, sticky="w", pady=(2, 0))
self.refresh_button = ttk.Button(title, text="Refresh", command=self.refresh)
self.refresh_button.grid(row=0, column=1, rowspan=2, sticky="e")
self.git_truenas_only_var = tk.BooleanVar(value=DEFAULT_GIT_TRUENAS_ONLY)
self.release_mode_toggle = ttk.Radiobutton(
mode_panel,
text="Git + TrueNAS",
variable=self.git_truenas_only_var,
value=True,
command=self._update_release_mode,
style="Panel.TRadiobutton",
)
self.release_mode_toggle.grid(row=0, column=1, sticky="w", padx=(0, 18))
self.apk_mode_toggle = ttk.Radiobutton(
mode_panel,
text="Include signed APK",
variable=self.git_truenas_only_var,
value=False,
command=self._update_release_mode,
style="Panel.TRadiobutton",
)
self.apk_mode_toggle.grid(row=0, column=2, sticky="w")
self.refresh_button = ttk.Button(mode_panel, text="Refresh", command=self.refresh)
self.refresh_button.grid(row=0, column=4, sticky="e")
self.git_summary_var = tk.StringVar(value="Loading Git status…")
release_panel = ttk.Frame(shell, style="Panel.TFrame", padding=15)
release_panel.grid(row=1, column=0, sticky="nsew", padx=(0, 8))
setup_host = ttk.Frame(shell)
setup_host.grid(row=1, column=0, sticky="nsew")
setup_host.columnconfigure(0, weight=1)
setup_host.rowconfigure(0, weight=1)
self.setup_canvas = tk.Canvas(
setup_host, bg=self.BG, highlightthickness=0, borderwidth=0, height=220
)
self.setup_canvas.grid(row=0, column=0, sticky="nsew")
setup_scrollbar = ttk.Scrollbar(
setup_host, orient="vertical", command=self.setup_canvas.yview
)
setup_scrollbar.grid(row=0, column=1, sticky="ns", padx=(4, 0))
self.setup_canvas.configure(yscrollcommand=setup_scrollbar.set)
self.setup_content = ttk.Frame(self.setup_canvas)
self.setup_content.columnconfigure(0, weight=3)
self.setup_content.columnconfigure(1, weight=2)
self.setup_window = self.setup_canvas.create_window(
(0, 0), window=self.setup_content, anchor="nw"
)
self.setup_canvas.bind(
"<Configure>",
lambda event: self.setup_canvas.itemconfigure(self.setup_window, width=event.width),
)
self.setup_content.bind(
"<Configure>",
lambda _event: self.setup_canvas.configure(
scrollregion=self.setup_canvas.bbox("all")
),
)
self.setup_canvas.bind(
"<MouseWheel>",
self._scroll_setup,
)
release_panel = ttk.Frame(self.setup_content, style="Panel.TFrame", padding=12)
release_panel.grid(row=0, column=0, sticky="nsew")
self.release_panel = release_panel
release_panel.columnconfigure(1, weight=1)
ttk.Label(
release_panel, text="Release details", style="Panel.TLabel",
font=("Segoe UI Semibold", 12),
).grid(row=0, column=0, columnspan=4, sticky="w", pady=(0, 11))
release_panel, text="Release details", style="Section.Panel.TLabel",
).grid(row=0, column=0, columnspan=4, sticky="w", pady=(0, 8))
self.current_version_var = tk.StringVar(value="—")
self.current_code_var = tk.StringVar(value="—")
@@ -679,11 +730,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
self.next_version_var = tk.StringVar()
self.next_code_var = tk.StringVar()
self.commit_message_var = tk.StringVar()
self.git_truenas_only_var = tk.BooleanVar(
value=DEFAULT_GIT_TRUENAS_ONLY
)
self.run_tests_var = tk.BooleanVar(value=DEFAULT_RUN_TESTS)
self.release_mode_hint_var = tk.StringVar()
self.test_mode_hint_var = tk.StringVar()
ttk.Label(release_panel, text="Current", style="Muted.Panel.TLabel").grid(
@@ -700,7 +747,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
)
ttk.Label(release_panel, text="Bump", style="Muted.Panel.TLabel").grid(
row=2, column=0, sticky="w", pady=(12, 0)
row=2, column=0, sticky="w", pady=(7, 0)
)
self.bump_box = ttk.Combobox(
release_panel,
@@ -709,7 +756,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
state="readonly",
width=10,
)
self.bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(12, 0))
self.bump_box.grid(row=2, column=1, sticky="w", padx=(8, 20), pady=(7, 0))
self.bump_box.bind("<<ComboboxSelected>>", lambda _event: self._suggest_versions())
ttk.Label(release_panel, text="Next version", style="Muted.Panel.TLabel").grid(
row=3, column=0, sticky="w", pady=(9, 0)
@@ -740,9 +787,9 @@ def launch_gui(*, smoke_test: bool = False) -> int:
)
self.release_notes = tk.Text(
release_panel,
height=4,
height=3,
wrap="word",
bg="#0a1716",
bg="#101b2b",
fg=self.TEXT,
insertbackground=self.TEXT,
relief="flat",
@@ -753,48 +800,40 @@ def launch_gui(*, smoke_test: bool = False) -> int:
self.release_notes.grid(
row=5, column=1, columnspan=3, sticky="nsew", padx=(8, 0), pady=(9, 0)
)
self.release_mode_toggle = ttk.Checkbutton(
release_panel,
text="Git + TrueNAS only (skip Android version bump and APK publication)",
variable=self.git_truenas_only_var,
command=self._update_release_mode,
)
self.release_mode_toggle.grid(
row=6, column=0, columnspan=4, sticky="w", pady=(12, 0)
)
ttk.Label(
release_panel,
textvariable=self.release_mode_hint_var,
style="Muted.Panel.TLabel",
wraplength=610,
).grid(row=7, column=0, columnspan=4, sticky="w", pady=(4, 0))
self.run_tests_toggle = ttk.Checkbutton(
release_panel,
text="Run test suites (recommended)",
text="Run all test suites before release",
variable=self.run_tests_var,
command=self._update_test_mode,
style="Panel.TCheckbutton",
)
self.run_tests_toggle.grid(
row=8, column=0, columnspan=4, sticky="w", pady=(10, 0)
row=6, column=0, columnspan=4, sticky="w", pady=(10, 0)
)
ttk.Label(
release_panel,
textvariable=self.test_mode_hint_var,
style="Muted.Panel.TLabel",
wraplength=610,
).grid(row=9, column=0, columnspan=4, sticky="w", pady=(4, 0))
wraplength=620,
).grid(row=7, column=0, columnspan=4, sticky="w", pady=(4, 0))
self.apk_detail_widgets = tuple(
widget
for row in (2, 3, 5)
for widget in release_panel.grid_slaves(row=row)
)
secrets_panel = ttk.Frame(shell, style="Panel.TFrame", padding=15)
secrets_panel.grid(row=1, column=1, sticky="nsew", padx=(8, 0))
secrets_panel = ttk.Frame(self.setup_content, style="Panel.TFrame", padding=12)
secrets_panel.grid(row=0, column=1, sticky="nsew", padx=(8, 0))
self.secrets_panel = secrets_panel
secrets_panel.columnconfigure(1, weight=1)
ttk.Label(
secrets_panel, text="Signing and Gitea (APK only)", style="Panel.TLabel",
font=("Segoe UI Semibold", 12),
secrets_panel, text="Signing and Gitea", style="Section.Panel.TLabel",
).grid(row=0, column=0, columnspan=3, sticky="w")
ttk.Label(
secrets_panel,
text="Signing password and saved token are Windows-encrypted outside Git.",
style="Muted.Panel.TLabel",
wraplength=260,
).grid(row=1, column=0, columnspan=3, sticky="w", pady=(2, 10))
self.keystore_var = tk.StringVar(value=str(DEFAULT_KEYSTORE_FILE))
@@ -829,12 +868,13 @@ def launch_gui(*, smoke_test: bool = False) -> int:
secrets_panel,
text="Remember Gitea token on this PC",
variable=self.remember_token_var,
style="Panel.TCheckbutton",
).grid(row=7, column=0, columnspan=2, sticky="w", pady=(7, 0))
ttk.Button(
secrets_panel,
text="Forget saved token",
command=self._forget_saved_token,
).grid(row=7, column=2, sticky="e", pady=(7, 0))
).grid(row=8, column=0, columnspan=3, sticky="w", pady=(5, 0))
ttk.Label(
secrets_panel,
text=(
@@ -842,16 +882,17 @@ def launch_gui(*, smoke_test: bool = False) -> int:
"enter the Gitea token once and it will load automatically."
),
style="Muted.Panel.TLabel",
wraplength=360,
).grid(row=8, column=0, columnspan=3, sticky="w", pady=(7, 0))
wraplength=260,
).grid(row=9, column=0, columnspan=3, sticky="w", pady=(7, 0))
lower = ttk.Panedwindow(shell, orient="horizontal")
lower.grid(row=2, column=0, columnspan=2, sticky="nsew", pady=(16, 0))
workspace = ttk.Notebook(shell)
workspace.grid(row=2, column=0, sticky="nsew", pady=(8, 0))
self.workspace_tabs = workspace
log_panel = ttk.Frame(lower, style="Panel.TFrame", padding=12)
commands_panel = ttk.Frame(lower, style="Panel.TFrame", padding=12)
lower.add(log_panel, weight=3)
lower.add(commands_panel, weight=2)
log_panel = ttk.Frame(workspace, style="Panel.TFrame", padding=14)
commands_panel = ttk.Frame(workspace, style="Panel.TFrame", padding=14)
workspace.add(log_panel, text="Activity")
workspace.add(commands_panel, text="TrueNAS commands")
log_panel.rowconfigure(2, weight=1)
log_panel.columnconfigure(0, weight=1)
commands_panel.rowconfigure(1, weight=1)
@@ -867,28 +908,27 @@ def launch_gui(*, smoke_test: bool = False) -> int:
).grid(row=0, column=0, sticky="w")
self.progress = ttk.Progressbar(status_row, mode="indeterminate", length=150)
self.progress.grid(row=0, column=1, sticky="e")
self.git_summary_var = tk.StringVar(value="Loading Git status…")
ttk.Label(
log_panel,
textvariable=self.git_summary_var,
style="Muted.Panel.TLabel",
wraplength=600,
wraplength=1060,
).grid(row=1, column=0, sticky="ew", pady=(4, 8))
self.log = scrolledtext.ScrolledText(
log_panel,
wrap="word",
bg="#071110",
fg="#cfe2dc",
bg="#101b2b",
fg=self.TEXT,
insertbackground=self.TEXT,
relief="flat",
font=("Cascadia Mono", 9),
font=("Cascadia Mono", 10),
state="disabled",
)
self.log.grid(row=2, column=0, sticky="nsew")
ttk.Label(
commands_panel,
text="TrueNAS shell commands",
text="Deploy from TrueNAS after the Git push",
style="Panel.TLabel",
font=("Segoe UI Semibold", 11),
).grid(row=0, column=0, sticky="w", pady=(0, 7))
@@ -908,7 +948,8 @@ def launch_gui(*, smoke_test: bool = False) -> int:
)
actions = ttk.Frame(shell)
actions.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(14, 0))
self.actions = actions
actions.grid(row=3, column=0, sticky="ew", pady=(8, 0))
actions.columnconfigure(0, weight=1)
self.release_button = ttk.Button(
actions,
@@ -927,25 +968,79 @@ def launch_gui(*, smoke_test: bool = False) -> int:
self.cancel_button.grid(row=0, column=2, padx=(7, 0))
self._update_release_mode()
self._update_test_mode()
self._bind_setup_scroll(self.setup_content)
self.root.bind("<Configure>", self._fit_setup_height)
self.root.after_idle(self._fit_setup_height)
def _bind_setup_scroll(self, widget: tk.Misc) -> None:
if not isinstance(widget, (tk.Text, ttk.Combobox)):
widget.bind("<MouseWheel>", self._scroll_setup, add="+")
for child in widget.winfo_children():
self._bind_setup_scroll(child)
def _scroll_setup(self, event: tk.Event[tk.Misc]) -> str:
if event.delta:
self.setup_canvas.yview_scroll(-1 if event.delta > 0 else 1, "units")
return "break"
def _fit_setup_height(self, event: tk.Event[tk.Misc] | None = None) -> None:
if event is not None and event.widget is not self.root:
return
self._layout_setup_panels()
available = max(140, int(self.root.winfo_height() * 0.43))
desired = min(self.setup_content.winfo_reqheight(), available)
if int(self.setup_canvas.cget("height")) != desired:
self.setup_canvas.configure(height=desired)
def _layout_setup_panels(self) -> None:
apk_mode = not self.git_truenas_only_var.get()
stacked = apk_mode and self.root.winfo_width() < 940
layout = (apk_mode, stacked)
if layout == getattr(self, "_setup_layout", None):
return
self._setup_layout = layout
if not apk_mode:
self.release_panel.grid_configure(
row=0, column=0, columnspan=2, padx=0, pady=0
)
self.secrets_panel.grid_remove()
elif stacked:
self.release_panel.grid_configure(
row=0, column=0, columnspan=2, padx=0, pady=0
)
self.secrets_panel.grid_configure(
row=1, column=0, columnspan=2, padx=0, pady=(8, 0)
)
else:
self.release_panel.grid_configure(
row=0, column=0, columnspan=1, padx=(0, 4), pady=0
)
self.secrets_panel.grid_configure(
row=0, column=1, columnspan=1, padx=(4, 0), pady=0
)
self.root.after_idle(self._fit_setup_height)
def _update_release_mode(self) -> None:
git_truenas_only = self.git_truenas_only_var.get()
if git_truenas_only:
self.retry_button.grid_remove()
for widget in self.apk_detail_widgets:
widget.grid_remove()
else:
self.retry_button.grid()
for widget in self.apk_detail_widgets:
widget.grid()
self._layout_setup_panels()
self.setup_canvas.yview_moveto(0)
self.root.after_idle(self._fit_setup_height)
self.bump_box.configure(state="disabled" if git_truenas_only else "readonly")
entry_state = "disabled" if git_truenas_only else "normal"
self.next_version_entry.configure(state=entry_state)
self.next_code_entry.configure(state=entry_state)
if git_truenas_only:
self.release_button.configure(text="Validate and Push Git + TrueNAS Update")
self.release_mode_hint_var.set(
"Default mode: keeps the current Android version, skips Android sync and APK "
"publication, and prepares the pinned TrueNAS deployment commands after push."
)
else:
self.release_button.configure(text="Validate, Push, and Publish APK")
self.release_mode_hint_var.set(
"APK mode: bumps the app and Android versions, runs Android sync, and publishes "
"the signed APK after the Git push."
)
self._suggest_versions()
current_notes = self.release_notes.get("1.0", "end").strip()
server_default = "Healer Man Git and TrueNAS server update."
@@ -980,15 +1075,18 @@ def launch_gui(*, smoke_test: bool = False) -> int:
frame.columnconfigure(0, weight=1)
widget = tk.Text(
frame,
wrap="none",
bg="#071110",
fg="#cfe2dc",
wrap="word",
bg="#101b2b",
fg=self.TEXT,
relief="flat",
padx=8,
pady=8,
font=("Cascadia Mono", 8),
padx=12,
pady=10,
font=("Cascadia Mono", 10),
)
widget.grid(row=0, column=0, sticky="nsew")
scrollbar = ttk.Scrollbar(frame, orient="vertical", command=widget.yview)
scrollbar.grid(row=0, column=1, sticky="ns")
widget.configure(yscrollcommand=scrollbar.set)
return widget
def _browse_keystore(self) -> None:
@@ -1265,11 +1363,13 @@ def launch_gui(*, smoke_test: bool = False) -> int:
self.busy = True
self.cancel_event.clear()
self.status_var.set(label)
self.workspace_tabs.select(0)
self.progress.start(12)
self.release_button.configure(state="disabled")
self.retry_button.configure(state="disabled")
self.refresh_button.configure(state="disabled")
self.release_mode_toggle.configure(state="disabled")
self.apk_mode_toggle.configure(state="disabled")
self.run_tests_toggle.configure(state="disabled")
self.cancel_button.configure(state="normal")
self._append_log(f"\n=== {label} ===")
@@ -1774,6 +1874,7 @@ def launch_gui(*, smoke_test: bool = False) -> int:
self.retry_button.configure(state="normal")
self.refresh_button.configure(state="normal")
self.release_mode_toggle.configure(state="normal")
self.apk_mode_toggle.configure(state="normal")
self.run_tests_toggle.configure(state="normal")
self.cancel_button.configure(state="disabled")
self.keystore_password_var.set("")
@@ -1781,21 +1882,24 @@ def launch_gui(*, smoke_test: bool = False) -> int:
if self.remember_token_var.get() and SAVED_GITEA_TOKEN_FILE.is_file():
self.token_var.set(read_saved_gitea_token())
if result == "success":
self.status_var.set("Workflow completed")
self._append_log("\n✓ Workflow completed successfully.")
if self.git_truenas_only_var.get():
self.workspace_tabs.select(1)
messagebox.showinfo(
"Workflow complete",
detail,
parent=self.root,
)
elif result == "cancelled":
self.status_var.set("Workflow cancelled")
self._append_log(f"\nCancelled: {detail}")
else:
self.status_var.set("Workflow failed")
self._append_log(f"\nERROR: {detail}")
messagebox.showerror("Workflow failed", detail, parent=self.root)
self.refresh()
self.status_var.set({
"success": "Workflow completed",
"cancelled": "Workflow cancelled",
}.get(result, "Workflow failed"))
def _close(self) -> None:
if self.busy:
@@ -1809,11 +1913,37 @@ def launch_gui(*, smoke_test: bool = False) -> int:
self.root.destroy()
root = tk.Tk()
ReleaseManagerApp(root)
app = ReleaseManagerApp(root)
if smoke_test:
root.withdraw()
root.update_idletasks()
root.update()
app.git_truenas_only_var.set(False)
app._update_release_mode()
root.update_idletasks()
assert app.secrets_panel.winfo_manager() == "grid"
assert app.release_panel.grid_info()["columnspan"] == 1
app.git_truenas_only_var.set(True)
app._update_release_mode()
root.update_idletasks()
assert app.release_panel.grid_info()["columnspan"] == 2
assert not app.secrets_panel.winfo_manager()
root.geometry("700x500")
root.deiconify()
root.update()
app.git_truenas_only_var.set(False)
app._update_release_mode()
root.update()
app._fit_setup_height()
assert app.secrets_panel.grid_info()["row"] == 1
assert app.release_panel.grid_info()["columnspan"] == 2
assert int(app.setup_canvas.cget("height")) <= 215
assert app.setup_canvas.yview()[1] < 1
app.setup_canvas.yview_moveto(1)
root.update_idletasks()
assert app.setup_canvas.yview()[0] > 0
assert app.release_button.winfo_manager() == "grid"
assert app.actions.winfo_y() + app.actions.winfo_height() <= root.winfo_height()
root.destroy()
return 0
root.mainloop()
+244
View File
@@ -0,0 +1,244 @@
"""Import client minimap tiles and calibrated navigation plans for RuneWaker dungeons.
Requires Pillow and NumPy. RUNEWAKER_ROOT may point at another extracted client.
"""
import hashlib
import json
import os
import re
import struct
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFilter
ROOT = Path(__file__).resolve().parents[2]
SOURCE = Path(os.environ.get("RUNEWAKER_ROOT", ROOT.parent / "Runewaker")) / "Resource" / "texture" / "minimap"
RECIPES = ROOT / "scripts/runewaker-pipeline/recipes"
OUTPUT = ROOT / "public/assets/game/dungeons"
CATALOG = ROOT / "src/game/generated/runewakerDungeonMaps.generated.json"
IMAGE_SIZE = (1002, 668)
SOURCE_PIXELS_PER_UNIT = 0.12
ALIASES = {
"ystra-labyrinth-boss1": "tiledgn_hybora_labyrinth_01",
"ystra-labyrinth-boss2": "tiledgn_hybora_labyrinth_02",
"ystra-labyrinth-boss3": "tiledgn_hybora_labyrinth_03",
}
def read_json(path):
return json.loads(path.read_text(encoding="utf-8"))
def runtime_visual_checksum(slug):
file = (ROOT / "src/game/dungeons/forsakenAbbey.ts" if slug == "forsaken-abbey"
else ROOT / "src/game/generated/runewakerDungeonDefinitions.generated.ts")
source = file.read_text(encoding="utf-8")
identifier = f'id: "{slug}"' if slug == "forsaken-abbey" else f'"id": "{slug}"'
start = source.find(identifier)
if start < 0:
raise ValueError(f"Missing runtime definition for {slug}")
match = re.search(r'\bvisual\b[\s\S]*?\bchecksum["\s:]*"([0-9a-f]{64})"', source[start:])
if not match:
raise ValueError(f"Missing runtime visual checksum for {slug}")
return match.group(1)
def navigation_mesh(slug):
data = (ROOT / f"src/assets/game/dungeons/{slug}/{slug}-navigation.glb").read_bytes()
json_length = struct.unpack_from("<I", data, 12)[0]
gltf = json.loads(data[20:20 + json_length])
binary_start = 20 + ((json_length + 3) & ~3) + 8
def accessor(index):
item = gltf["accessors"][index]
view = gltf["bufferViews"][item["bufferView"]]
kind = {5126: "<f4", 5125: "<u4", 5123: "<u2"}[item["componentType"]]
width = {"VEC3": 3, "SCALAR": 1}[item["type"]]
offset = binary_start + view.get("byteOffset", 0) + item.get("byteOffset", 0)
return np.frombuffer(data, dtype=kind, count=item["count"] * width, offset=offset).reshape(-1, width)
primitive = gltf["meshes"][0]["primitives"][0]
return (accessor(primitive["attributes"]["POSITION"]).astype(np.float64),
accessor(primitive["indices"]).reshape(-1, 3))
def placement_for(slug):
metadata = read_json(ROOT / f"src/assets/game/dungeons/{slug}/import-metadata.json")
source = metadata["source"]
return source.get("wdbReport", source.get("wdb", {}))["primaryPlacement"]
def source_points(local_vertices, placement):
points = local_vertices * np.array([10, 10, -10], dtype=np.float64)
quaternion = np.array(placement["rotation"], dtype=np.float64)
cross = np.cross(quaternion[:3], points)
points = points + 2 * (quaternion[3] * cross + np.cross(quaternion[:3], cross))
return points * np.array(placement["scale"]) + np.array(placement["translation"])
def client_tiles(name):
directory = SOURCE / name / "0"
tiles = {}
for file in directory.glob("minimap_*.dds"):
match = re.fullmatch(r"minimap_(\d+)_(\d+)\.dds", file.name)
if match:
tiles[tuple(map(int, match.groups()))] = file
if not tiles:
return None
size = ((max(x for x, _ in tiles) + 1) * 512, (max(y for _, y in tiles) + 1) * 512)
image = Image.new("RGBA", size)
for (x, y), file in sorted(tiles.items()):
image.paste(Image.open(file).convert("RGBA"), (x * 512, y * 512))
return image, sorted(tiles.values())
def artwork_mask(image, size):
reduced = image.resize(size, Image.Resampling.BOX)
rgb = np.asarray(reduced.convert("RGB")).astype(np.int16)
background = np.asarray(image.convert("RGB").getpixel((0, 0)), dtype=np.int16)
opaque = np.asarray(reduced.getchannel("A")) > 8
visible = (np.max(np.abs(rgb - background), axis=2) > 18) & opaque
return np.asarray(Image.fromarray(visible.astype("uint8") * 255).filter(ImageFilter.MaxFilter(5))) > 8
def navmesh_mask(points, faces, size):
image = Image.new("L", size)
draw = ImageDraw.Draw(image)
for face in faces:
draw.polygon([tuple(points[index]) for index in face], fill=255)
return np.asarray(image) > 0
def calibrate(image, source_vertices, faces):
factor = min(1, 512 / max(image.size))
size = (round(image.width * factor), round(image.height * factor))
art = artwork_mask(image, size)
oriented = source_vertices[:, [0, 2]] * np.array([1, -1])
minimum = oriented.min(axis=0)
points = (oriented - minimum) * SOURCE_PIXELS_PER_UNIT * factor
if points[:, 0].max() >= size[0] or points[:, 1].max() >= size[1]:
return None
floor = navmesh_mask(points, faces, size)
if floor.sum() < 20 or art.sum() < 20:
return None
correlation = np.fft.irfft2(
np.fft.rfft2(art.astype(np.float32)) * np.conj(np.fft.rfft2(floor.astype(np.float32))),
s=art.shape,
)
valid = correlation[:size[1] - int(points[:, 1].max()), :size[0] - int(points[:, 0].max())]
row, column = np.unravel_index(np.argmax(valid), valid.shape)
confidence = float(valid[row, column]) / float(floor.sum())
return minimum, np.array([column / factor, row / factor]), confidence
def original_map(image, source_vertices, faces, calibrated):
minimum, shift, confidence = calibrated
points = ((source_vertices[:, [0, 2]] * np.array([1, -1]) - minimum)
* SOURCE_PIXELS_PER_UNIT + shift)
left = max(0, int(np.floor(points[:, 0].min() - 60)))
top = max(0, int(np.floor(points[:, 1].min() - 60)))
right = min(image.width, int(np.ceil(points[:, 0].max() + 60)))
bottom = min(image.height, int(np.ceil(points[:, 1].max() + 60)))
if right <= left or bottom <= top:
raise ValueError("Calibrated minimap crop is empty")
cropped = image.crop((left, top, right, bottom))
scale = min(IMAGE_SIZE[0] / cropped.width, IMAGE_SIZE[1] / cropped.height)
resized = cropped.resize((round(cropped.width * scale), round(cropped.height * scale)), Image.Resampling.LANCZOS)
output = Image.new("RGBA", IMAGE_SIZE, (10, 14, 17, 255))
origin = ((IMAGE_SIZE[0] - resized.width) // 2, (IMAGE_SIZE[1] - resized.height) // 2)
output.alpha_composite(resized, origin)
def projected(source):
point = (source[[0, 2]] * np.array([1, -1]) - minimum) * SOURCE_PIXELS_PER_UNIT + shift
return np.array(origin) + (point - np.array([left, top])) * scale
return output, projected, confidence
def navigation_plan(vertices, faces):
horizontal = vertices[:, [0, 2]] * np.array([1, -1])
minimum = horizontal.min(axis=0)
maximum = horizontal.max(axis=0)
span = np.maximum(maximum - minimum, 1)
scale = min((IMAGE_SIZE[0] - 80) / span[0], (IMAGE_SIZE[1] - 80) / span[1])
origin = np.array([(IMAGE_SIZE[0] - span[0] * scale) / 2,
(IMAGE_SIZE[1] - span[1] * scale) / 2])
points = origin + (horizontal - minimum) * scale
image = Image.new("RGBA", IMAGE_SIZE, (10, 14, 17, 255))
draw = ImageDraw.Draw(image)
for face in faces:
draw.polygon([tuple(points[index]) for index in face], fill=(43, 68, 76, 255))
def projected(runtime):
return origin + (runtime[[0, 2]] * np.array([1, -1]) - minimum) * scale
return image, projected
def affine(projected, placement=None):
def runtime_to_source(runtime):
return source_points(np.array([runtime], dtype=np.float64), placement)[0]
base = projected(runtime_to_source([0, 0, 0])) if placement else projected(np.array([0, 0, 0]))
axes = []
for axis in range(3):
unit = np.zeros(3)
unit[axis] = 1
point = projected(runtime_to_source(unit)) if placement else projected(unit)
axes.append(point - base)
return [round(float(value), 8) for row in range(2) for value in
(axes[0][row], axes[1][row], axes[2][row], base[row])]
def main():
maps = {}
summary = []
for recipe_file in sorted(RECIPES.glob("*.json")):
if recipe_file.name.endswith("-population.json") or recipe_file.stem == "runewaker-player-avatars":
continue
recipe = read_json(recipe_file)
slug = recipe["slug"]
vertices, faces = navigation_mesh(slug)
placement = placement_for(slug)
source_vertices = source_points(vertices, placement)
map_name = ALIASES.get(slug, Path(recipe["source"]["wdb"]).stem)
tiles = client_tiles(map_name)
calibrated = calibrate(tiles[0], source_vertices, faces) if tiles else None
if calibrated and calibrated[2] >= 0.65:
image, projected, confidence = original_map(tiles[0], source_vertices, faces, calibrated)
matrix = affine(projected, placement)
kind = "client"
source_hash = hashlib.sha256(b"".join(file.read_bytes() for file in tiles[1])).hexdigest()
else:
image, projected = navigation_plan(vertices, faces)
matrix = affine(projected)
kind = "navigation"
confidence = calibrated[2] if calibrated else 0
source_hash = hashlib.sha256((ROOT / f"src/assets/game/dungeons/{slug}/{slug}-navigation.glb").read_bytes()).hexdigest()
destination = OUTPUT / slug / "maps" / "runewaker-map.png"
destination.parent.mkdir(parents=True, exist_ok=True)
image.save(destination, optimize=True)
metadata = read_json(ROOT / f"src/assets/game/dungeons/{slug}/import-metadata.json")
maps[slug] = {
"mapId": read_json(next((ROOT / "scripts/runewaker-pipeline/snapshots").glob(f"{slug}-zone-*.json")))["zoneId"],
"coordinateTransform": "threeLocal=(inverse(WDB placement)*server)*(0.1,0.1,-0.1)",
"visualChecksum": runtime_visual_checksum(slug),
"kind": kind,
"image": f"/assets/game/dungeons/{slug}/maps/runewaker-map.png",
"checksum": hashlib.sha256(destination.read_bytes()).hexdigest(),
"sourceHash": source_hash,
"runtimeToMap": matrix,
"width": IMAGE_SIZE[0],
"height": IMAGE_SIZE[1],
}
summary.append((slug, kind, round(confidence, 3), metadata["status"]))
CATALOG.write_text(json.dumps({"schemaVersion": 1, "dungeons": maps}, indent=2) + "\n", encoding="utf-8")
for entry in summary:
print(*entry)
if __name__ == "__main__":
main()