Compare commits

..

No commits in common. '9a801d95572d449ca99245091c6e1d86a82e756a' and 'eec798e57bb7e7e0c2ba1cf22176b7078487d70e' have entirely different histories.

@ -1,38 +0,0 @@
---
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,7 +9,6 @@ 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,23 +1,8 @@
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 admin-web/nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY admin-web/index.html /usr/share/nginx/html/index.html COPY 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,7 +2718,6 @@
<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,16 +2,10 @@ 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 $uri/ /index.html; try_files $uri /index.html;
} }
location /admin-api/ { location /admin-api/ {

@ -1,38 +0,0 @@
<!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>

@ -1,17 +0,0 @@
{
"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"
}
}

@ -1,879 +0,0 @@
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();

@ -1,125 +0,0 @@
* {
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;
}

@ -1,22 +0,0 @@
{
"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"]
}

@ -1,32 +0,0 @@
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,13 +1,5 @@
{ {
"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,11 +21,6 @@ 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
@ -439,8 +434,6 @@ 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":
@ -468,76 +461,6 @@ 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
@ -1895,6 +1818,7 @@ 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,
@ -1905,6 +1829,7 @@ 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,

@ -386,12 +386,11 @@ func (e *Engine) pushHeroMeetStartLocked(heroID int64, lingerMs int64, meetPhase
hm.SyncToHero() hm.SyncToHero()
px, py := ph.Hero.PositionX, ph.Hero.PositionY px, py := ph.Hero.PositionX, ph.Hero.PositionY
partner := model.HeroMeetPartnerSnapshot{ partner := model.HeroMeetPartnerSnapshot{
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,
} }
anyOnline := e.heroMeetAnySubscriberOnline(heroID, pid) anyOnline := e.heroMeetAnySubscriberOnline(heroID, pid)
var promptEnds *time.Time var promptEnds *time.Time

@ -8,7 +8,6 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"math"
"net/http" "net/http"
"runtime" "runtime"
"strconv" "strconv"
@ -163,7 +162,7 @@ func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
return nil return nil
} }
s := &adminLiveMovementJSON{ s := &adminLiveMovementJSON{
Online: true, Online: true,
} }
if !hm.RestUntil.IsZero() { if !hm.RestUntil.IsZero() {
t := hm.RestUntil t := hm.RestUntil
@ -733,299 +732,6 @@ 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: 50 units, max 5 heroes. // Default radius: 500 units, max 50 heroes.
radius := 50.0 radius := 500.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 > 100 { if radius > 2000 {
radius = 100 radius = 2000
} }
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, 5) nearby, err := h.store.GetNearbyHeroes(r.Context(), hero.ID, posX, posY, radius, 50)
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,11 +34,6 @@ 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)
@ -74,8 +69,6 @@ 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{}),
} }
} }
@ -109,9 +102,6 @@ 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)
@ -154,36 +144,8 @@ 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()
nearbySubs = h.nearbySubscriptions[heroID] defer h.mu.RUnlock()
for client := range h.clients { for client := range h.clients {
if client.heroID == heroID { if client.heroID == heroID {
select { select {
@ -194,66 +156,8 @@ 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).

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

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

@ -1,11 +0,0 @@
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,32 +243,6 @@ 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"`
@ -277,12 +251,6 @@ 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"`
@ -318,12 +286,11 @@ type ExcursionEndPayload struct{}
// HeroMeetPartnerSnapshot is the other hero for UI (render + name). // HeroMeetPartnerSnapshot is the other hero for UI (render + name).
type HeroMeetPartnerSnapshot struct { 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"`
} }
// HeroMeetStartPayload begins a paired meet session (server → client). // HeroMeetStartPayload begins a paired meet session (server → client).

@ -120,8 +120,6 @@ 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.hero_model_variant, h.id, h.telegram_id, h.name,
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,14 +265,12 @@ 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,
@ -286,24 +284,22 @@ 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, $3, $4, $5, $6, $7,
$4, $5, $6, $7, $8, $8, $9, $10, $11,
$9, $10, $11, $12, $12,
$13, $13, $14, $15,
$14, $15, $16, $16, $17, $18,
$17, $18, $19, $19, $20, $21,
$20, $21, $22, $22, $23, $24,
$23, $24, $25, $25, $26, $27, $28, $29,
$26, $27, $28, $29, $30, $30,
$31, $31, $32,
$32, $33, $33, $34
$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),
@ -557,28 +553,26 @@ func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
query := ` query := `
UPDATE heroes SET UPDATE heroes SET
hero_model_variant = $1, hp = $1, max_hp = $2,
hp = $2, max_hp = $3, attack = $3, defense = $4, speed = $5,
attack = $4, defense = $5, speed = $6, strength = $6, constitution = $7, agility = $8, luck = $9,
strength = $7, constitution = $8, agility = $9, luck = $10, state = $10,
state = $11, gold = $11, xp = $12, level = $13,
gold = $12, xp = $13, level = $14, revive_count = $14, subscription_active = $15, subscription_expires_at = $16,
revive_count = $15, subscription_active = $16, subscription_expires_at = $17, buff_free_charges_remaining = $17, buff_quota_period_end = $18, buff_charges = $19,
buff_free_charges_remaining = $18, buff_quota_period_end = $19, buff_charges = $20, position_x = $20, position_y = $21, potions = $22,
position_x = $21, position_y = $22, potions = $23, total_kills = $23, elite_kills = $24, total_deaths = $25,
total_kills = $24, elite_kills = $25, total_deaths = $26, kills_since_death = $26, legendary_drops = $27,
kills_since_death = $27, legendary_drops = $28, last_online_at = $28,
last_online_at = $29, updated_at = $29,
updated_at = $30, destination_town_id = $30,
destination_town_id = $31, current_town_id = $31,
current_town_id = $32, town_pause = $32
town_pause = $33 WHERE id = $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,
@ -707,7 +701,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.ModelVariant, &h.ID, &h.TelegramID, &h.Name,
&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,
@ -726,9 +720,6 @@ 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)
@ -745,7 +736,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.ModelVariant, &h.ID, &h.TelegramID, &h.Name,
&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,
@ -767,9 +758,6 @@ 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)
@ -968,12 +956,11 @@ func unmarshalBuffCharges(raw []byte) map[string]model.BuffChargeState {
// HeroSummary is a lightweight projection of a hero for nearby-heroes queries. // HeroSummary is a lightweight projection of a hero for nearby-heroes queries.
type HeroSummary struct { 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"`
} }
// UpdateOnlineStatus updates last_online_at and position for shared-world presence. // UpdateOnlineStatus updates last_online_at and position for shared-world presence.
@ -990,21 +977,24 @@ 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 < 1 { if limit <= 0 {
limit = 1 limit = 20
} }
if limit > 5 { if limit > 100 {
limit = 5 limit = 100
} }
cutoff := time.Now().Add(-2 * time.Minute)
rows, err := s.pool.Query(ctx, ` rows, err := s.pool.Query(ctx, `
SELECT id, name, level, hero_model_variant, position_x, position_y SELECT id, name, level, position_x, position_y
FROM heroes FROM heroes
WHERE id != $1 WHERE id != $1
AND sqrt(power(position_x - $2, 2) + power(position_y - $3, 2)) <= $4 AND last_online_at > $2
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 $5 LIMIT $6
`, heroID, posX, posY, radius, limit) `, heroID, cutoff, 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)
} }
@ -1013,12 +1003,9 @@ 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.ModelVariant, &h.PositionX, &h.PositionY); err != nil { if err := rows.Scan(&h.ID, &h.Name, &h.Level, &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 {
@ -1056,10 +1043,6 @@ 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,7 +6,6 @@ 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"
@ -20,36 +19,6 @@ 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}
@ -205,155 +174,6 @@ 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, `
@ -790,10 +610,10 @@ func (s *QuestStore) IncrementCollectItemProgress(ctx context.Context, heroID in
type collectQuest struct { type collectQuest struct {
hqID int64 hqID int64
targetCount int targetCount int
progress int progress int
dropChance float64 dropChance float64
targetEnemyType *string targetEnemyType *string
targetEnemyArchetype *string targetEnemyArchetype *string
} }
var cqs []collectQuest var cqs []collectQuest

@ -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.3.0-dev" const Version = "0.2.0-dev"

@ -1,16 +0,0 @@
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);

@ -1,26 +0,0 @@
-- 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: . context: ./admin-web
dockerfile: admin-web/Dockerfile dockerfile: Dockerfile
ports: ports:
- "3002:80" - "3002:80"
depends_on: depends_on:

@ -117,28 +117,11 @@ Adjust only within the migration plans size tables; do not invent new keys wi
--- ---
## 8. Generation pipeline (PixelLab MCP) ## 8. Sign-off
**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 and **§8** (MCP descriptions and saved assets), - All new sprites are reviewed against §§16,
- 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,11 +188,6 @@ 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)

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

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