Fix city generation retries and remove city-wide economy mechanics
This commit is contained in:
1 parent
85df37c26e
commit
63f0c72ff3
21 files changed
+252
-585
No files matched your search
+38
-27
@@ -47,6 +47,28 @@ function progress(text: string, frac: number) {
|
||||
}
|
||||
const hideProgress = () => (overlay.hidden = true);
|
||||
|
||||
async function followCityJob(id: string, signal: AbortSignal) {
|
||||
history.replaceState(null, '', `/?create-city&job=${encodeURIComponent(id)}`);
|
||||
progress('Проверка задачи генерации…', 0.05);
|
||||
for (;;) {
|
||||
const response = await fetch(`/api/cities/jobs/${encodeURIComponent(id)}`, { signal });
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) history.replaceState(null, '', '/?create-city');
|
||||
throw new Error((await response.json()).error || `HTTP ${response.status}`);
|
||||
}
|
||||
const state = fromBinary(CityJobSchema, new Uint8Array(await response.arrayBuffer()));
|
||||
if (state.status === 'failed') {
|
||||
history.replaceState(null, '', '/?create-city');
|
||||
throw new Error(state.error || 'Не удалось создать город');
|
||||
}
|
||||
if (state.status === 'ready') {
|
||||
hideProgress(); history.replaceState(null, '', `/?play&room=${encodeURIComponent(state.roomId)}`); await showLobby(); return;
|
||||
}
|
||||
progress(state.status === 'queued' ? 'Город в очереди…' : `${state.stage || 'Подготовка города'}…`, state.status === 'queued' ? 0.05 : 0.5);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); signal.throwIfAborted();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ экраны
|
||||
|
||||
function showViewer(world: World) {
|
||||
@@ -166,27 +188,22 @@ async function showPicker(initial?: BBox) {
|
||||
});
|
||||
|
||||
goBtn.addEventListener('click', async () => {
|
||||
if (!bbox || goBtn.disabled) return;
|
||||
if ((!bbox && !new URLSearchParams(location.search).has('job')) || goBtn.disabled) return;
|
||||
goBtn.disabled = true;
|
||||
showErr(null);
|
||||
abort = new AbortController();
|
||||
(overlay.querySelector('[data-a=cancel]') as HTMLButtonElement).textContent = new URLSearchParams(location.search).has('create-city') ? 'Закрыть ожидание' : 'Отмена';
|
||||
try {
|
||||
if (new URLSearchParams(location.search).has('create-city')) {
|
||||
const existing = new URLSearchParams(location.search).get('job');
|
||||
if (existing) { await followCityJob(existing, abort.signal); return; }
|
||||
if (!bbox) return;
|
||||
const response = await fetch('/api/cities/jobs',{method:'POST',headers:{'Content-Type':'application/x-protobuf'},body:toBinary(CreateCitySchema,create(CreateCitySchema,{bbox,name:nameInput.value.trim() || 'Новый город',seed:1})),signal:abort.signal});
|
||||
if (!response.ok) throw new Error((await response.json()).error || `HTTP ${response.status}`);
|
||||
const job = fromBinary(CityJobSchema,new Uint8Array(await response.arrayBuffer()));
|
||||
for (;;) {
|
||||
const response = await fetch(`/api/cities/jobs/${encodeURIComponent(job.id)}`,{signal:abort.signal});
|
||||
if (!response.ok) throw new Error((await response.json()).error || `HTTP ${response.status}`);
|
||||
const state = fromBinary(CityJobSchema,new Uint8Array(await response.arrayBuffer()));
|
||||
if (state.status === 'failed') throw new Error(state.error || 'Не удалось создать город');
|
||||
if (state.status === 'ready') { hideProgress(); history.replaceState(null,'',`/?play&room=${encodeURIComponent(state.roomId)}`); await showLobby(); return; }
|
||||
progress(state.status === 'queued' ? 'Город в очереди…' : `${state.stage || 'Подготовка города'}…`,state.status === 'queued' ? 0.05 : 0.5);
|
||||
await new Promise(resolve => setTimeout(resolve,1000));
|
||||
abort.signal.throwIfAborted();
|
||||
}
|
||||
await followCityJob(job.id, abort.signal); return;
|
||||
}
|
||||
if (!bbox) return;
|
||||
progress('Загрузка данных OpenStreetMap…', 0.02);
|
||||
const els = await fetchOverpass(bbox, (s) => progress(s, 0.03), abort.signal);
|
||||
await runGeneration(els, bbox, nameInput.value.trim() || 'Новая карта');
|
||||
@@ -195,7 +212,7 @@ async function showPicker(initial?: BBox) {
|
||||
if ((ex as Error).name !== 'AbortError') showErr((ex as Error).message);
|
||||
else if (new URLSearchParams(location.search).has('create-city')) showErr('Ожидание закрыто. Принятая сервером задача продолжится; готовый город появится в каталоге комнат.');
|
||||
} finally {
|
||||
goBtn.disabled = !bbox || bboxSizeKm(bbox).km2 > MAX_KM2;
|
||||
goBtn.disabled = !new URLSearchParams(location.search).has('job') && (!bbox || bboxSizeKm(bbox).km2 > MAX_KM2);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -237,6 +254,15 @@ async function showPicker(initial?: BBox) {
|
||||
showErr((ex as Error).message);
|
||||
}
|
||||
});
|
||||
const pendingJob = new URLSearchParams(location.search).get('job');
|
||||
if (pendingJob && new URLSearchParams(location.search).has('create-city')) {
|
||||
abort = new AbortController(); goBtn.disabled = true;
|
||||
(overlay.querySelector('[data-a=cancel]') as HTMLButtonElement).textContent = 'Закрыть ожидание';
|
||||
try { await followCityJob(pendingJob, abort.signal); }
|
||||
catch (ex) {
|
||||
hideProgress(); showErr((ex as Error).name === 'AbortError' ? 'Ожидание закрыто. Задача продолжает выполняться; нажмите «Сгенерировать», чтобы продолжить ожидание.' : (ex as Error).message);
|
||||
} finally { goBtn.disabled = !new URLSearchParams(location.search).has('job') && (!bbox || bboxSizeKm(bbox).km2 > MAX_KM2); }
|
||||
}
|
||||
}
|
||||
|
||||
function bboxOfElements(els: OsmElement[]): BBox {
|
||||
@@ -326,14 +352,6 @@ async function showTiledCity(url: string, connection?: RoomConnection) {
|
||||
}
|
||||
const vote = document.createElement('span'); vote.className = 'room-vote'; vote.setAttribute('role','status'); controls.appendChild(vote);
|
||||
tiledViewer.root.querySelector('.viewer-top')!.appendChild(controls);
|
||||
const gameplay = document.createElement('div'); gameplay.className = 'row';
|
||||
const state = document.createElement('span'); state.className = 'room-gameplay'; state.setAttribute('role', 'status'); gameplay.appendChild(state);
|
||||
for (const [action, label] of [['supplies', 'Запасы · 2¤/жит.'], ['meal', 'Обед · 1¤/жит.'], ['rest', 'Отдых · 3¤/жит.']]) {
|
||||
const button = document.createElement('button'); button.className = 'btn'; button.dataset.action = action; button.textContent = label;
|
||||
button.title = 'Общая казна. Одна команда на игровой час; исполнение в следующую игровую минуту.';
|
||||
button.onclick = () => connection.intervene(action); gameplay.appendChild(button);
|
||||
}
|
||||
tiledViewer.root.querySelector('.viewer-top')!.appendChild(gameplay);
|
||||
}
|
||||
(window as any).viewer = viewer;
|
||||
hideProgress();
|
||||
@@ -370,13 +388,6 @@ async function showLobby() {
|
||||
status: text => { status.textContent = text; viewer?.setLoadStatus(text); },
|
||||
clock: c => { const clock = app.querySelector('.room-clock'); if (clock) clock.textContent = `День ${Math.floor(c.gameSeconds / 86400) + 1} · ${String(Math.floor(c.gameSeconds/3600)%24).padStart(2,'0')}:${String(Math.floor(c.gameSeconds/60)%60).padStart(2,'0')} · ×${c.speed}`; },
|
||||
vote: v => { const vote = app.querySelector('.room-vote'); if (vote) vote.textContent = v.active ? `Голосование за ×${v.speed}: ${v.yes}/${v.players}, ${v.remainingSeconds} с` : `${v.players} игроков`; },
|
||||
gameplay: g => {
|
||||
const state = app.querySelector('.room-gameplay');
|
||||
const names: Record<string, string> = { harvest: 'Урожай', illness: 'Болезнь', rain: 'Дождь', supplies: 'Запасы пополнены', meal: 'Общий обед', rest: 'Отдых' };
|
||||
const event = g.events.at(-1);
|
||||
if (state) state.textContent = `Казна ${g.treasury}¤ · Еда ${g.food} · Голод ${g.needs?.hunger ?? 0}% · Усталость ${g.needs?.fatigue ?? 0}% · Настроение ${g.needs?.morale ?? 0}%${g.pending.length ? ' · Команда в очереди' : ''}${event ? ` · ${names[event.kind] ?? event.kind}` : ''}`;
|
||||
for (const button of app.querySelectorAll<HTMLButtonElement>('[data-action]')) button.disabled = g.pending.length > 0 || g.minute < g.nextInterventionMinute;
|
||||
},
|
||||
});
|
||||
roomConnection = connection;
|
||||
};
|
||||
|
||||
+15
-7
@@ -21,6 +21,7 @@ export const OVERPASS_ENDPOINTS = [
|
||||
];
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 45_000;
|
||||
const SERVER_REQUEST_TIMEOUT_MS = 90_000;
|
||||
|
||||
function waitForRetry(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -73,7 +74,9 @@ out geom;`;
|
||||
}
|
||||
|
||||
export async function fetchOverpass(b: BBox, onStatus?: (s: string) => void, signal?: AbortSignal): Promise<OsmElement[]> {
|
||||
const query = buildQuery(b);
|
||||
const server = typeof window === 'undefined';
|
||||
const timeout = server ? SERVER_REQUEST_TIMEOUT_MS : REQUEST_TIMEOUT_MS;
|
||||
const query = server ? buildQuery(b).replace('[timeout:30]', '[timeout:60]') : buildQuery(b);
|
||||
let lastErr: unknown = null;
|
||||
for (const url of OVERPASS_ENDPOINTS) {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
@@ -85,19 +88,24 @@ export async function fetchOverpass(b: BBox, onStatus?: (s: string) => void, sig
|
||||
const timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
controller.abort();
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
}, timeout);
|
||||
try {
|
||||
onStatus?.(`Запрос к ${new URL(url).host}${attempt ? ' (повтор)' : ''} — до 45 секунд…`);
|
||||
onStatus?.(`Запрос к ${new URL(url).host}${attempt ? ' (повтор)' : ''} — до ${timeout / 1000} секунд…`);
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: 'data=' + encodeURIComponent(query),
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(server ? { 'User-Agent': 'citygen/0.2.0 (self-hosted OSM city generator)' } : {}) },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (res.status === 429 || res.status === 504) {
|
||||
lastErr = new Error(`Сервер перегружен (${res.status})`);
|
||||
lastErr = new Error(`Overpass ${new URL(url).host} временно отклонил запрос (${res.status})`);
|
||||
clearTimeout(timer);
|
||||
if (attempt === 0) await waitForRetry(3000, signal);
|
||||
if (attempt === 0) {
|
||||
const retry = Number(res.headers?.get('Retry-After'));
|
||||
const delay = Number.isFinite(retry) && retry > 0 ? Math.min(30_000, retry * 1000) : 3000;
|
||||
onStatus?.(`Overpass вернул ${res.status}. Повтор через ${Math.ceil(delay / 1000)} с…`);
|
||||
await waitForRetry(delay, signal);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@@ -108,7 +116,7 @@ export async function fetchOverpass(b: BBox, onStatus?: (s: string) => void, sig
|
||||
return json.elements as OsmElement[];
|
||||
} catch (e) {
|
||||
if (signal?.aborted) throw new DOMException('Отменено', 'AbortError');
|
||||
lastErr = timedOut ? new Error(`Таймаут ${new URL(url).host}: 45 секунд`) : e;
|
||||
lastErr = timedOut ? new Error(`Таймаут ${new URL(url).host}: ${timeout / 1000} секунд`) : e;
|
||||
break;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
|
||||
Reference in new issue
Block a user