Compare commits

..

15 Commits

@ -0,0 +1,38 @@
---
description: PixelLab MCP — генерация спрайтов для AutoHero, сохранение в репо и ключи манифеста
globs:
- frontend/assets/**
- frontend/public/assets/game/**
- frontend/src/game/assets/**
- docs/art-bible.md
- docs/pixellab-mcp-schema.md
---
# PixelLab AI MCP — пайплайн спрайтов
## Когда использовать
При добавлении или замене **растровой** графики сцены (тайлы, пропы, персонажи, здания как спрайты), если задача — сгенерировать PNG через подключённый **PixelLab** MCP в Cursor.
Креативная дисциплина и префикс промптов: [docs/art-bible.md](docs/art-bible.md) §1 и §8. Сверка имён инструментов и параметров: [docs/pixellab-mcp-schema.md](docs/pixellab-mcp-schema.md) и живые [PixelLab MCP docs](https://api.pixellab.ai/mcp/docs).
## Обязательное правило после генерации
1. **Сразу после успешного `get_*` (статус completed)** сохранить изображение в репозиторий под стабильным именем, согласованным с манифестом:
- каталог: **`frontend/assets/`** с подпапками по типу: `tiles/` (тайлы `terrain.*.vN.png`), `prop/`, `building/`, `enemies/`, `characters/`;
- имя файла: совпадает с контент-ключом или префиксом ключа (`terrain.grass.v0.png`, `prop.tree.v1.png`, `enemy.wolf_forest.png`, …).
2. **`frontend/public/assets/game/manifest.json`** — поле `file` для каждой текстуры **относительно `frontend/assets/`** (например `tiles/terrain.grass.v0.png`). Обновить манифест в том же шаге. Не оставлять ассет только по временной ссылке MCP.
3. Для **асинхронных** задач: вызвать `create_*`, затем периодически `get_*` по `tile_id` / `object_id` / `character_id`, пока не `completed`.
## Маппинг задач на инструменты (кратко)
- Изометрический тайл / куб / пол: `create_isometric_tile` → `get_isometric_tile` (в ответе может быть base64 PNG).
- Проп или высокий объект с прозрачным фоном: `create_map_object` → `get_map_object`.
- Герой / NPC / враг (пиксель-пайплайн): `create_character` → при необходимости `animate_character` → `get_character` (ZIP/URL — выгрузить кадры в репо).
- Сетки Wang / платформер: `create_topdown_tileset` / `create_sidescroller_tileset` — только если осознанно нужны для контента.
Имена в UI Cursor могут быть с префиксом сервера (`mcp_*_create_character` и т.д.) — вызывать те же семантические инструменты.
## Безопасность
Не вставлять **API token** PixelLab в файлы репозитория. Токен только в локальной конфигурации MCP пользователя.

1
.gitignore vendored

@ -9,6 +9,7 @@ backend/vendor/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
frontend/.vite/ frontend/.vite/
frontend/scripts/.enemy-south-pixellab-state.json
# IDE # IDE
.idea/ .idea/

@ -1,8 +1,23 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY admin-web/town-editor/package.json admin-web/town-editor/
RUN cd admin-web/town-editor && npm install
COPY admin-web/town-editor admin-web/town-editor
COPY frontend/src frontend/src
COPY frontend/assets frontend/assets
COPY frontend/public frontend/public
RUN cd admin-web/town-editor && npm run build
FROM nginx:alpine FROM nginx:alpine
RUN rm /etc/nginx/conf.d/default.conf RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY admin-web/nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html COPY admin-web/index.html /usr/share/nginx/html/index.html
COPY --from=builder /app/admin-web/town-editor/dist /usr/share/nginx/html/town-editor
EXPOSE 80 EXPOSE 80

@ -2718,6 +2718,7 @@
<h3>Towns &amp; NPCs (world)</h3> <h3>Towns &amp; NPCs (world)</h3>
<p class="muted">Browse towns, NPCs, and which templates an NPC offers. To accept a quest for a hero, use Heroes → Give quest from world.</p> <p class="muted">Browse towns, NPCs, and which templates an NPC offers. To accept a quest for a hero, use Heroes → Give quest from world.</p>
<button class="btn" onclick="withAction(loadQuestTowns)">Load towns</button> <button class="btn" onclick="withAction(loadQuestTowns)">Load towns</button>
<a class="btn" href="/town-editor/" target="_blank" rel="noopener">Open town editor</a>
</div> </div>
<div class="row-2"> <div class="row-2">
<div class="card"><h4>Towns</h4><div class="list">${towns || `<div class="list-row"><span class="muted">No towns loaded</span><span></span><span></span><span></span></div>`}</div>${pagerHtml("towns", townsPage.page, townsPage.total)}</div> <div class="card"><h4>Towns</h4><div class="list">${towns || `<div class="list-row"><span class="muted">No towns loaded</span><span></span><span></span><span></span></div>`}</div>${pagerHtml("towns", townsPage.page, townsPage.total)}</div>

@ -2,10 +2,16 @@ server {
listen 80; listen 80;
server_name _; server_name _;
location /town-editor/ {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /town-editor/index.html;
}
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
try_files $uri /index.html; try_files $uri $uri/ /index.html;
} }
location /admin-api/ { location /admin-api/ {

@ -0,0 +1,38 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoHero Town Editor</title>
</head>
<body>
<div id="app">
<aside class="sidebar">
<div class="card">
<h3>Auth</h3>
<input id="auth-user" placeholder="Username" />
<input id="auth-pass" type="password" placeholder="Password" />
<button id="auth-save" class="btn">Save credentials</button>
<div id="auth-status" class="muted"></div>
</div>
<div class="card">
<h3>Town</h3>
<button id="towns-reload" class="btn">Load towns</button>
<select id="town-select"></select>
</div>
<div class="card">
<h3>Layout</h3>
<button id="layout-reload" class="btn">Reload layout</button>
<button id="layout-save" class="btn" disabled>Save</button>
<div id="selection-info" class="muted"></div>
<div id="layout-status" class="muted"></div>
</div>
</aside>
<main class="canvas-area">
<div id="canvas-root" class="canvas-root"></div>
<div id="context-menu" class="context-menu hidden"></div>
</main>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

@ -0,0 +1,17 @@
{
"name": "autohero-admin-town-editor",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"pixi.js": "^8.6.6"
},
"devDependencies": {
"typescript": "~5.7.2",
"vite": "^6.2.0"
}
}

@ -0,0 +1,879 @@
import './style.css';
import { Camera } from '@game/camera';
import { GameRenderer, screenToWorld, worldToScreen } from '@game/renderer';
import { buildWorldTerrainContext, townsApiToInfluences } from '@game/procedural';
import type { BuildingData, NPCData, TownData, TownObjectData } from '@game/types';
type AdminTown = {
id: number;
name: string;
nameKey?: string;
biome: string;
worldX: number;
worldY: number;
radius: number;
levelMin: number;
levelMax: number;
};
type AdminNPC = {
id: number;
townId: number;
name: string;
nameKey?: string;
type: NPCData['type'];
offsetX: number;
offsetY: number;
buildingId?: number | null;
};
type AdminBuilding = {
id: number;
townId: number;
buildingType: string;
offsetX: number;
offsetY: number;
facing: BuildingData['facing'];
footprintW: number;
footprintH: number;
};
type AdminTownObject = {
id: number;
townId: number;
objectType: string;
variant: number;
offsetX: number;
offsetY: number;
};
type TownLayoutResponse = {
town: AdminTown;
npcs: AdminNPC[];
buildings: AdminBuilding[];
objects: AdminTownObject[];
};
type DraftNPC = AdminNPC;
type DraftBuilding = AdminBuilding;
type DraftObject = AdminTownObject;
type Selection =
| { type: 'npc'; id: number }
| { type: 'building'; id: number }
| { type: 'object'; id: number }
| { type: 'none' };
type DragState =
| {
type: 'npc';
id: number;
startOffsetX: number;
startOffsetY: number;
}
| {
type: 'building';
id: number;
startOffsetX: number;
startOffsetY: number;
linkedNpcOffsets: Map<number, { offsetX: number; offsetY: number }>;
}
| {
type: 'object';
id: number;
startOffsetX: number;
startOffsetY: number;
}
| null;
type PanState = {
startClientX: number;
startClientY: number;
startCameraX: number;
startCameraY: number;
} | null;
const state = {
auth: {
username: sessionStorage.getItem('admin_user') || '',
password: sessionStorage.getItem('admin_pass') || '',
},
towns: [] as AdminTown[],
selectedTownId: null as number | null,
townData: null as TownData | null,
npcData: [] as NPCData[],
draftTown: null as AdminTown | null,
draftNPCs: [] as DraftNPC[],
draftBuildings: [] as DraftBuilding[],
draftObjects: [] as DraftObject[],
deletedNPCIds: [] as number[],
deletedBuildingIds: [] as number[],
deletedObjectIds: [] as number[],
dirty: false,
tempBuildingId: -1,
tempNpcId: -1,
tempObjectId: -1,
selection: { type: 'none' } as Selection,
drag: null as DragState,
pan: null as PanState,
contextWorld: null as { x: number; y: number } | null,
};
const renderer = new GameRenderer();
const camera = new Camera();
const authUserInput = document.getElementById('auth-user') as HTMLInputElement;
const authPassInput = document.getElementById('auth-pass') as HTMLInputElement;
const authSaveBtn = document.getElementById('auth-save') as HTMLButtonElement;
const authStatus = document.getElementById('auth-status') as HTMLDivElement;
const townsReloadBtn = document.getElementById('towns-reload') as HTMLButtonElement;
const townSelect = document.getElementById('town-select') as HTMLSelectElement;
const layoutReloadBtn = document.getElementById('layout-reload') as HTMLButtonElement;
const layoutSaveBtn = document.getElementById('layout-save') as HTMLButtonElement;
const selectionInfo = document.getElementById('selection-info') as HTMLDivElement;
const layoutStatus = document.getElementById('layout-status') as HTMLDivElement;
const canvasRoot = document.getElementById('canvas-root') as HTMLDivElement;
const contextMenu = document.getElementById('context-menu') as HTMLDivElement;
authUserInput.value = state.auth.username;
authPassInput.value = state.auth.password;
selectionInfo.textContent = 'No selection.';
function setStatus(el: HTMLElement, message: string, isError = false): void {
el.textContent = message;
el.style.color = isError ? '#ff8f8f' : '';
}
function setDirty(value: boolean): void {
state.dirty = value;
layoutSaveBtn.disabled = !value;
}
function setSelection(next: Selection): void {
state.selection = next;
if (next.type === 'none') {
selectionInfo.textContent = 'No selection.';
return;
}
selectionInfo.textContent = `${next.type.toUpperCase()} #${next.id}`;
}
function saveAuth(): void {
state.auth.username = authUserInput.value.trim();
state.auth.password = authPassInput.value.trim();
sessionStorage.setItem('admin_user', state.auth.username);
sessionStorage.setItem('admin_pass', state.auth.password);
setStatus(authStatus, 'Credentials saved.');
}
function authHeader(): string {
return `Basic ${btoa(`${state.auth.username}:${state.auth.password}`)}`;
}
async function api(path: string, opts: RequestInit = {}): Promise<Response> {
const headers = Object.assign(
{ Authorization: authHeader(), 'Content-Type': 'application/json' },
opts.headers || {},
);
return fetch(`/admin-api/${path}`, { cache: 'no-store', ...opts, headers });
}
function townSize(radius: number): string {
if (radius > 40) return 'XL';
if (radius > 25) return 'M';
if (radius > 15) return 'S';
return 'XS';
}
function buildTownData(town: AdminTown, npcs: AdminNPC[], buildings: AdminBuilding[]): TownData {
const npcData: NPCData[] = npcs.map((n) => ({
id: n.id,
name: n.nameKey || n.name,
nameKey: n.nameKey,
type: n.type,
worldX: town.worldX + n.offsetX,
worldY: town.worldY + n.offsetY,
buildingId: n.buildingId ?? undefined,
townId: town.id,
townLevelMin: town.levelMin,
townLevelMax: town.levelMax,
}));
const buildingData: BuildingData[] = buildings.map((b) => ({
id: b.id,
buildingType: b.buildingType,
worldX: town.worldX + b.offsetX,
worldY: town.worldY + b.offsetY,
facing: b.facing,
footprintW: b.footprintW,
footprintH: b.footprintH,
}));
const objectData: TownObjectData[] = state.draftObjects.map((o) => ({
id: o.id,
objectType: o.objectType,
variant: o.variant,
worldX: town.worldX + o.offsetX,
worldY: town.worldY + o.offsetY,
}));
return {
id: town.id,
name: town.nameKey || town.name,
nameKey: town.nameKey,
centerX: town.worldX,
centerY: town.worldY,
radius: town.radius,
biome: town.biome,
levelMin: town.levelMin,
size: townSize(town.radius),
npcs: npcData,
buildings: buildingData,
objects: objectData,
};
}
function refreshTownData(): void {
if (!state.draftTown) return;
state.townData = buildTownData(state.draftTown, state.draftNPCs, state.draftBuildings);
state.npcData = state.townData.npcs || [];
}
function applyLayout(data: TownLayoutResponse): void {
state.draftTown = data.town;
state.draftNPCs = data.npcs.map((n) => ({ ...n }));
state.draftBuildings = data.buildings.map((b) => ({ ...b }));
state.draftObjects = (data.objects || []).map((o) => ({ ...o }));
state.deletedNPCIds = [];
state.deletedBuildingIds = [];
state.deletedObjectIds = [];
refreshTownData();
if (state.townData) {
const center = worldToScreen(state.townData.centerX, state.townData.centerY);
camera.setTarget(center.x, center.y);
camera.snapToTarget();
}
setSelection({ type: 'none' });
setDirty(false);
}
async function loadTowns(): Promise<void> {
if (!state.auth.username || !state.auth.password) {
setStatus(authStatus, 'Set admin credentials first.', true);
return;
}
setStatus(layoutStatus, 'Loading towns...');
const res = await api('quests/towns');
if (!res.ok) {
setStatus(layoutStatus, `Failed to load towns (${res.status}).`, true);
return;
}
const data = await res.json();
const towns = (data.towns || []) as AdminTown[];
state.towns = towns;
townSelect.innerHTML = '';
for (const t of towns) {
const opt = document.createElement('option');
opt.value = String(t.id);
opt.textContent = `${t.name} (Lv ${t.levelMin}-${t.levelMax})`;
townSelect.appendChild(opt);
}
if (!state.selectedTownId && towns.length > 0) {
state.selectedTownId = towns[0].id;
}
if (state.selectedTownId) {
townSelect.value = String(state.selectedTownId);
}
const influences = townsApiToInfluences(towns);
renderer.setWorldTerrainContext(buildWorldTerrainContext(influences, null));
setStatus(layoutStatus, 'Towns loaded.');
if (state.selectedTownId) {
await loadLayout(state.selectedTownId);
}
}
async function loadLayout(townId: number): Promise<void> {
setStatus(layoutStatus, 'Loading town layout...');
const res = await api(`quests/towns/${townId}/layout`);
if (!res.ok) {
setStatus(layoutStatus, `Failed to load layout (${res.status}).`, true);
return;
}
const data = (await res.json()) as TownLayoutResponse;
applyLayout(data);
setStatus(layoutStatus, 'Layout loaded.');
}
async function saveLayout(): Promise<void> {
if (!state.draftTown) return;
setStatus(layoutStatus, 'Saving layout...');
layoutSaveBtn.disabled = true;
const payload = {
npcs: state.draftNPCs.map((n) => ({
...(n.id > 0 ? { id: n.id } : {}),
name: n.name,
nameKey: n.nameKey || '',
type: n.type,
offsetX: n.offsetX,
offsetY: n.offsetY,
...(n.buildingId ? { buildingId: n.buildingId } : {}),
})),
buildings: state.draftBuildings.map((b) => ({
...(b.id > 0 ? { id: b.id } : {}),
buildingType: b.buildingType,
offsetX: b.offsetX,
offsetY: b.offsetY,
facing: b.facing,
footprintW: b.footprintW,
footprintH: b.footprintH,
})),
objects: state.draftObjects.map((o) => ({
...(o.id > 0 ? { id: o.id } : {}),
objectType: o.objectType,
variant: o.variant,
offsetX: o.offsetX,
offsetY: o.offsetY,
})),
deleteNpcIds: state.deletedNPCIds,
deleteBuildingIds: state.deletedBuildingIds,
deleteObjectIds: state.deletedObjectIds,
};
const res = await api(`quests/towns/${state.draftTown.id}/layout`, {
method: 'PUT',
body: JSON.stringify(payload),
});
if (!res.ok) {
setStatus(layoutStatus, `Failed to save layout (${res.status}).`, true);
layoutSaveBtn.disabled = false;
return;
}
const data = (await res.json()) as TownLayoutResponse;
applyLayout(data);
setStatus(layoutStatus, 'Layout saved.');
}
const buildingMenuItems = [
{ label: 'House: Quest Giver', type: 'house.quest_giver', w: 2.5, h: 2.0 },
{ label: 'House: Merchant', type: 'house.merchant', w: 2.5, h: 2.0 },
{ label: 'House: Armorer', type: 'house.armorer', w: 2.5, h: 2.0 },
{ label: 'House: Weapon Smith', type: 'house.weapon_smith', w: 2.5, h: 2.0 },
{ label: 'House: Jeweler', type: 'house.jeweler', w: 2.5, h: 2.0 },
{ label: 'House: Bounty Hunter', type: 'house.bounty_hunter', w: 2.5, h: 2.0 },
{ label: 'House: Elder', type: 'house.elder', w: 2.5, h: 2.0 },
{ label: 'House: Healer', type: 'house.healer', w: 2.5, h: 2.0 },
{ label: 'Decoration: Well', type: 'decoration.well', w: 1.5, h: 1.5 },
{ label: 'Decoration: Stall', type: 'decoration.stall', w: 1.5, h: 1.5 },
{ label: 'Decoration: Signpost', type: 'decoration.signpost', w: 0.5, h: 0.5 },
];
const npcTypeItems = [
{ label: 'Quest Giver', type: 'quest_giver' as NPCData['type'] },
{ label: 'Merchant', type: 'merchant' as NPCData['type'] },
{ label: 'Armorer', type: 'armorer' as NPCData['type'] },
{ label: 'Weapon', type: 'weapon' as NPCData['type'] },
{ label: 'Jeweler', type: 'jeweler' as NPCData['type'] },
{ label: 'Bounty Hunter', type: 'bounty_hunter' as NPCData['type'] },
{ label: 'Elder', type: 'elder' as NPCData['type'] },
{ label: 'Healer', type: 'healer' as NPCData['type'] },
];
const objectTypeItems = [
{ label: 'Tree', type: 'tree' },
{ label: 'Rock', type: 'rock' },
{ label: 'Cart', type: 'cart' },
{ label: 'Barrel', type: 'barrel' },
{ label: 'Bush', type: 'bush' },
{ label: 'Mushroom', type: 'mushroom' },
{ label: 'Leaves', type: 'leaves' },
{ label: 'Stump', type: 'stump' },
{ label: 'Bones', type: 'bones' },
{ label: 'Ruin', type: 'ruin' },
];
function hideContextMenu(): void {
contextMenu.classList.add('hidden');
}
function addContextHeader(label: string): void {
const header = document.createElement('div');
header.textContent = label;
header.style.padding = '6px 12px 4px 12px';
header.style.fontSize = '11px';
header.style.color = '#9eb0d6';
contextMenu.appendChild(header);
}
function addContextButton(label: string, onClick: () => void): void {
const btn = document.createElement('button');
btn.textContent = label;
btn.addEventListener('click', onClick);
contextMenu.appendChild(btn);
}
function addContextSubmenu(label: string, build: (submenu: HTMLDivElement) => void): void {
const wrapper = document.createElement('div');
wrapper.className = 'context-menu-item';
const button = document.createElement('button');
button.textContent = `${label}`;
const submenu = document.createElement('div');
submenu.className = 'submenu';
build(submenu);
wrapper.appendChild(button);
wrapper.appendChild(submenu);
contextMenu.appendChild(wrapper);
}
function showContextMenu(clientX: number, clientY: number, render: () => void): void {
contextMenu.innerHTML = '';
render();
const area = canvasRoot.parentElement?.getBoundingClientRect();
if (!area) return;
contextMenu.style.left = `${clientX - area.left}px`;
contextMenu.style.top = `${clientY - area.top}px`;
contextMenu.classList.remove('hidden');
}
function removeNpcById(id: number): void {
state.draftNPCs = state.draftNPCs.filter((n) => n.id !== id);
if (id > 0) state.deletedNPCIds.push(id);
setSelection({ type: 'none' });
refreshTownData();
setDirty(true);
}
function removeBuildingById(id: number): void {
state.draftBuildings = state.draftBuildings.filter((b) => b.id !== id);
if (id > 0) state.deletedBuildingIds.push(id);
setSelection({ type: 'none' });
refreshTownData();
setDirty(true);
}
function removeObjectById(id: number): void {
state.draftObjects = state.draftObjects.filter((o) => o.id !== id);
if (id > 0) state.deletedObjectIds.push(id);
setSelection({ type: 'none' });
refreshTownData();
setDirty(true);
}
function showContextMenuForSelection(clientX: number, clientY: number, selection: Selection): void {
showContextMenu(clientX, clientY, () => {
if (!state.draftTown) return;
if (selection.type === 'npc') {
addContextHeader('NPC');
addContextSubmenu('Change type', (submenu) => {
for (const item of npcTypeItems) {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.addEventListener('click', () => {
const npc = state.draftNPCs.find((n) => n.id === selection.id);
if (!npc) return;
npc.type = item.type;
if (!npc.name) npc.name = `New ${item.label}`;
setDirty(true);
refreshTownData();
hideContextMenu();
});
submenu.appendChild(btn);
}
});
addContextHeader('Actions');
addContextButton('Delete NPC', () => {
removeNpcById(selection.id);
hideContextMenu();
});
return;
}
if (selection.type === 'building') {
addContextHeader('Building');
addContextSubmenu('Change type', (submenu) => {
for (const item of buildingMenuItems) {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.addEventListener('click', () => {
const building = state.draftBuildings.find((b) => b.id === selection.id);
if (!building) return;
building.buildingType = item.type;
building.footprintW = item.w;
building.footprintH = item.h;
setDirty(true);
refreshTownData();
hideContextMenu();
});
submenu.appendChild(btn);
}
});
addContextHeader('Actions');
addContextButton('Delete building', () => {
removeBuildingById(selection.id);
hideContextMenu();
});
return;
}
if (selection.type === 'object') {
addContextHeader('Object');
addContextSubmenu('Change type', (submenu) => {
for (const item of objectTypeItems) {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.addEventListener('click', () => {
const obj = state.draftObjects.find((o) => o.id === selection.id);
if (!obj) return;
obj.objectType = item.type;
setDirty(true);
refreshTownData();
hideContextMenu();
});
submenu.appendChild(btn);
}
});
addContextSubmenu('Variant', (submenu) => {
const btn0 = document.createElement('button');
btn0.textContent = 'Variant v0';
btn0.addEventListener('click', () => {
const obj = state.draftObjects.find((o) => o.id === selection.id);
if (!obj) return;
obj.variant = 0;
setDirty(true);
refreshTownData();
hideContextMenu();
});
const btn1 = document.createElement('button');
btn1.textContent = 'Variant v1';
btn1.addEventListener('click', () => {
const obj = state.draftObjects.find((o) => o.id === selection.id);
if (!obj) return;
obj.variant = 1;
setDirty(true);
refreshTownData();
hideContextMenu();
});
submenu.appendChild(btn0);
submenu.appendChild(btn1);
});
addContextHeader('Actions');
addContextButton('Delete object', () => {
removeObjectById(selection.id);
hideContextMenu();
});
return;
}
});
}
function showContextMenuForEmpty(clientX: number, clientY: number): void {
showContextMenu(clientX, clientY, () => {
if (!state.draftTown || !state.contextWorld) return;
const world = state.contextWorld;
addContextSubmenu('Add NPC', (submenu) => {
for (const item of npcTypeItems) {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.addEventListener('click', () => {
const offsetX = world.x - state.draftTown!.worldX;
const offsetY = world.y - state.draftTown!.worldY;
const newId = state.tempNpcId--;
state.draftNPCs.push({
id: newId,
townId: state.draftTown!.id,
name: `New ${item.label}`,
nameKey: '',
type: item.type,
offsetX,
offsetY,
buildingId: null,
});
refreshTownData();
setSelection({ type: 'npc', id: newId });
setDirty(true);
hideContextMenu();
});
submenu.appendChild(btn);
}
});
addContextSubmenu('Add Building', (submenu) => {
for (const item of buildingMenuItems) {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.addEventListener('click', () => {
const offsetX = world.x - state.draftTown!.worldX;
const offsetY = world.y - state.draftTown!.worldY;
const newId = state.tempBuildingId--;
state.draftBuildings.push({
id: newId,
townId: state.draftTown!.id,
buildingType: item.type,
offsetX,
offsetY,
facing: 'south',
footprintW: item.w,
footprintH: item.h,
});
refreshTownData();
setSelection({ type: 'building', id: newId });
setDirty(true);
hideContextMenu();
});
submenu.appendChild(btn);
}
});
addContextSubmenu('Add Object', (submenu) => {
for (const item of objectTypeItems) {
const btn = document.createElement('button');
btn.textContent = item.label;
btn.addEventListener('click', () => {
const offsetX = world.x - state.draftTown!.worldX;
const offsetY = world.y - state.draftTown!.worldY;
const newId = state.tempObjectId--;
state.draftObjects.push({
id: newId,
townId: state.draftTown!.id,
objectType: item.type,
variant: 0,
offsetX,
offsetY,
});
refreshTownData();
setSelection({ type: 'object', id: newId });
setDirty(true);
hideContextMenu();
});
submenu.appendChild(btn);
}
});
});
}
function eventToWorld(evt: MouseEvent): { x: number; y: number } | null {
if (!renderer.initialized) return null;
const canvas = renderer.app.canvas as HTMLCanvasElement;
const rect = canvas.getBoundingClientRect();
if (evt.clientX < rect.left || evt.clientX > rect.right || evt.clientY < rect.top || evt.clientY > rect.bottom) {
return null;
}
const localX = evt.clientX - rect.left;
const localY = evt.clientY - rect.top;
const screenW = renderer.app.renderer.width;
const screenH = renderer.app.renderer.height;
const worldScreenX = localX - screenW / 2 + camera.finalX;
const worldScreenY = localY - screenH / 2 + camera.finalY;
return screenToWorld(worldScreenX, worldScreenY);
}
function findHit(world: { x: number; y: number }): Selection {
if (!state.draftTown) return { type: 'none' };
const npcHitRadius = 1.8;
const objectHitRadius = 1.8;
const buildingPadding = 1.0;
for (const npc of state.draftNPCs) {
const nx = state.draftTown.worldX + npc.offsetX;
const ny = state.draftTown.worldY + npc.offsetY;
const dx = world.x - nx;
const dy = world.y - ny;
if (Math.hypot(dx, dy) <= npcHitRadius) {
return { type: 'npc', id: npc.id };
}
}
for (const obj of state.draftObjects) {
const ox = state.draftTown.worldX + obj.offsetX;
const oy = state.draftTown.worldY + obj.offsetY;
if (Math.hypot(world.x - ox, world.y - oy) <= objectHitRadius) {
return { type: 'object', id: obj.id };
}
}
for (const building of state.draftBuildings) {
const bx = state.draftTown.worldX + building.offsetX;
const by = state.draftTown.worldY + building.offsetY;
const dx = Math.abs(world.x - bx);
const dy = Math.abs(world.y - by);
if (dx <= building.footprintW / 2 + buildingPadding && dy <= building.footprintH / 2 + buildingPadding) {
return { type: 'building', id: building.id };
}
}
return { type: 'none' };
}
function startDrag(selection: Selection): void {
if (selection.type === 'npc') {
const npc = state.draftNPCs.find((n) => n.id === selection.id);
if (!npc) return;
state.drag = {
type: 'npc',
id: npc.id,
startOffsetX: npc.offsetX,
startOffsetY: npc.offsetY,
};
return;
}
if (selection.type === 'building') {
const building = state.draftBuildings.find((b) => b.id === selection.id);
if (!building) return;
const linked = new Map<number, { offsetX: number; offsetY: number }>();
for (const npc of state.draftNPCs) {
if (npc.buildingId === building.id) {
linked.set(npc.id, { offsetX: npc.offsetX, offsetY: npc.offsetY });
}
}
state.drag = {
type: 'building',
id: building.id,
startOffsetX: building.offsetX,
startOffsetY: building.offsetY,
linkedNpcOffsets: linked,
};
return;
}
if (selection.type === 'object') {
const obj = state.draftObjects.find((o) => o.id === selection.id);
if (!obj) return;
state.drag = {
type: 'object',
id: obj.id,
startOffsetX: obj.offsetX,
startOffsetY: obj.offsetY,
};
}
}
function updateDrag(world: { x: number; y: number }): void {
if (!state.draftTown || !state.drag) return;
const offsetX = world.x - state.draftTown.worldX;
const offsetY = world.y - state.draftTown.worldY;
if (state.drag.type === 'npc') {
const npc = state.draftNPCs.find((n) => n.id === state.drag?.id);
if (!npc) return;
npc.offsetX = offsetX;
npc.offsetY = offsetY;
}
if (state.drag.type === 'building') {
const building = state.draftBuildings.find((b) => b.id === state.drag?.id);
if (!building) return;
const deltaX = offsetX - state.drag.startOffsetX;
const deltaY = offsetY - state.drag.startOffsetY;
building.offsetX = offsetX;
building.offsetY = offsetY;
for (const npc of state.draftNPCs) {
const start = state.drag.linkedNpcOffsets.get(npc.id);
if (!start) continue;
npc.offsetX = start.offsetX + deltaX;
npc.offsetY = start.offsetY + deltaY;
}
}
if (state.drag.type === 'object') {
const obj = state.draftObjects.find((o) => o.id === state.drag?.id);
if (!obj) return;
obj.offsetX = offsetX;
obj.offsetY = offsetY;
}
refreshTownData();
setDirty(true);
}
function startPan(evt: MouseEvent): void {
state.pan = {
startClientX: evt.clientX,
startClientY: evt.clientY,
startCameraX: camera.x,
startCameraY: camera.y,
};
}
function updatePan(evt: MouseEvent): void {
if (!state.pan) return;
const dx = evt.clientX - state.pan.startClientX;
const dy = evt.clientY - state.pan.startClientY;
const nextX = state.pan.startCameraX - dx;
const nextY = state.pan.startCameraY - dy;
camera.setTarget(nextX, nextY);
camera.snapToTarget();
}
function renderLoop(): void {
requestAnimationFrame(renderLoop);
if (!renderer.initialized) return;
if (!state.townData) return;
const screenW = renderer.app.renderer.width;
const screenH = renderer.app.renderer.height;
camera.applyTo(renderer.worldContainer, screenW, screenH);
renderer.drawGround(camera, screenW, screenH);
renderer.drawTowns([state.townData], camera, screenW, screenH);
renderer.drawTownObjects(state.townData.objects ?? [], camera, screenW, screenH);
if (state.npcData.length > 0) {
renderer.drawNPCs(state.npcData, camera, screenW, screenH, performance.now());
}
}
async function bootstrap(): Promise<void> {
await renderer.init(canvasRoot);
window.addEventListener('resize', () => renderer.resize());
renderLoop();
const canvas = renderer.app.canvas as HTMLCanvasElement;
canvas.addEventListener('contextmenu', (evt) => {
evt.preventDefault();
const world = eventToWorld(evt);
if (!world) return;
state.contextWorld = world;
const hit = findHit(world);
if (hit.type === 'none') {
showContextMenuForEmpty(evt.clientX, evt.clientY);
return;
}
setSelection(hit);
showContextMenuForSelection(evt.clientX, evt.clientY, hit);
});
canvas.addEventListener('mousedown', (evt) => {
hideContextMenu();
const world = eventToWorld(evt);
if (!world) return;
if (evt.button === 1) {
startPan(evt);
return;
}
if (evt.button !== 0) return;
const hit = findHit(world);
if (hit.type === 'none') {
setSelection(hit);
startPan(evt);
return;
}
setSelection(hit);
startDrag(hit);
});
window.addEventListener('mousemove', (evt) => {
if (state.drag) {
const world = eventToWorld(evt);
if (!world) return;
updateDrag(world);
return;
}
if (state.pan) {
updatePan(evt);
}
});
window.addEventListener('mouseup', () => {
state.drag = null;
state.pan = null;
});
window.addEventListener('click', () => hideContextMenu());
if (state.auth.username && state.auth.password) {
void loadTowns();
}
}
authSaveBtn.addEventListener('click', saveAuth);
townsReloadBtn.addEventListener('click', () => void loadTowns());
layoutReloadBtn.addEventListener('click', () => {
if (!state.selectedTownId) return;
void loadLayout(state.selectedTownId);
});
layoutSaveBtn.addEventListener('click', () => void saveLayout());
townSelect.addEventListener('change', () => {
const id = Number(townSelect.value);
if (!id) return;
state.selectedTownId = id;
void loadLayout(id);
});
void bootstrap();

@ -0,0 +1,125 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Arial, sans-serif;
background: #10141f;
color: #e8eef9;
}
#app {
display: grid;
grid-template-columns: 260px 1fr;
min-height: 100vh;
}
.sidebar {
background: #151b2a;
padding: 16px;
border-right: 1px solid #2a3551;
}
.canvas-area {
position: relative;
overflow: hidden;
}
.canvas-root {
width: 100%;
height: 100%;
}
.card {
background: #151b2a;
border: 1px solid #2a3551;
border-radius: 8px;
padding: 12px;
margin-bottom: 12px;
}
.card h3 {
margin: 0 0 8px 0;
font-size: 14px;
}
input,
select {
width: 100%;
padding: 8px;
margin: 4px 0;
background: #0f1522;
color: #fff;
border: 1px solid #2f3b5a;
}
.btn {
padding: 8px 12px;
border: 1px solid #2f3b5a;
background: #223152;
color: #fff;
cursor: pointer;
margin-top: 6px;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.muted {
color: #9eb0d6;
font-size: 12px;
margin-top: 6px;
}
.context-menu {
position: absolute;
min-width: 180px;
background: #151b2a;
border: 1px solid #2a3551;
border-radius: 6px;
padding: 6px 0;
z-index: 10;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
}
.context-menu.hidden {
display: none;
}
.context-menu button {
width: 100%;
border: 0;
background: transparent;
color: #e8eef9;
text-align: left;
padding: 8px 12px;
cursor: pointer;
}
.context-menu button:hover {
background: #223152;
}
.context-menu-item {
position: relative;
}
.context-menu-item > .submenu {
position: absolute;
top: 0;
left: 100%;
min-width: 200px;
background: #151b2a;
border: 1px solid #2a3551;
border-radius: 6px;
padding: 6px 0;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.35);
display: none;
}
.context-menu-item:hover > .submenu {
display: block;
}

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"strict": true,
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@game/*": ["../../frontend/src/game/*"],
"@shared/*": ["../../frontend/src/shared/*"],
"@frontend/*": ["../../frontend/src/*"]
}
},
"include": ["src"]
}

@ -0,0 +1,32 @@
import { defineConfig } from 'vite';
import path from 'path';
const rootDir = __dirname;
export default defineConfig({
root: rootDir,
base: '/town-editor/',
publicDir: path.resolve(rootDir, '../../frontend/public'),
resolve: {
alias: {
'@game': path.resolve(rootDir, '../../frontend/src/game'),
'@shared': path.resolve(rootDir, '../../frontend/src/shared'),
'@frontend': path.resolve(rootDir, '../../frontend/src'),
'pixi.js': path.resolve(rootDir, 'node_modules/pixi.js'),
},
dedupe: ['pixi.js'],
},
server: {
port: 5175,
fs: {
allow: [
path.resolve(rootDir, '../../frontend'),
path.resolve(rootDir, '../../admin-web/town-editor'),
],
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
});

@ -1,5 +1,13 @@
{ {
"releases": [ "releases": [
{
"version": "0.3.0-dev",
"title": "AutoHero — 0.3.0",
"items": [
"GRAPHICS! We are delighted to introduce graphics in the game. It's static but it's better than procedural.",
"New NPC types."
]
},
{ {
"version": "0.2.0-dev", "version": "0.2.0-dev",
"title": "AutoHero — 0.2.0", "title": "AutoHero — 0.2.0",

@ -21,6 +21,11 @@ type MessageSender interface {
BroadcastEvent(event model.CombatEvent) BroadcastEvent(event model.CombatEvent)
} }
// NearbySubscriptionManager can attach nearby-hero movement subscriptions for a viewer.
type NearbySubscriptionManager interface {
SetNearbySubscriptions(viewerID int64, targetIDs []int64)
}
// EnemyDeathCallback runs when an enemy dies (loot/XP applied). Returns processed loot drops for combat_end WS. // EnemyDeathCallback runs when an enemy dies (loot/XP applied). Returns processed loot drops for combat_end WS.
type EnemyDeathCallback func(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop type EnemyDeathCallback func(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop
@ -434,6 +439,8 @@ func (e *Engine) handleClientMessage(msg IncomingMessage) {
return return
} }
switch msg.Type { switch msg.Type {
case "request_nearby_heroes":
e.handleNearbyHeroesRequest(msg)
case "activate_buff": case "activate_buff":
e.handleActivateBuff(msg) e.handleActivateBuff(msg)
case "use_potion": case "use_potion":
@ -461,6 +468,76 @@ func (e *Engine) handleClientMessage(msg IncomingMessage) {
} }
} }
func (e *Engine) handleNearbyHeroesRequest(msg IncomingMessage) {
if e.heroStore == nil || e.sender == nil {
return
}
var req model.NearbyHeroesRequestPayload
if len(msg.Payload) > 0 {
if err := json.Unmarshal(msg.Payload, &req); err != nil {
e.sendError(msg.HeroID, "invalid_payload", "invalid request_nearby_heroes payload")
return
}
}
radius := 50.0
if req.Radius > 0 {
radius = req.Radius
}
if radius > 100 {
radius = 100
}
limit := 5
if req.Limit > 0 {
limit = req.Limit
}
if limit > 5 {
limit = 5
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
hero, err := e.heroStore.GetByID(ctx, msg.HeroID)
if err != nil || hero == nil {
e.sendError(msg.HeroID, "no_hero", "hero not found")
return
}
posX, posY := hero.PositionX, hero.PositionY
if wx, wy, ok := e.HeroWorldPositionForCombat(hero.ID); ok {
posX, posY = wx, wy
}
nearby, err := e.heroStore.GetNearbyHeroes(ctx, hero.ID, posX, posY, radius, limit)
if err != nil {
e.logger.Error("failed to load nearby heroes", "hero_id", hero.ID, "error", err)
e.sendError(msg.HeroID, "nearby_load_failed", "failed to load nearby heroes")
return
}
e.OverlayResidentWorldPositionsOnNearby(nearby)
summaries := make([]model.NearbyHeroSummary, 0, len(nearby))
ids := make([]int64, 0, len(nearby))
for _, h := range nearby {
summaries = append(summaries, model.NearbyHeroSummary{
ID: h.ID,
Name: h.Name,
Level: h.Level,
ModelVariant: h.ModelVariant,
PositionX: h.PositionX,
PositionY: h.PositionY,
})
ids = append(ids, h.ID)
}
e.sender.SendToHero(msg.HeroID, "nearby_heroes", model.NearbyHeroesPayload{
Heroes: summaries,
})
if sub, ok := e.sender.(NearbySubscriptionManager); ok {
sub.SetNearbySubscriptions(msg.HeroID, ids)
}
}
// handleActivateBuff processes the activate_buff client command. // handleActivateBuff processes the activate_buff client command.
func (e *Engine) handleActivateBuff(msg IncomingMessage) { func (e *Engine) handleActivateBuff(msg IncomingMessage) {
var payload model.ActivateBuffPayload var payload model.ActivateBuffPayload
@ -1818,7 +1895,6 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
victoryDrops = e.onEnemyDeath(hero, enemy, now) victoryDrops = e.onEnemyDeath(hero, enemy, now)
} }
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
e.applyOfflineDigest(dctx, cs.HeroID, hero, now, storage.OfflineDigestDelta{ e.applyOfflineDigest(dctx, cs.HeroID, hero, now, storage.OfflineDigestDelta{
MonstersKilled: 1, MonstersKilled: 1,
@ -1829,7 +1905,6 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
}) })
cancel() cancel()
e.emitEvent(model.CombatEvent{ e.emitEvent(model.CombatEvent{
Type: "combat_end", Type: "combat_end",
HeroID: cs.HeroID, HeroID: cs.HeroID,

@ -389,6 +389,7 @@ func (e *Engine) pushHeroMeetStartLocked(heroID int64, lingerMs int64, meetPhase
ID: ph.Hero.ID, ID: ph.Hero.ID,
Name: ph.Hero.Name, Name: ph.Hero.Name,
Level: ph.Hero.Level, Level: ph.Hero.Level,
ModelVariant: ph.Hero.ModelVariant,
PositionX: px, PositionX: px,
PositionY: py, PositionY: py,
} }

@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"math"
"net/http" "net/http"
"runtime" "runtime"
"strconv" "strconv"
@ -732,6 +733,299 @@ func (h *AdminHandler) ListTownNPCsForQuests(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, map[string]any{"npcs": npcs}) writeJSON(w, http.StatusOK, map[string]any{"npcs": npcs})
} }
type adminTownLayoutResponse struct {
Town *model.Town `json:"town"`
NPCs []model.NPC `json:"npcs"`
Buildings []model.TownBuilding `json:"buildings"`
Objects []model.TownObject `json:"objects"`
}
type adminTownLayoutNPCUpdate struct {
ID *int64 `json:"id,omitempty"`
Name string `json:"name"`
NameKey string `json:"nameKey"`
Type string `json:"type"`
OffsetX float64 `json:"offsetX"`
OffsetY float64 `json:"offsetY"`
BuildingID *int64 `json:"buildingId,omitempty"`
}
type adminTownLayoutBuildingUpdate struct {
ID *int64 `json:"id,omitempty"`
BuildingType string `json:"buildingType"`
OffsetX float64 `json:"offsetX"`
OffsetY float64 `json:"offsetY"`
Facing string `json:"facing"`
FootprintW float64 `json:"footprintW"`
FootprintH float64 `json:"footprintH"`
}
type adminTownLayoutObjectUpdate struct {
ID *int64 `json:"id,omitempty"`
ObjectType string `json:"objectType"`
Variant int `json:"variant"`
OffsetX float64 `json:"offsetX"`
OffsetY float64 `json:"offsetY"`
}
type adminTownLayoutRequest struct {
NPCs []adminTownLayoutNPCUpdate `json:"npcs"`
Buildings []adminTownLayoutBuildingUpdate `json:"buildings"`
Objects []adminTownLayoutObjectUpdate `json:"objects"`
DeleteNPCIDs []int64 `json:"deleteNpcIds"`
DeleteBuildingIDs []int64 `json:"deleteBuildingIds"`
DeleteObjectIDs []int64 `json:"deleteObjectIds"`
}
var adminTownNPCTypes = map[string]struct{}{
"quest_giver": {},
"merchant": {},
"armorer": {},
"weapon": {},
"jeweler": {},
"bounty_hunter": {},
"elder": {},
"healer": {},
}
var adminTownBuildingTypes = map[string]struct{}{
"house.quest_giver": {},
"house.merchant": {},
"house.armorer": {},
"house.weapon_smith": {},
"house.jeweler": {},
"house.bounty_hunter": {},
"house.elder": {},
"house.healer": {},
"decoration.well": {},
"decoration.stall": {},
"decoration.signpost": {},
}
var adminTownObjectTypes = map[string]struct{}{
"tree": {},
"rock": {},
"cart": {},
"barrel": {},
"bush": {},
"mushroom": {},
"leaves": {},
"stump": {},
"bones": {},
"ruin": {},
}
var adminTownBuildingFacings = map[string]struct{}{
"north": {},
"south": {},
"east": {},
"west": {},
}
func (h *AdminHandler) GetTownLayout(w http.ResponseWriter, r *http.Request) {
townID, err := strconv.ParseInt(chi.URLParam(r, "townId"), 10, 64)
if err != nil || townID <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid townId"})
return
}
town, err := h.questStore.GetTown(r.Context(), townID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load town"})
return
}
if town == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "town not found"})
return
}
npcs, err := h.questStore.ListNPCsByTown(r.Context(), townID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list npcs"})
return
}
buildings, err := h.questStore.ListBuildingsByTown(r.Context(), townID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list buildings"})
return
}
objects, err := h.questStore.ListTownObjectsByTown(r.Context(), townID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list objects"})
return
}
writeJSON(w, http.StatusOK, adminTownLayoutResponse{
Town: town,
NPCs: npcs,
Buildings: buildings,
Objects: objects,
})
}
func (h *AdminHandler) UpdateTownLayout(w http.ResponseWriter, r *http.Request) {
townID, err := strconv.ParseInt(chi.URLParam(r, "townId"), 10, 64)
if err != nil || townID <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid townId"})
return
}
town, err := h.questStore.GetTown(r.Context(), townID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load town"})
return
}
if town == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "town not found"})
return
}
var req adminTownLayoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"})
return
}
npcUpdates := make([]storage.TownLayoutNPCUpdate, 0, len(req.NPCs))
for _, n := range req.NPCs {
if n.ID != nil && *n.ID <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npc id"})
return
}
if _, ok := adminTownNPCTypes[n.Type]; !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npc type"})
return
}
if math.IsNaN(n.OffsetX) || math.IsNaN(n.OffsetY) || math.IsInf(n.OffsetX, 0) || math.IsInf(n.OffsetY, 0) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npc offsets"})
return
}
if n.ID == nil && strings.TrimSpace(n.Name) == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "npc name is required"})
return
}
npcUpdates = append(npcUpdates, storage.TownLayoutNPCUpdate{
ID: n.ID,
Name: n.Name,
NameKey: n.NameKey,
Type: n.Type,
OffsetX: n.OffsetX,
OffsetY: n.OffsetY,
BuildingID: n.BuildingID,
})
}
buildingUpdates := make([]storage.TownLayoutBuildingUpsert, 0, len(req.Buildings))
for _, b := range req.Buildings {
if b.ID != nil && *b.ID <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building id"})
return
}
if _, ok := adminTownBuildingTypes[b.BuildingType]; !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid buildingType"})
return
}
if _, ok := adminTownBuildingFacings[b.Facing]; !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building facing"})
return
}
if math.IsNaN(b.OffsetX) || math.IsNaN(b.OffsetY) || math.IsInf(b.OffsetX, 0) || math.IsInf(b.OffsetY, 0) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building offsets"})
return
}
if b.FootprintW <= 0 || b.FootprintH <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building footprint"})
return
}
buildingUpdates = append(buildingUpdates, storage.TownLayoutBuildingUpsert{
ID: b.ID,
BuildingType: b.BuildingType,
OffsetX: b.OffsetX,
OffsetY: b.OffsetY,
Facing: b.Facing,
FootprintW: b.FootprintW,
FootprintH: b.FootprintH,
})
}
objectUpdates := make([]storage.TownLayoutObjectUpsert, 0, len(req.Objects))
for _, o := range req.Objects {
if o.ID != nil && *o.ID <= 0 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object id"})
return
}
if _, ok := adminTownObjectTypes[o.ObjectType]; !ok {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object type"})
return
}
if o.Variant < 0 || o.Variant > 1 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object variant"})
return
}
if math.IsNaN(o.OffsetX) || math.IsNaN(o.OffsetY) || math.IsInf(o.OffsetX, 0) || math.IsInf(o.OffsetY, 0) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object offsets"})
return
}
objectUpdates = append(objectUpdates, storage.TownLayoutObjectUpsert{
ID: o.ID,
ObjectType: o.ObjectType,
Variant: o.Variant,
OffsetX: o.OffsetX,
OffsetY: o.OffsetY,
})
}
if err := h.questStore.UpdateTownLayout(
r.Context(),
townID,
npcUpdates,
buildingUpdates,
objectUpdates,
req.DeleteNPCIDs,
req.DeleteBuildingIDs,
req.DeleteObjectIDs,
); err != nil {
if errors.Is(err, storage.ErrTownLayoutMissing) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "npc, building, or object not found in town"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update town layout"})
return
}
rg, err := game.LoadRoadGraph(r.Context(), h.pool)
if err != nil {
h.logger.Error("admin: reload road graph after town layout update", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to reload road graph"})
return
}
h.engine.SetRoadGraph(rg)
npcs, err := h.questStore.ListNPCsByTown(r.Context(), townID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list npcs"})
return
}
buildings, err := h.questStore.ListBuildingsByTown(r.Context(), townID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list buildings"})
return
}
objects, err := h.questStore.ListTownObjectsByTown(r.Context(), townID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list objects"})
return
}
writeJSON(w, http.StatusOK, adminTownLayoutResponse{
Town: town,
NPCs: npcs,
Buildings: buildings,
Objects: objects,
})
}
// ContentAllQuests returns all quest template rows (global content). // ContentAllQuests returns all quest template rows (global content).
// GET /admin/content/quests // GET /admin/content/quests
func (h *AdminHandler) ContentAllQuests(w http.ResponseWriter, r *http.Request) { func (h *AdminHandler) ContentAllQuests(w http.ResponseWriter, r *http.Request) {

@ -1618,15 +1618,15 @@ func (h *GameHandler) NearbyHeroes(w http.ResponseWriter, r *http.Request) {
return return
} }
// Default radius: 500 units, max 50 heroes. // Default radius: 50 units, max 5 heroes.
radius := 500.0 radius := 50.0
if rStr := r.URL.Query().Get("radius"); rStr != "" { if rStr := r.URL.Query().Get("radius"); rStr != "" {
if parsed, err := strconv.ParseFloat(rStr, 64); err == nil && parsed > 0 { if parsed, err := strconv.ParseFloat(rStr, 64); err == nil && parsed > 0 {
radius = parsed radius = parsed
} }
} }
if radius > 2000 { if radius > 100 {
radius = 2000 radius = 100
} }
posX, posY := hero.PositionX, hero.PositionY posX, posY := hero.PositionX, hero.PositionY
@ -1636,7 +1636,7 @@ func (h *GameHandler) NearbyHeroes(w http.ResponseWriter, r *http.Request) {
} }
} }
nearby, err := h.store.GetNearbyHeroes(r.Context(), hero.ID, posX, posY, radius, 50) nearby, err := h.store.GetNearbyHeroes(r.Context(), hero.ID, posX, posY, radius, 5)
if err != nil { if err != nil {
h.logger.Error("failed to get nearby heroes", "hero_id", hero.ID, "error", err) h.logger.Error("failed to get nearby heroes", "hero_id", hero.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{ writeJSON(w, http.StatusInternalServerError, map[string]string{

@ -34,6 +34,11 @@ type Hub struct {
mu sync.RWMutex mu sync.RWMutex
logger *slog.Logger logger *slog.Logger
// nearbySubscriptions maps target hero -> viewers subscribed to its movement updates.
nearbySubscriptions map[int64]map[int64]struct{}
// viewerSubscriptions maps viewer hero -> targets they are currently subscribed to.
viewerSubscriptions map[int64]map[int64]struct{}
// OnConnect is called when a client finishes registration. // OnConnect is called when a client finishes registration.
// Set by the engine to push initial state. May be nil. // Set by the engine to push initial state. May be nil.
OnConnect func(heroID int64) OnConnect func(heroID int64)
@ -69,6 +74,8 @@ func NewHub(logger *slog.Logger) *Hub {
broadcast: make(chan model.WSEnvelope, 256), broadcast: make(chan model.WSEnvelope, 256),
Incoming: make(chan model.ClientMessage, 256), Incoming: make(chan model.ClientMessage, 256),
logger: logger, logger: logger,
nearbySubscriptions: make(map[int64]map[int64]struct{}),
viewerSubscriptions: make(map[int64]map[int64]struct{}),
} }
} }
@ -102,6 +109,9 @@ func (h *Hub) Run() {
remaining++ remaining++
} }
} }
if remaining == 0 {
h.removeNearbySubscriptionsLocked(heroID)
}
h.mu.Unlock() h.mu.Unlock()
h.logger.Info("client disconnected", "hero_id", heroID, "remaining_same_hero", remaining) h.logger.Info("client disconnected", "hero_id", heroID, "remaining_same_hero", remaining)
@ -144,8 +154,36 @@ func (h *Hub) SendToHero(heroID int64, msgType string, payload any) {
} }
} }
env := model.NewWSEnvelope(msgType, payload) env := model.NewWSEnvelope(msgType, payload)
var nearbyEnv *model.WSEnvelope
var nearbySubs map[int64]struct{}
if msgType == "hero_move" {
var move model.HeroMovePayload
switch v := payload.(type) {
case model.HeroMovePayload:
move = v
case *model.HeroMovePayload:
if v != nil {
move = *v
}
default:
move = model.HeroMovePayload{}
}
nearbyPayload := model.NearbyHeroMovePayload{
HeroID: heroID,
X: move.X,
Y: move.Y,
TargetX: move.TargetX,
TargetY: move.TargetY,
Speed: move.Speed,
Heading: move.Heading,
}
envBuilt := model.NewWSEnvelope("nearby_hero_move", nearbyPayload)
nearbyEnv = &envBuilt
}
h.mu.RLock() h.mu.RLock()
defer h.mu.RUnlock() nearbySubs = h.nearbySubscriptions[heroID]
for client := range h.clients { for client := range h.clients {
if client.heroID == heroID { if client.heroID == heroID {
select { select {
@ -156,8 +194,66 @@ func (h *Hub) SendToHero(heroID int64, msgType string, payload any) {
h.unregister <- c h.unregister <- c
}(client) }(client)
} }
continue
}
if nearbyEnv != nil && nearbySubs != nil {
if _, ok := nearbySubs[client.heroID]; ok {
select {
case client.send <- *nearbyEnv:
default:
go func(c *Client) {
h.unregister <- c
}(client)
}
}
}
}
h.mu.RUnlock()
}
// SetNearbySubscriptions replaces the viewer's subscriptions with the provided target hero IDs.
func (h *Hub) SetNearbySubscriptions(viewerID int64, targets []int64) {
h.mu.Lock()
defer h.mu.Unlock()
h.removeNearbySubscriptionsLocked(viewerID)
if len(targets) == 0 {
return
}
next := make(map[int64]struct{}, len(targets))
for _, targetID := range targets {
if targetID == viewerID || targetID <= 0 {
continue
}
next[targetID] = struct{}{}
subs := h.nearbySubscriptions[targetID]
if subs == nil {
subs = make(map[int64]struct{})
h.nearbySubscriptions[targetID] = subs
}
subs[viewerID] = struct{}{}
}
if len(next) > 0 {
h.viewerSubscriptions[viewerID] = next
}
}
func (h *Hub) removeNearbySubscriptionsLocked(viewerID int64) {
existing := h.viewerSubscriptions[viewerID]
if len(existing) == 0 {
delete(h.viewerSubscriptions, viewerID)
return
}
for targetID := range existing {
subs := h.nearbySubscriptions[targetID]
if subs == nil {
continue
}
delete(subs, viewerID)
if len(subs) == 0 {
delete(h.nearbySubscriptions, targetID)
} }
} }
delete(h.viewerSubscriptions, viewerID)
} }
// BroadcastAll sends an envelope to every connected client (rare: server announcements). // BroadcastAll sends an envelope to every connected client (rare: server announcements).

@ -16,6 +16,7 @@ type Hero struct {
ID int64 `json:"id"` ID int64 `json:"id"`
TelegramID int64 `json:"telegramId"` TelegramID int64 `json:"telegramId"`
Name string `json:"name"` Name string `json:"name"`
ModelVariant int `json:"modelVariant"`
HP int `json:"hp"` HP int `json:"hp"`
MaxHP int `json:"maxHp"` MaxHP int `json:"maxHp"`
Attack int `json:"attack"` Attack int `json:"attack"`
@ -139,7 +140,7 @@ func (h *Hero) LevelUp() bool {
h.MaxHP += hpBase + h.Constitution/6 h.MaxHP += hpBase + h.Constitution/6
} }
if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 { if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 {
h.Attack ++ h.Attack++
} }
if cfg.LevelUpDEFEvery > 0 && h.Level%int(cfg.LevelUpDEFEvery) == 0 { if cfg.LevelUpDEFEvery > 0 && h.Level%int(cfg.LevelUpDEFEvery) == 0 {
h.Defense++ h.Defense++

@ -0,0 +1,10 @@
package model
const (
HeroModelVariantMin = 0
HeroModelVariantMax = 2
)
func IsValidHeroModelVariant(v int) bool {
return v >= HeroModelVariantMin && v <= HeroModelVariantMax
}

@ -0,0 +1,11 @@
package model
// TownObject represents a decorative prop placed in a town.
type TownObject struct {
ID int64 `json:"id"`
TownID int64 `json:"townId"`
ObjectType string `json:"objectType"`
Variant int `json:"variant"`
OffsetX float64 `json:"offsetX"`
OffsetY float64 `json:"offsetY"`
}

@ -243,6 +243,32 @@ type DebuffAppliedPayload struct {
ExpiresAt time.Time `json:"expiresAt"` ExpiresAt time.Time `json:"expiresAt"`
} }
// NearbyHeroSummary is a lightweight snapshot for shared-world rendering updates.
type NearbyHeroSummary struct {
ID int64 `json:"id"`
Name string `json:"name"`
Level int `json:"level"`
ModelVariant int `json:"modelVariant"`
PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
}
// NearbyHeroesPayload returns the nearby hero list (server -> client).
type NearbyHeroesPayload struct {
Heroes []NearbyHeroSummary `json:"heroes"`
}
// NearbyHeroMovePayload pushes live movement for a nearby hero (server -> client).
type NearbyHeroMovePayload struct {
HeroID int64 `json:"heroId"`
X float64 `json:"x"`
Y float64 `json:"y"`
TargetX float64 `json:"targetX"`
TargetY float64 `json:"targetY"`
Speed float64 `json:"speed"`
Heading float64 `json:"heading,omitempty"`
}
// ErrorPayload is sent when a client command fails validation. // ErrorPayload is sent when a client command fails validation.
type ErrorPayload struct { type ErrorPayload struct {
Code string `json:"code"` Code string `json:"code"`
@ -251,6 +277,12 @@ type ErrorPayload struct {
// --- Client -> Server payload types --- // --- Client -> Server payload types ---
// NearbyHeroesRequestPayload is the payload for the request_nearby_heroes command.
type NearbyHeroesRequestPayload struct {
Radius float64 `json:"radius,omitempty"`
Limit int `json:"limit,omitempty"`
}
// ActivateBuffPayload is the payload for the activate_buff command. // ActivateBuffPayload is the payload for the activate_buff command.
type ActivateBuffPayload struct { type ActivateBuffPayload struct {
BuffType string `json:"buffType"` BuffType string `json:"buffType"`
@ -289,6 +321,7 @@ type HeroMeetPartnerSnapshot struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Level int `json:"level"` Level int `json:"level"`
ModelVariant int `json:"modelVariant"`
PositionX float64 `json:"positionX"` PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"` PositionY float64 `json:"positionY"`
} }

@ -120,6 +120,8 @@ func New(deps Deps) *chi.Mux {
r.Put("/content/gear/{gearId}", adminH.ContentUpdateGear) r.Put("/content/gear/{gearId}", adminH.ContentUpdateGear)
r.Get("/quests/towns", adminH.ListTownsForQuests) r.Get("/quests/towns", adminH.ListTownsForQuests)
r.Get("/quests/towns/{townId}/npcs", adminH.ListTownNPCsForQuests) r.Get("/quests/towns/{townId}/npcs", adminH.ListTownNPCsForQuests)
r.Get("/quests/towns/{townId}/layout", adminH.GetTownLayout)
r.Put("/quests/towns/{townId}/layout", adminH.UpdateTownLayout)
r.Get("/quests/npcs/{npcId}", adminH.ListNPCQuestsForAdmin) r.Get("/quests/npcs/{npcId}", adminH.ListNPCQuestsForAdmin)
r.Delete("/heroes/{heroId}", adminH.DeleteHero) r.Delete("/heroes/{heroId}", adminH.DeleteHero)
r.Get("/towns", adminH.ListTowns) r.Get("/towns", adminH.ListTowns)

@ -21,7 +21,7 @@ import (
// Gear is loaded separately via GearStore.GetHeroGear after the hero row is loaded. // Gear is loaded separately via GearStore.GetHeroGear after the hero row is loaded.
const heroSelectQuery = ` const heroSelectQuery = `
SELECT SELECT
h.id, h.telegram_id, h.name, h.id, h.telegram_id, h.name, h.hero_model_variant,
h.hp, h.max_hp, h.attack, h.defense, h.speed, h.hp, h.max_hp, h.attack, h.defense, h.speed,
h.strength, h.constitution, h.agility, h.luck, h.strength, h.constitution, h.agility, h.luck,
h.state, h.state,
@ -265,12 +265,14 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
hero.CreatedAt = now hero.CreatedAt = now
hero.UpdatedAt = now hero.UpdatedAt = now
hero.ModelVariant = randomHeroModelVariant()
buffChargesJSON := marshalBuffCharges(hero.BuffCharges) buffChargesJSON := marshalBuffCharges(hero.BuffCharges)
query := ` query := `
INSERT INTO heroes ( INSERT INTO heroes (
telegram_id, name, telegram_id, name,
hero_model_variant,
hp, max_hp, attack, defense, speed, hp, max_hp, attack, defense, speed,
strength, constitution, agility, luck, strength, constitution, agility, luck,
state, state,
@ -284,22 +286,24 @@ func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) erro
current_town_id, destination_town_id current_town_id, destination_town_id
) VALUES ( ) VALUES (
$1, $2, $1, $2,
$3, $4, $5, $6, $7, $3,
$8, $9, $10, $11, $4, $5, $6, $7, $8,
$12, $9, $10, $11, $12,
$13, $14, $15, $13,
$16, $17, $18, $14, $15, $16,
$19, $20, $21, $17, $18, $19,
$22, $23, $24, $20, $21, $22,
$25, $26, $27, $28, $29, $23, $24, $25,
$30, $26, $27, $28, $29, $30,
$31, $32, $31,
$33, $34 $32, $33,
$34, $35
) RETURNING id ) RETURNING id
` `
err := s.pool.QueryRow(ctx, query, err := s.pool.QueryRow(ctx, query,
hero.TelegramID, hero.Name, hero.TelegramID, hero.Name,
hero.ModelVariant,
hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed, hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed,
hero.Strength, hero.Constitution, hero.Agility, hero.Luck, hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
string(hero.State), string(hero.State),
@ -553,26 +557,28 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
query := ` query := `
UPDATE heroes SET UPDATE heroes SET
hp = $1, max_hp = $2, hero_model_variant = $1,
attack = $3, defense = $4, speed = $5, hp = $2, max_hp = $3,
strength = $6, constitution = $7, agility = $8, luck = $9, attack = $4, defense = $5, speed = $6,
state = $10, strength = $7, constitution = $8, agility = $9, luck = $10,
gold = $11, xp = $12, level = $13, state = $11,
revive_count = $14, subscription_active = $15, subscription_expires_at = $16, gold = $12, xp = $13, level = $14,
buff_free_charges_remaining = $17, buff_quota_period_end = $18, buff_charges = $19, revive_count = $15, subscription_active = $16, subscription_expires_at = $17,
position_x = $20, position_y = $21, potions = $22, buff_free_charges_remaining = $18, buff_quota_period_end = $19, buff_charges = $20,
total_kills = $23, elite_kills = $24, total_deaths = $25, position_x = $21, position_y = $22, potions = $23,
kills_since_death = $26, legendary_drops = $27, total_kills = $24, elite_kills = $25, total_deaths = $26,
last_online_at = $28, kills_since_death = $27, legendary_drops = $28,
updated_at = $29, last_online_at = $29,
destination_town_id = $30, updated_at = $30,
current_town_id = $31, destination_town_id = $31,
town_pause = $32 current_town_id = $32,
WHERE id = $33 town_pause = $33
WHERE id = $34
` `
townPauseJSON := marshalTownPause(hero.TownPause) townPauseJSON := marshalTownPause(hero.TownPause)
tag, err := s.pool.Exec(ctx, query, tag, err := s.pool.Exec(ctx, query,
hero.ModelVariant,
hero.HP, hero.MaxHP, hero.HP, hero.MaxHP,
hero.Attack, hero.Defense, hero.Speed, hero.Attack, hero.Defense, hero.Speed,
hero.Strength, hero.Constitution, hero.Agility, hero.Luck, hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
@ -701,7 +707,7 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
var townPauseRaw []byte var townPauseRaw []byte
err := rows.Scan( err := rows.Scan(
&h.ID, &h.TelegramID, &h.Name, &h.ID, &h.TelegramID, &h.Name, &h.ModelVariant,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed, &h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck, &h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &state,
@ -720,6 +726,9 @@ func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
} }
h.BuffCharges = unmarshalBuffCharges(buffChargesRaw) h.BuffCharges = unmarshalBuffCharges(buffChargesRaw)
h.State = model.GameState(state) h.State = model.GameState(state)
if !model.IsValidHeroModelVariant(h.ModelVariant) {
h.ModelVariant = model.HeroModelVariantMin
}
h.Gear = make(map[model.EquipmentSlot]*model.GearItem) h.Gear = make(map[model.EquipmentSlot]*model.GearItem)
h.TownPause = unmarshalTownPause(townPauseRaw) h.TownPause = unmarshalTownPause(townPauseRaw)
@ -736,7 +745,7 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
var townPauseRaw []byte var townPauseRaw []byte
err := row.Scan( err := row.Scan(
&h.ID, &h.TelegramID, &h.Name, &h.ID, &h.TelegramID, &h.Name, &h.ModelVariant,
&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed, &h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
&h.Strength, &h.Constitution, &h.Agility, &h.Luck, &h.Strength, &h.Constitution, &h.Agility, &h.Luck,
&state, &state,
@ -758,6 +767,9 @@ func scanHeroRow(row pgx.Row) (*model.Hero, error) {
} }
h.BuffCharges = unmarshalBuffCharges(buffChargesRaw) h.BuffCharges = unmarshalBuffCharges(buffChargesRaw)
h.State = model.GameState(state) h.State = model.GameState(state)
if !model.IsValidHeroModelVariant(h.ModelVariant) {
h.ModelVariant = model.HeroModelVariantMin
}
h.Gear = make(map[model.EquipmentSlot]*model.GearItem) h.Gear = make(map[model.EquipmentSlot]*model.GearItem)
h.TownPause = unmarshalTownPause(townPauseRaw) h.TownPause = unmarshalTownPause(townPauseRaw)
@ -959,6 +971,7 @@ type HeroSummary struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Level int `json:"level"` Level int `json:"level"`
ModelVariant int `json:"modelVariant"`
PositionX float64 `json:"positionX"` PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"` PositionY float64 `json:"positionY"`
} }
@ -977,24 +990,21 @@ func (s *HeroStore) UpdateOnlineStatus(ctx context.Context, heroID int64, posX,
// GetNearbyHeroes returns other heroes within radius who were online recently (< 2 min). // GetNearbyHeroes returns other heroes within radius who were online recently (< 2 min).
func (s *HeroStore) GetNearbyHeroes(ctx context.Context, heroID int64, posX, posY, radius float64, limit int) ([]HeroSummary, error) { func (s *HeroStore) GetNearbyHeroes(ctx context.Context, heroID int64, posX, posY, radius float64, limit int) ([]HeroSummary, error) {
if limit <= 0 { if limit < 1 {
limit = 20 limit = 1
} }
if limit > 100 { if limit > 5 {
limit = 100 limit = 5
} }
cutoff := time.Now().Add(-2 * time.Minute)
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, name, level, position_x, position_y SELECT id, name, level, hero_model_variant, position_x, position_y
FROM heroes FROM heroes
WHERE id != $1 WHERE id != $1
AND last_online_at > $2 AND sqrt(power(position_x - $2, 2) + power(position_y - $3, 2)) <= $4
AND sqrt(power(position_x - $3, 2) + power(position_y - $4, 2)) <= $5
ORDER BY last_online_at DESC ORDER BY last_online_at DESC
LIMIT $6 LIMIT $5
`, heroID, cutoff, posX, posY, radius, limit) `, heroID, posX, posY, radius, limit)
if err != nil { if err != nil {
return nil, fmt.Errorf("get nearby heroes: %w", err) return nil, fmt.Errorf("get nearby heroes: %w", err)
} }
@ -1003,9 +1013,12 @@ func (s *HeroStore) GetNearbyHeroes(ctx context.Context, heroID int64, posX, pos
var heroes []HeroSummary var heroes []HeroSummary
for rows.Next() { for rows.Next() {
var h HeroSummary var h HeroSummary
if err := rows.Scan(&h.ID, &h.Name, &h.Level, &h.PositionX, &h.PositionY); err != nil { if err := rows.Scan(&h.ID, &h.Name, &h.Level, &h.ModelVariant, &h.PositionX, &h.PositionY); err != nil {
return nil, fmt.Errorf("scan nearby hero: %w", err) return nil, fmt.Errorf("scan nearby hero: %w", err)
} }
if !model.IsValidHeroModelVariant(h.ModelVariant) {
h.ModelVariant = model.HeroModelVariantMin
}
heroes = append(heroes, h) heroes = append(heroes, h)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@ -1043,6 +1056,10 @@ func (s *HeroStore) CreatePayment(ctx context.Context, p *model.Payment) error {
).Scan(&p.ID) ).Scan(&p.ID)
} }
func randomHeroModelVariant() int {
return rand.Intn(model.HeroModelVariantMax-model.HeroModelVariantMin+1) + model.HeroModelVariantMin
}
func derefStr(p *string) string { func derefStr(p *string) string {
if p == nil { if p == nil {
return "" return ""

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"math/rand" "math/rand"
"strings" "strings"
"time"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@ -19,6 +20,36 @@ type QuestStore struct {
pool *pgxpool.Pool pool *pgxpool.Pool
} }
var ErrTownLayoutMissing = errors.New("town layout: missing npc or building")
type TownLayoutNPCUpdate struct {
ID *int64
Name string
NameKey string
Type string
OffsetX float64
OffsetY float64
BuildingID *int64
}
type TownLayoutBuildingUpsert struct {
ID *int64
BuildingType string
OffsetX float64
OffsetY float64
Facing string
FootprintW float64
FootprintH float64
}
type TownLayoutObjectUpsert struct {
ID *int64
ObjectType string
Variant int
OffsetX float64
OffsetY float64
}
// NewQuestStore creates a new QuestStore backed by the given connection pool. // NewQuestStore creates a new QuestStore backed by the given connection pool.
func NewQuestStore(pool *pgxpool.Pool) *QuestStore { func NewQuestStore(pool *pgxpool.Pool) *QuestStore {
return &QuestStore{pool: pool} return &QuestStore{pool: pool}
@ -174,6 +205,155 @@ func (s *QuestStore) ListBuildingsByTown(ctx context.Context, townID int64) ([]m
return buildings, nil return buildings, nil
} }
// UpdateTownLayout updates NPC offsets and upserts town buildings in a single transaction.
func (s *QuestStore) UpdateTownLayout(
ctx context.Context,
townID int64,
npcs []TownLayoutNPCUpdate,
buildings []TownLayoutBuildingUpsert,
objects []TownLayoutObjectUpsert,
deleteNPCIDs []int64,
deleteBuildingIDs []int64,
deleteObjectIDs []int64,
) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("update town layout begin: %w", err)
}
defer tx.Rollback(ctx)
if len(deleteNPCIDs) > 0 {
if _, err := tx.Exec(ctx, `
DELETE FROM npcs
WHERE town_id = $1 AND id = ANY($2)
`, townID, deleteNPCIDs); err != nil {
return fmt.Errorf("delete npcs: %w", err)
}
}
if len(deleteBuildingIDs) > 0 {
if _, err := tx.Exec(ctx, `
DELETE FROM town_buildings
WHERE town_id = $1 AND id = ANY($2)
`, townID, deleteBuildingIDs); err != nil {
return fmt.Errorf("delete town buildings: %w", err)
}
}
if len(deleteObjectIDs) > 0 {
if _, err := tx.Exec(ctx, `
DELETE FROM town_objects
WHERE town_id = $1 AND id = ANY($2)
`, townID, deleteObjectIDs); err != nil {
return fmt.Errorf("delete town objects: %w", err)
}
}
for _, n := range npcs {
if n.ID != nil {
tag, err := tx.Exec(ctx, `
UPDATE npcs
SET name = $1, name_key = $2, type = $3, offset_x = $4, offset_y = $5, building_id = $6
WHERE id = $7 AND town_id = $8
`, n.Name, n.NameKey, n.Type, n.OffsetX, n.OffsetY, n.BuildingID, *n.ID, townID)
if err != nil {
return fmt.Errorf("update npc: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("update npc: %w", ErrTownLayoutMissing)
}
continue
}
if _, err := tx.Exec(ctx, `
INSERT INTO npcs (town_id, name, name_key, type, offset_x, offset_y, created_at, building_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`, townID, n.Name, n.NameKey, n.Type, n.OffsetX, n.OffsetY, time.Now().UTC(), n.BuildingID); err != nil {
return fmt.Errorf("insert npc: %w", err)
}
}
now := time.Now().UTC()
for _, b := range buildings {
if b.ID != nil {
tag, err := tx.Exec(ctx, `
UPDATE town_buildings
SET building_type = $1, offset_x = $2, offset_y = $3, facing = $4, footprint_w = $5, footprint_h = $6
WHERE id = $7 AND town_id = $8
`, b.BuildingType, b.OffsetX, b.OffsetY, b.Facing, b.FootprintW, b.FootprintH, *b.ID, townID)
if err != nil {
return fmt.Errorf("update town building: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("update town building: %w", ErrTownLayoutMissing)
}
continue
}
if err := tx.QueryRow(ctx, `
INSERT INTO town_buildings (town_id, building_type, offset_x, offset_y, facing, footprint_w, footprint_h, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`, townID, b.BuildingType, b.OffsetX, b.OffsetY, b.Facing, b.FootprintW, b.FootprintH, now).Scan(new(int64)); err != nil {
return fmt.Errorf("insert town building: %w", err)
}
}
for _, o := range objects {
if o.ID != nil {
tag, err := tx.Exec(ctx, `
UPDATE town_objects
SET object_type = $1, variant = $2, offset_x = $3, offset_y = $4
WHERE id = $5 AND town_id = $6
`, o.ObjectType, o.Variant, o.OffsetX, o.OffsetY, *o.ID, townID)
if err != nil {
return fmt.Errorf("update town object: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("update town object: %w", ErrTownLayoutMissing)
}
continue
}
if _, err := tx.Exec(ctx, `
INSERT INTO town_objects (town_id, object_type, variant, offset_x, offset_y, created_at)
VALUES ($1, $2, $3, $4, $5, $6)
`, townID, o.ObjectType, o.Variant, o.OffsetX, o.OffsetY, now); err != nil {
return fmt.Errorf("insert town object: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("update town layout commit: %w", err)
}
return nil
}
// ListTownObjectsByTown returns all props in the given town.
func (s *QuestStore) ListTownObjectsByTown(ctx context.Context, townID int64) ([]model.TownObject, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, town_id, object_type, variant, offset_x, offset_y
FROM town_objects
WHERE town_id = $1
ORDER BY id ASC
`, townID)
if err != nil {
return nil, fmt.Errorf("list town objects: %w", err)
}
defer rows.Close()
var objects []model.TownObject
for rows.Next() {
var o model.TownObject
if err := rows.Scan(&o.ID, &o.TownID, &o.ObjectType, &o.Variant, &o.OffsetX, &o.OffsetY); err != nil {
return nil, fmt.Errorf("scan town object: %w", err)
}
objects = append(objects, o)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list town objects rows: %w", err)
}
if objects == nil {
objects = []model.TownObject{}
}
return objects, nil
}
// ListAllBuildings returns every building across all towns (for road_graph preload). // ListAllBuildings returns every building across all towns (for road_graph preload).
func (s *QuestStore) ListAllBuildings(ctx context.Context) ([]model.TownBuilding, error) { func (s *QuestStore) ListAllBuildings(ctx context.Context) ([]model.TownBuilding, error) {
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `

@ -3,4 +3,4 @@
package version package version
// Version is the active server build id (shown in /hero/init and admin /info). // Version is the active server build id (shown in /hero/init and admin /info).
const Version = "0.2.0-dev" const Version = "0.3.0-dev"

@ -0,0 +1,16 @@
ALTER TABLE heroes
ADD COLUMN hero_model_variant integer;
UPDATE heroes
SET hero_model_variant = floor(random() * 3)::int
WHERE hero_model_variant IS NULL;
ALTER TABLE heroes
ALTER COLUMN hero_model_variant SET NOT NULL;
ALTER TABLE heroes
ALTER COLUMN hero_model_variant SET DEFAULT floor(random() * 3)::int;
ALTER TABLE heroes
ADD CONSTRAINT heroes_hero_model_variant_check
CHECK (hero_model_variant BETWEEN 0 AND 2);

@ -0,0 +1,26 @@
-- Town objects for editor-placed props (barrel, stump, etc.)
CREATE TABLE IF NOT EXISTS public.town_objects (
id bigint PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
town_id bigint NOT NULL REFERENCES public.towns(id) ON DELETE CASCADE,
object_type text NOT NULL,
variant int NOT NULL DEFAULT 0,
offset_x double precision NOT NULL DEFAULT 0,
offset_y double precision NOT NULL DEFAULT 0,
created_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT town_objects_object_type_check CHECK (object_type = ANY (ARRAY[
'tree'::text,
'rock'::text,
'cart'::text,
'barrel'::text,
'bush'::text,
'mushroom'::text,
'leaves'::text,
'stump'::text,
'bones'::text,
'ruin'::text
])),
CONSTRAINT town_objects_variant_check CHECK (variant IN (0, 1))
);
CREATE INDEX IF NOT EXISTS idx_town_objects_town ON public.town_objects USING btree (town_id);

@ -81,8 +81,8 @@ services:
admin-web: admin-web:
image: ${DOCKER_REGISTRY:-static.ranneft.ru:25000}/autohero/admin-web:${IMAGE_TAG:-latest} image: ${DOCKER_REGISTRY:-static.ranneft.ru:25000}/autohero/admin-web:${IMAGE_TAG:-latest}
build: build:
context: ./admin-web context: .
dockerfile: Dockerfile dockerfile: admin-web/Dockerfile
ports: ports:
- "3002:80" - "3002:80"
depends_on: depends_on:

@ -117,11 +117,28 @@ Adjust only within the migration plans size tables; do not invent new keys wi
--- ---
## 8. Sign-off ## 8. Generation pipeline (PixelLab MCP)
**Product decision — hybrid (locked):** The **creative target** stays **painterly, Arcane-like** (sections 16). **Execution** for raster sprites in Cursor uses **PixelLab AI MCP** by default. PixelLab outputs are **pixel art**; they still must obey mood, palette, silhouette, and perspective here by **always** prepending the §1 English prompt prefix to every MCP `description`, then appending the row-specific brief from the sprite migration plan.
| Need | MCP tool (names may be prefixed in Cursor, e.g. `mcp_pixellab_*`) | Notes |
|------|-------------------------------------------------------------------|--------|
| Isometric ground / blocky terrain pieces | `create_isometric_tile` | Default `size` in API is 32; target **96×48** world rhombus may require upscale or `create_tiles_pro` with `tile_type` isometric — see [docs/pixellab-mcp-schema.md](pixellab-mcp-schema.md). |
| Props, buildings as sprites | `create_map_object` | Set `width` / `height` when known; optional `background_image` for style matching. |
| Heroes, NPCs, enemies (humanoid or quadruped) | `create_character` | `n_directions` 4 or 8; `size` canvas; quadrupeds need `body_type` + `template`. |
| Animation (phase 2) | `animate_character` | After `character_id` exists; use `get_character` for status and URLs. |
Jobs are **asynchronous**: creation returns quickly — poll `get_isometric_tile`, `get_map_object`, `get_character`, etc., until status is completed, then **persist files immediately** (see [.cursor/rules/pixellab-sprite-pipeline.mdc](../.cursor/rules/pixellab-sprite-pipeline.mdc)). Later, individual textures may be swapped for hand-painted or non-pixel art **without changing content IDs** if anchors and manifest keys stay the same.
**Authoritative tool list and parameters:** [https://api.pixellab.ai/mcp/docs](https://api.pixellab.ai/mcp/docs) (regenerated from server definitions; verify if behavior changes).
---
## 9. Sign-off
Art direction is **locked** for phase-1 sprite replacement when: Art direction is **locked** for phase-1 sprite replacement when:
- All new sprites are reviewed against §§16, - All new sprites are reviewed against §§16 and **§8** (MCP descriptions and saved assets),
- Palette and perspective exceptions are documented per asset only when technically required (e.g. HUD icons), - Palette and perspective exceptions are documented per asset only when technically required (e.g. HUD icons),
- Placeholders (if used) follow the same muted palette and anchor rules until final art lands. - Placeholders (if used) follow the same muted palette and anchor rules until final art lands.

@ -188,6 +188,11 @@ Naming convention:
| `prop` | `cart_broken` | `obj.prop.cart_broken.v1` | `poi` | roadside storytelling | | `prop` | `cart_broken` | `obj.prop.cart_broken.v1` | `poi` | roadside storytelling |
| `prop` | `sign_wood` | `obj.prop.sign_wood.v1` | `poi` | route marker | | `prop` | `sign_wood` | `obj.prop.sign_wood.v1` | `poi` | route marker |
| `prop` | `totem_bone` | `obj.prop.totem_bone.v1` | `poi_dark` | undead area marker | | `prop` | `totem_bone` | `obj.prop.totem_bone.v1` | `poi_dark` | undead area marker |
| `wall` | `cobble_<dir>` | `obj.wall.cobble_<dir>.v1` | `town_edge` | `<dir>``n,ne,e,se,s,sw,w,nw` — cobble segment/corner for isometric town ring |
| `wall` | `brick_<dir>` | `obj.wall.brick_<dir>.v1` | `town_edge` | fired brick wall, same 8-way set |
| `wall` | `palisade_<dir>` | `obj.wall.palisade_<dir>.v1` | `town_edge` | wooden palisade/log wall, same 8-way set |
Town plaza sprites (manifest texture keys, not `obj.*`): `building.civic.townhall.v0`, `prop.fountain.plaza.v0`, `prop.market.stall.v0`, `building.tavern.v0`, `building.tavern.v1`, `building.house.v2`.
## 3) Sound Cue Catalog (Gameplay + UI) ## 3) Sound Cue Catalog (Gameplay + UI)

@ -0,0 +1 @@
PIXELLAB_API_TOKEN=b87d979a-8027-405d-8e07-105ac494cc0b

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save