forked from phenom/healer-man
Update Healer Man server
This commit is contained in:
1 parent
9c5fb4c614
commit
bd073fb00f
113 files changed
+4902
-273
No files matched your search
@@ -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; });
|
||||
@@ -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; });
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user