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/dist/
frontend/.vite/
frontend/scripts/.enemy-south-pixellab-state.json
# IDE
.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
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html
COPY admin-web/nginx.conf /etc/nginx/conf.d/default.conf
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

@ -2718,6 +2718,7 @@
<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>
<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 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>

@ -2,10 +2,16 @@ server {
listen 80;
server_name _;
location /town-editor/ {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /town-editor/index.html;
}
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri /index.html;
try_files $uri $uri/ /index.html;
}
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": [
{
"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",
"title": "AutoHero — 0.2.0",

@ -21,6 +21,11 @@ type MessageSender interface {
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.
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
}
switch msg.Type {
case "request_nearby_heroes":
e.handleNearbyHeroesRequest(msg)
case "activate_buff":
e.handleActivateBuff(msg)
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.
func (e *Engine) handleActivateBuff(msg IncomingMessage) {
var payload model.ActivateBuffPayload
@ -1818,7 +1895,6 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
victoryDrops = e.onEnemyDeath(hero, enemy, now)
}
dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
e.applyOfflineDigest(dctx, cs.HeroID, hero, now, storage.OfflineDigestDelta{
MonstersKilled: 1,
@ -1829,7 +1905,6 @@ func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
})
cancel()
e.emitEvent(model.CombatEvent{
Type: "combat_end",
HeroID: cs.HeroID,

@ -386,11 +386,12 @@ func (e *Engine) pushHeroMeetStartLocked(heroID int64, lingerMs int64, meetPhase
hm.SyncToHero()
px, py := ph.Hero.PositionX, ph.Hero.PositionY
partner := model.HeroMeetPartnerSnapshot{
ID: ph.Hero.ID,
Name: ph.Hero.Name,
Level: ph.Hero.Level,
PositionX: px,
PositionY: py,
ID: ph.Hero.ID,
Name: ph.Hero.Name,
Level: ph.Hero.Level,
ModelVariant: ph.Hero.ModelVariant,
PositionX: px,
PositionY: py,
}
anyOnline := e.heroMeetAnySubscriberOnline(heroID, pid)
var promptEnds *time.Time

@ -8,6 +8,7 @@ import (
"fmt"
"io"
"log/slog"
"math"
"net/http"
"runtime"
"strconv"
@ -162,7 +163,7 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
return nil
}
s := &adminLiveMovementJSON{
Online: true,
Online: true,
}
if !hm.RestUntil.IsZero() {
t := hm.RestUntil
@ -732,6 +733,299 @@ func (h *AdminHandler) ListTownNPCsForQuests(w http.ResponseWriter, r *http.Requ
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).
// GET /admin/content/quests
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
}
// Default radius: 500 units, max 50 heroes.
radius := 500.0
// Default radius: 50 units, max 5 heroes.
radius := 50.0
if rStr := r.URL.Query().Get("radius"); rStr != "" {
if parsed, err := strconv.ParseFloat(rStr, 64); err == nil && parsed > 0 {
radius = parsed
}
}
if radius > 2000 {
radius = 2000
if radius > 100 {
radius = 100
}
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 {
h.logger.Error("failed to get nearby heroes", "hero_id", hero.ID, "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{

@ -34,6 +34,11 @@ type Hub struct {
mu sync.RWMutex
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.
// Set by the engine to push initial state. May be nil.
OnConnect func(heroID int64)
@ -69,6 +74,8 @@ func NewHub(logger *slog.Logger) *Hub {
broadcast: make(chan model.WSEnvelope, 256),
Incoming: make(chan model.ClientMessage, 256),
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++
}
}
if remaining == 0 {
h.removeNearbySubscriptionsLocked(heroID)
}
h.mu.Unlock()
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)
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()
defer h.mu.RUnlock()
nearbySubs = h.nearbySubscriptions[heroID]
for client := range h.clients {
if client.heroID == heroID {
select {
@ -156,8 +194,66 @@ func (h *Hub) SendToHero(heroID int64, msgType string, payload any) {
h.unregister <- c
}(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).

@ -13,57 +13,58 @@ const (
)
type Hero struct {
ID int64 `json:"id"`
TelegramID int64 `json:"telegramId"`
Name string `json:"name"`
HP int `json:"hp"`
MaxHP int `json:"maxHp"`
Attack int `json:"attack"`
Defense int `json:"defense"`
Speed float64 `json:"speed"` // attacks per second base rate
Strength int `json:"strength"`
Constitution int `json:"constitution"`
Agility int `json:"agility"`
Luck int `json:"luck"`
State GameState `json:"state"`
ID int64 `json:"id"`
TelegramID int64 `json:"telegramId"`
Name string `json:"name"`
ModelVariant int `json:"modelVariant"`
HP int `json:"hp"`
MaxHP int `json:"maxHp"`
Attack int `json:"attack"`
Defense int `json:"defense"`
Speed float64 `json:"speed"` // attacks per second base rate
Strength int `json:"strength"`
Constitution int `json:"constitution"`
Agility int `json:"agility"`
Luck int `json:"luck"`
State GameState `json:"state"`
Gear map[EquipmentSlot]*GearItem `json:"gear"`
// Inventory holds unequipped gear (order matches DB slot_index). Max length: MaxInventorySlots.
Inventory []*GearItem `json:"inventory,omitempty"`
Buffs []ActiveBuff `json:"buffs,omitempty"`
Debuffs []ActiveDebuff `json:"debuffs,omitempty"`
Inventory []*GearItem `json:"inventory,omitempty"`
Buffs []ActiveBuff `json:"buffs,omitempty"`
Debuffs []ActiveDebuff `json:"debuffs,omitempty"`
// DebuffCatalog is effective debuff definitions (durations from live catalog); not persisted.
DebuffCatalog map[string]DebuffJSON `json:"debuffCatalog,omitempty"`
Gold int64 `json:"gold"`
XP int64 `json:"xp"`
Level int `json:"level"`
XPToNext int64 `json:"xpToNext"`
AttackSpeed float64 `json:"attackSpeed,omitempty"`
AttackPower int `json:"attackPower,omitempty"`
DefensePower int `json:"defensePower,omitempty"`
MoveSpeed float64 `json:"moveSpeed,omitempty"`
PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
Potions int `json:"potions"`
ReviveCount int `json:"reviveCount"`
SubscriptionActive bool `json:"subscriptionActive"`
SubscriptionExpiresAt *time.Time `json:"subscriptionExpiresAt,omitempty"`
DebuffCatalog map[string]DebuffJSON `json:"debuffCatalog,omitempty"`
Gold int64 `json:"gold"`
XP int64 `json:"xp"`
Level int `json:"level"`
XPToNext int64 `json:"xpToNext"`
AttackSpeed float64 `json:"attackSpeed,omitempty"`
AttackPower int `json:"attackPower,omitempty"`
DefensePower int `json:"defensePower,omitempty"`
MoveSpeed float64 `json:"moveSpeed,omitempty"`
PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
Potions int `json:"potions"`
ReviveCount int `json:"reviveCount"`
SubscriptionActive bool `json:"subscriptionActive"`
SubscriptionExpiresAt *time.Time `json:"subscriptionExpiresAt,omitempty"`
// Deprecated: BuffFreeChargesRemaining is the legacy shared counter. Use BuffCharges instead.
BuffFreeChargesRemaining int `json:"buffFreeChargesRemaining"`
BuffFreeChargesRemaining int `json:"buffFreeChargesRemaining"`
// Deprecated: BuffQuotaPeriodEnd is the legacy shared period end. Use BuffCharges instead.
BuffQuotaPeriodEnd *time.Time `json:"buffQuotaPeriodEnd,omitempty"`
BuffQuotaPeriodEnd *time.Time `json:"buffQuotaPeriodEnd,omitempty"`
// BuffCharges holds per-buff-type free charge state (remaining count + period window).
BuffCharges map[string]BuffChargeState `json:"buffCharges"`
// Stat tracking for achievements.
TotalKills int `json:"totalKills"`
EliteKills int `json:"eliteKills"`
TotalDeaths int `json:"totalDeaths"`
TotalKills int `json:"totalKills"`
EliteKills int `json:"eliteKills"`
TotalDeaths int `json:"totalDeaths"`
KillsSinceDeath int `json:"killsSinceDeath"`
LegendaryDrops int `json:"legendaryDrops"`
LegendaryDrops int `json:"legendaryDrops"`
// Movement state (persisted to DB for reconnect recovery).
CurrentTownID *int64 `json:"currentTownId,omitempty"`
DestinationTownID *int64 `json:"destinationTownId,omitempty"`
RestKind RestKind `json:"restKind,omitempty"`
CurrentTownID *int64 `json:"currentTownId,omitempty"`
DestinationTownID *int64 `json:"destinationTownId,omitempty"`
RestKind RestKind `json:"restKind,omitempty"`
// ExcursionPhase is set when a mini-adventure session is active (out/wild/return); empty otherwise.
ExcursionPhase ExcursionPhase `json:"excursionPhase,omitempty"`
// ExcursionKind is "roadside" | "adventure" | "town" during attractor-based sessions; empty otherwise.
@ -80,9 +81,9 @@ type Hero struct {
// WsDisconnectedAt is when the last WebSocket session ended (DB only; optional telemetry).
WsDisconnectedAt *time.Time `json:"-"`
// ChangelogAckVersion is the internal/version.Version the player last dismissed in the UI (DB only).
ChangelogAckVersion string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ChangelogAckVersion string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// BuffChargeState tracks the remaining free charges and period window for a single buff type.
@ -139,7 +140,7 @@ func (h *Hero) LevelUp() bool {
h.MaxHP += hpBase + h.Constitution/6
}
if cfg.LevelUpATKEvery > 0 && h.Level%int(cfg.LevelUpATKEvery) == 0 {
h.Attack ++
h.Attack++
}
if cfg.LevelUpDEFEvery > 0 && h.Level%int(cfg.LevelUpDEFEvery) == 0 {
h.Defense++
@ -162,29 +163,29 @@ func (h *Hero) LevelUp() bool {
}
type statBonuses struct {
strengthBonus int
constitutionBonus int
agilityBonus int
attackMultiplier float64
speedMultiplier float64
defenseMultiplier float64
critChanceBonus float64
critDamageBonus float64
blockChanceBonus float64
strengthBonus int
constitutionBonus int
agilityBonus int
attackMultiplier float64
speedMultiplier float64
defenseMultiplier float64
critChanceBonus float64
critDamageBonus float64
blockChanceBonus float64
movementMultiplier float64
}
func (h *Hero) activeStatBonuses(now time.Time) statBonuses {
out := statBonuses{
strengthBonus: 0,
constitutionBonus: 0,
agilityBonus: 0,
attackMultiplier: 1.0,
speedMultiplier: 1.0,
defenseMultiplier: 1.0,
critChanceBonus: 0.0,
critDamageBonus: 0.0,
blockChanceBonus: 0.0,
strengthBonus: 0,
constitutionBonus: 0,
agilityBonus: 0,
attackMultiplier: 1.0,
speedMultiplier: 1.0,
defenseMultiplier: 1.0,
critChanceBonus: 0.0,
critDamageBonus: 0.0,
blockChanceBonus: 0.0,
movementMultiplier: 1.0,
}
for _, ab := range h.Buffs {

@ -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"`
}
// 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.
type ErrorPayload struct {
Code string `json:"code"`
@ -251,6 +277,12 @@ type ErrorPayload struct {
// --- 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.
type ActivateBuffPayload struct {
BuffType string `json:"buffType"`
@ -286,11 +318,12 @@ type ExcursionEndPayload struct{}
// HeroMeetPartnerSnapshot is the other hero for UI (render + name).
type HeroMeetPartnerSnapshot struct {
ID int64 `json:"id"`
Name string `json:"name"`
Level int `json:"level"`
PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
ID int64 `json:"id"`
Name string `json:"name"`
Level int `json:"level"`
ModelVariant int `json:"modelVariant"`
PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
}
// HeroMeetStartPayload begins a paired meet session (server → client).

@ -120,6 +120,8 @@ func New(deps Deps) *chi.Mux {
r.Put("/content/gear/{gearId}", adminH.ContentUpdateGear)
r.Get("/quests/towns", adminH.ListTownsForQuests)
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.Delete("/heroes/{heroId}", adminH.DeleteHero)
r.Get("/towns", adminH.ListTowns)

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

@ -6,6 +6,7 @@ import (
"fmt"
"math/rand"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@ -19,6 +20,36 @@ type QuestStore struct {
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.
func NewQuestStore(pool *pgxpool.Pool) *QuestStore {
return &QuestStore{pool: pool}
@ -174,6 +205,155 @@ func (s *QuestStore) ListBuildingsByTown(ctx context.Context, townID int64) ([]m
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).
func (s *QuestStore) ListAllBuildings(ctx context.Context) ([]model.TownBuilding, error) {
rows, err := s.pool.Query(ctx, `
@ -610,10 +790,10 @@ func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID in
type collectQuest struct {
hqID int64
targetCount int
progress int
dropChance float64
targetEnemyType *string
targetCount int
progress int
dropChance float64
targetEnemyType *string
targetEnemyArchetype *string
}
var cqs []collectQuest

@ -3,4 +3,4 @@
package version
// 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:
image: ${DOCKER_REGISTRY:-static.ranneft.ru:25000}/autohero/admin-web:${IMAGE_TAG:-latest}
build:
context: ./admin-web
dockerfile: Dockerfile
context: .
dockerfile: admin-web/Dockerfile
ports:
- "3002:80"
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:
- 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),
- 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` | `sign_wood` | `obj.prop.sign_wood.v1` | `poi` | route 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)

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