Add city gameplay, snapshot migrations, administration and Compose operations
This commit is contained in:
1 parent
3b1fcd996a
commit
85df37c26e
24 files changed
+1042
-55
No files matched your search
@@ -6,3 +6,4 @@ server/target
|
|||||||
data
|
data
|
||||||
world.json
|
world.json
|
||||||
public/cities
|
public/cities
|
||||||
|
.env
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Optional administrator API; at least 32 random characters. Empty disables it.
|
||||||
|
CITYGEN_ADMIN_TOKEN=
|
||||||
|
CITYGEN_HOST_IP=127.0.0.1
|
||||||
|
CITYGEN_PORT=3000
|
||||||
|
CITYGEN_AGENTS=1000
|
||||||
|
CITYGEN_MAX_ROOMS=5
|
||||||
|
CITYGEN_MEMORY=4g
|
||||||
|
CITYGEN_CPUS=4
|
||||||
|
CITYGEN_SIM_THREADS=4
|
||||||
|
CITYGEN_SAVE_QUOTA_BYTES=2147483648
|
||||||
@@ -6,3 +6,4 @@ world.json
|
|||||||
server/target/
|
server/target/
|
||||||
data/
|
data/
|
||||||
public/cities/
|
public/cities/
|
||||||
|
.env
|
||||||
@@ -17,9 +17,13 @@ COPY --from=client /app/node_modules node_modules
|
|||||||
COPY --from=client /app/dist dist
|
COPY --from=client /app/dist dist
|
||||||
COPY --from=client /app/src src
|
COPY --from=client /app/src src
|
||||||
COPY --from=client /app/tools tools
|
COPY --from=client /app/tools tools
|
||||||
|
COPY --from=client /app/samples samples
|
||||||
COPY --from=client /app/package.json package.json
|
COPY --from=client /app/package.json package.json
|
||||||
COPY --from=server /app/server/target/release/citygen-server /usr/local/bin/citygen-server
|
COPY --from=server /app/server/target/release/citygen-server /usr/local/bin/citygen-server
|
||||||
|
RUN mkdir -p /app/public/cities /app/data && chown -R node:node /app
|
||||||
ENV CITYGEN_BIND=0.0.0.0:3000
|
ENV CITYGEN_BIND=0.0.0.0:3000
|
||||||
|
USER node
|
||||||
|
HEALTHCHECK --interval=15s --timeout=3s --start-period=30s --retries=3 CMD node -e "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||||
VOLUME ["/app/public/cities", "/app/data"]
|
VOLUME ["/app/public/cities", "/app/data"]
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["citygen-server"]
|
CMD ["citygen-server"]
|
||||||
@@ -4,6 +4,13 @@
|
|||||||
комнаты до 30 наблюдателей, жители по расписанию, общий календарь и голосования,
|
комнаты до 30 наблюдателей, жители по расписанию, общий календарь и голосования,
|
||||||
сохранения и очередь генерации. Браузерный генератор ниже остаётся режимом отладки.
|
сохранения и очередь генерации. Браузерный генератор ниже остаётся режимом отладки.
|
||||||
|
|
||||||
|
## Эксплуатация и игровой слой
|
||||||
|
|
||||||
|
Для запуска через Docker Compose см. [deploy/README.md](deploy/README.md).
|
||||||
|
В комнате доступны казна, запасы еды, потребности населения, события и три
|
||||||
|
ограниченных действия игроков. Подробности модели и настроек —
|
||||||
|
[server/README.md](server/README.md). Интерфейс оператора: `/?admin`.
|
||||||
|
|
||||||
## Сетевая игра
|
## Сетевая игра
|
||||||
|
|
||||||
Нужны Node.js 22+ и Rust stable (на Windows — установленный MSVC linker).
|
Нужны Node.js 22+ и Rust stable (на Windows — установленный MSVC linker).
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
/** Operator UI. The token lives only in this page's memory, never in a URL or storage. */
|
||||||
|
export function showAdmin(root: HTMLElement) {
|
||||||
|
root.replaceChildren();
|
||||||
|
const panel = document.createElement('section'); panel.className = 'card picker-panel'; panel.style.maxWidth = '850px';
|
||||||
|
panel.innerHTML = `<h1>Администрирование</h1><label class="field">Токен оператора <input type="password" autocomplete="off" aria-label="Токен оператора"></label><button class="btn" data-refresh>Обновить</button><p role="status"></p><div data-results></div><a class="btn" href="/?play">Комнаты</a>`;
|
||||||
|
root.appendChild(panel);
|
||||||
|
const status = panel.querySelector<HTMLElement>('[role=status]')!;
|
||||||
|
const results = panel.querySelector<HTMLElement>('[data-results]')!;
|
||||||
|
const request = async (path: string, method = 'GET') => {
|
||||||
|
const token = panel.querySelector('input')!.value;
|
||||||
|
const response = await fetch(path, { method, headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(25_000) });
|
||||||
|
const data = await response.json();
|
||||||
|
if (!response.ok) throw new Error(data.error ?? `HTTP ${response.status}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
const refresh = async () => {
|
||||||
|
results.replaceChildren(); status.textContent = 'Загрузка…';
|
||||||
|
try {
|
||||||
|
const data = await request('/api/admin');
|
||||||
|
status.textContent = `Снимки: ${(data.saveBytes / 1048576).toFixed(1)} / ${(data.saveQuotaBytes / 1048576).toFixed(1)} МиБ`;
|
||||||
|
for (const room of data.rooms) {
|
||||||
|
const row = document.createElement('p');
|
||||||
|
row.textContent = `${room.id} · ${room.cityId} · ${room.players} игроков · тик ${room.tick} · сохранён ${room.savedTick} · ошибок записи ${room.saveErrors} `;
|
||||||
|
const button = document.createElement('button'); button.className = 'btn'; button.textContent = 'Сохранить'; button.disabled = !room.active;
|
||||||
|
button.onclick = async () => {
|
||||||
|
button.disabled = true;
|
||||||
|
try { await request(`/api/admin/rooms/${encodeURIComponent(room.id)}/checkpoint`, 'POST'); await refresh(); }
|
||||||
|
catch (e) { status.textContent = (e as Error).message; button.disabled = false; }
|
||||||
|
};
|
||||||
|
row.appendChild(button); results.appendChild(row);
|
||||||
|
}
|
||||||
|
for (const snapshot of data.snapshots) if (snapshot.error) {
|
||||||
|
const row = document.createElement('p'); row.textContent = `${snapshot.id}: ${snapshot.error}`; results.appendChild(row);
|
||||||
|
}
|
||||||
|
} catch (e) { status.textContent = (e as Error).message; }
|
||||||
|
};
|
||||||
|
panel.querySelector<HTMLButtonElement>('[data-refresh]')!.onclick = () => { void refresh(); };
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
||||||
import { ClientMessageSchema, ServerMessageSchema, type ClientMessage, type Welcome, type Clock, type VoteState } from './generated/city_pb';
|
import { ClientMessageSchema, ServerMessageSchema, type ClientMessage, type Welcome, type Clock, type VoteState, type Gameplay } from './generated/city_pb';
|
||||||
import type { Bounds } from '../src/city/format';
|
import type { Bounds } from '../src/city/format';
|
||||||
import { EntityState } from './entities';
|
import { EntityState } from './entities';
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ export class RoomConnection {
|
|||||||
constructor(room: string, nickname: string, private events: {
|
constructor(room: string, nickname: string, private events: {
|
||||||
welcome: (welcome: Welcome) => void; status: (text: string) => void;
|
welcome: (welcome: Welcome) => void; status: (text: string) => void;
|
||||||
clock: (clock: Clock) => void; vote: (vote: VoteState) => void;
|
clock: (clock: Clock) => void; vote: (vote: VoteState) => void;
|
||||||
|
gameplay: (state: Gameplay) => void;
|
||||||
}) {
|
}) {
|
||||||
const url = new URL(`/api/rooms/${encodeURIComponent(room)}/ws`, location.href);
|
const url = new URL(`/api/rooms/${encodeURIComponent(room)}/ws`, location.href);
|
||||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
@@ -42,6 +43,7 @@ export class RoomConnection {
|
|||||||
} else events.status(b.value.code === 'VOTE_LIMIT' ? 'Дождитесь завершения голосования или 30-секундного интервала между предложениями' : `${b.value.code}: ${b.value.message}`);
|
} else events.status(b.value.code === 'VOTE_LIMIT' ? 'Дождитесь завершения голосования или 30-секундного интервала между предложениями' : `${b.value.code}: ${b.value.message}`);
|
||||||
break;
|
break;
|
||||||
case 'clock': events.clock(b.value); break;
|
case 'clock': events.clock(b.value); break;
|
||||||
|
case 'gameplay': events.gameplay(b.value); break;
|
||||||
case 'voteState': events.vote(b.value); break;
|
case 'voteState': events.vote(b.value); break;
|
||||||
}
|
}
|
||||||
} catch (error) { events.status((error as Error).message); this.destroy(); }
|
} catch (error) { events.status((error as Error).message); this.destroy(); }
|
||||||
@@ -64,5 +66,6 @@ export class RoomConnection {
|
|||||||
}
|
}
|
||||||
propose(speed: number) { this.send({ case: 'propose', value: { speed } }); }
|
propose(speed: number) { this.send({ case: 'propose', value: { speed } }); }
|
||||||
vote(yes: boolean) { this.send({ case: 'vote', value: { yes } }); }
|
vote(yes: boolean) { this.send({ case: 'vote', value: { yes } }); }
|
||||||
|
intervene(action: string) { this.send({ case: 'intervene', value: { action } }); }
|
||||||
destroy() { this.closed = true; clearTimeout(this.retry); clearInterval(this.ping); this.socket.close(); this.entities.clear(); }
|
destroy() { this.closed = true; clearTimeout(this.retry); clearInterval(this.ping); this.socket.close(); this.entities.clear(); }
|
||||||
}
|
}
|
||||||
+147
-15
@@ -10,7 +10,7 @@ import type { Message } from "@bufbuild/protobuf";
|
|||||||
* Describes the file city.proto.
|
* Describes the file city.proto.
|
||||||
*/
|
*/
|
||||||
export const file_city: GenFile = /*@__PURE__*/
|
export const file_city: GenFile = /*@__PURE__*/
|
||||||
fileDesc("CgpjaXR5LnByb3RvEgRjaXR5IkIKBFJlY3QSDQoFbWluX3gYASABKBESDQoFbWluX3kYAiABKBESDQoFbWF4X3gYAyABKBESDQoFbWF4X3kYBCABKBEiPgoESm9pbhIMCgRyb29tGAEgASgJEhYKDmNsaWVudF92ZXJzaW9uGAIgASgNEhAKCG5pY2tuYW1lGAMgASgJIjIKCVN1YnNjcmliZRIYCgRyZWN0GAEgASgLMgouY2l0eS5SZWN0EgsKA2xvZBgCIAEoDSIVCgRQaW5nEg0KBW5vbmNlGAEgASgNIhgKB1Byb3Bvc2USDQoFc3BlZWQYASABKA0iEwoEVm90ZRILCgN5ZXMYASABKAgiNgoKQ3JlYXRlQ2l0eRIMCgRiYm94GAEgAygBEgwKBG5hbWUYAiABKAkSDAoEc2VlZBgDIAEoDSJlCgdDaXR5Sm9iEgoKAmlkGAEgASgJEg8KB2NpdHlfaWQYAiABKAkSDgoGc3RhdHVzGAMgASgJEg8KB3Jvb21faWQYBCABKAkSDQoFZXJyb3IYBSABKAkSDQoFc3RhZ2UYBiABKAkiswEKDUNsaWVudE1lc3NhZ2USGgoEam9pbhgBIAEoCzIKLmNpdHkuSm9pbkgAEiQKCXN1YnNjcmliZRgCIAEoCzIPLmNpdHkuU3Vic2NyaWJlSAASGgoEcGluZxgDIAEoCzIKLmNpdHkuUGluZ0gAEiAKB3Byb3Bvc2UYBCABKAsyDS5jaXR5LlByb3Bvc2VIABIaCgR2b3RlGAUgASgLMgouY2l0eS5Wb3RlSABCBgoEYm9keSI3CgVBZ2VudBIKCgJpZBgBIAEoDRIJCgF4GAIgASgREgkKAXkYAyABKBESDAoEa2luZBgEIAEoDSJrCgdXZWxjb21lEgwKBHJvb20YASABKAkSDAoEdGljaxgCIAEoBBIZChFjaXR5X21hbmlmZXN0X3VybBgDIAEoCRIPCgdzZXNzaW9uGAQgASgJEhgKEHByb3RvY29sX3ZlcnNpb24YBSABKA0iNwoIU25hcHNob3QSDAoEdGljaxgBIAEoBBIdCghlbnRpdGllcxgCIAMoCzILLmNpdHkuQWdlbnQiZAoFRGVsdGESDAoEdGljaxgBIAEoBBIcCgdzcGF3bmVkGAIgAygLMgsuY2l0eS5BZ2VudBIcCgdjaGFuZ2VkGAMgAygLMgsuY2l0eS5BZ2VudBIRCglkZXNwYXduZWQYBCADKA0iJgoFRXJyb3ISDAoEY29kZRgBIAEoCRIPCgdtZXNzYWdlGAIgASgJImMKCVZvdGVTdGF0ZRIOCgZhY3RpdmUYASABKAgSDQoFc3BlZWQYAiABKA0SCwoDeWVzGAMgASgNEg8KB3BsYXllcnMYBCABKA0SGQoRcmVtYWluaW5nX3NlY29uZHMYBSABKA0iOgoFQ2xvY2sSDAoEdGljaxgBIAEoBBIUCgxnYW1lX3NlY29uZHMYAiABKAESDQoFc3BlZWQYAyABKA0i+gEKDVNlcnZlck1lc3NhZ2USIAoHd2VsY29tZRgBIAEoCzINLmNpdHkuV2VsY29tZUgAEiIKCHNuYXBzaG90GAIgASgLMg4uY2l0eS5TbmFwc2hvdEgAEhwKBWRlbHRhGAMgASgLMgsuY2l0eS5EZWx0YUgAEhwKBWVycm9yGAQgASgLMgsuY2l0eS5FcnJvckgAEhoKBHBvbmcYBSABKAsyCi5jaXR5LlBpbmdIABIlCgp2b3RlX3N0YXRlGAYgASgLMg8uY2l0eS5Wb3RlU3RhdGVIABIcCgVjbG9jaxgHIAEoCzILLmNpdHkuQ2xvY2tIAEIGCgRib2R5IjkKClNhdmVkQWdlbnQSCgoCaWQYASABKA0SDQoFcm91dGUYAiABKA0SEAoIZGlzdGFuY2UYAyABKAEiOgoFUm91dGUSCgoCeHkYASADKBESDwoHaG9tZV9pZBgCIAEoCRIUCgx3b3JrcGxhY2VfaWQYAyABKAki5QEKCFJvb21TYXZlEg8KB3ZlcnNpb24YASABKA0SCgoCaWQYAiABKAkSDwoHY2l0eV9pZBgDIAEoCRIUCgxjaXR5X3ZlcnNpb24YBCABKA0SDAoEdGljaxgFIAEoBBIMCgRzZWVkGAYgASgEEhQKDGdhbWVfc2Vjb25kcxgHIAEoARINCgVzcGVlZBgIIAEoDRIgCgZhZ2VudHMYCSADKAsyEC5jaXR5LlNhdmVkQWdlbnQSGwoGcm91dGVzGAogAygLMgsuY2l0eS5Sb3V0ZRIVCg1jaXR5X3JldmlzaW9uGAsgASgJYgZwcm90bzM");
|
fileDesc("CgpjaXR5LnByb3RvEgRjaXR5IkIKBFJlY3QSDQoFbWluX3gYASABKBESDQoFbWluX3kYAiABKBESDQoFbWF4X3gYAyABKBESDQoFbWF4X3kYBCABKBEiPgoESm9pbhIMCgRyb29tGAEgASgJEhYKDmNsaWVudF92ZXJzaW9uGAIgASgNEhAKCG5pY2tuYW1lGAMgASgJIjIKCVN1YnNjcmliZRIYCgRyZWN0GAEgASgLMgouY2l0eS5SZWN0EgsKA2xvZBgCIAEoDSIVCgRQaW5nEg0KBW5vbmNlGAEgASgNIhgKB1Byb3Bvc2USDQoFc3BlZWQYASABKA0iEwoEVm90ZRILCgN5ZXMYASABKAgiGwoJSW50ZXJ2ZW5lEg4KBmFjdGlvbhgBIAEoCSI4CgVOZWVkcxIOCgZodW5nZXIYASABKA0SDwoHZmF0aWd1ZRgCIAEoDRIOCgZtb3JhbGUYAyABKA0iKQoJR2FtZUV2ZW50Eg4KBm1pbnV0ZRgBIAEoBBIMCgRraW5kGAIgASgJIqoBCghHYW1lcGxheRIQCgh0cmVhc3VyeRgBIAEoEhIMCgRmb29kGAIgASgEEhoKBW5lZWRzGAMgASgLMgsuY2l0eS5OZWVkcxIfCgZldmVudHMYBCADKAsyDy5jaXR5LkdhbWVFdmVudBIPCgdwZW5kaW5nGAUgAygJEg4KBm1pbnV0ZRgGIAEoBBIgChhuZXh0X2ludGVydmVudGlvbl9taW51dGUYByABKAQiNgoKQ3JlYXRlQ2l0eRIMCgRiYm94GAEgAygBEgwKBG5hbWUYAiABKAkSDAoEc2VlZBgDIAEoDSJlCgdDaXR5Sm9iEgoKAmlkGAEgASgJEg8KB2NpdHlfaWQYAiABKAkSDgoGc3RhdHVzGAMgASgJEg8KB3Jvb21faWQYBCABKAkSDQoFZXJyb3IYBSABKAkSDQoFc3RhZ2UYBiABKAki2QEKDUNsaWVudE1lc3NhZ2USGgoEam9pbhgBIAEoCzIKLmNpdHkuSm9pbkgAEiQKCXN1YnNjcmliZRgCIAEoCzIPLmNpdHkuU3Vic2NyaWJlSAASGgoEcGluZxgDIAEoCzIKLmNpdHkuUGluZ0gAEiAKB3Byb3Bvc2UYBCABKAsyDS5jaXR5LlByb3Bvc2VIABIaCgR2b3RlGAUgASgLMgouY2l0eS5Wb3RlSAASJAoJaW50ZXJ2ZW5lGAYgASgLMg8uY2l0eS5JbnRlcnZlbmVIAEIGCgRib2R5IjcKBUFnZW50EgoKAmlkGAEgASgNEgkKAXgYAiABKBESCQoBeRgDIAEoERIMCgRraW5kGAQgASgNImsKB1dlbGNvbWUSDAoEcm9vbRgBIAEoCRIMCgR0aWNrGAIgASgEEhkKEWNpdHlfbWFuaWZlc3RfdXJsGAMgASgJEg8KB3Nlc3Npb24YBCABKAkSGAoQcHJvdG9jb2xfdmVyc2lvbhgFIAEoDSI3CghTbmFwc2hvdBIMCgR0aWNrGAEgASgEEh0KCGVudGl0aWVzGAIgAygLMgsuY2l0eS5BZ2VudCJkCgVEZWx0YRIMCgR0aWNrGAEgASgEEhwKB3NwYXduZWQYAiADKAsyCy5jaXR5LkFnZW50EhwKB2NoYW5nZWQYAyADKAsyCy5jaXR5LkFnZW50EhEKCWRlc3Bhd25lZBgEIAMoDSImCgVFcnJvchIMCgRjb2RlGAEgASgJEg8KB21lc3NhZ2UYAiABKAkiYwoJVm90ZVN0YXRlEg4KBmFjdGl2ZRgBIAEoCBINCgVzcGVlZBgCIAEoDRILCgN5ZXMYAyABKA0SDwoHcGxheWVycxgEIAEoDRIZChFyZW1haW5pbmdfc2Vjb25kcxgFIAEoDSI6CgVDbG9jaxIMCgR0aWNrGAEgASgEEhQKDGdhbWVfc2Vjb25kcxgCIAEoARINCgVzcGVlZBgDIAEoDSKeAgoNU2VydmVyTWVzc2FnZRIgCgd3ZWxjb21lGAEgASgLMg0uY2l0eS5XZWxjb21lSAASIgoIc25hcHNob3QYAiABKAsyDi5jaXR5LlNuYXBzaG90SAASHAoFZGVsdGEYAyABKAsyCy5jaXR5LkRlbHRhSAASHAoFZXJyb3IYBCABKAsyCy5jaXR5LkVycm9ySAASGgoEcG9uZxgFIAEoCzIKLmNpdHkuUGluZ0gAEiUKCnZvdGVfc3RhdGUYBiABKAsyDy5jaXR5LlZvdGVTdGF0ZUgAEhwKBWNsb2NrGAcgASgLMgsuY2l0eS5DbG9ja0gAEiIKCGdhbWVwbGF5GAggASgLMg4uY2l0eS5HYW1lcGxheUgAQgYKBGJvZHkiOQoKU2F2ZWRBZ2VudBIKCgJpZBgBIAEoDRINCgVyb3V0ZRgCIAEoDRIQCghkaXN0YW5jZRgDIAEoASI6CgVSb3V0ZRIKCgJ4eRgBIAMoERIPCgdob21lX2lkGAIgASgJEhQKDHdvcmtwbGFjZV9pZBgDIAEoCSKHAgoIUm9vbVNhdmUSDwoHdmVyc2lvbhgBIAEoDRIKCgJpZBgCIAEoCRIPCgdjaXR5X2lkGAMgASgJEhQKDGNpdHlfdmVyc2lvbhgEIAEoDRIMCgR0aWNrGAUgASgEEgwKBHNlZWQYBiABKAQSFAoMZ2FtZV9zZWNvbmRzGAcgASgBEg0KBXNwZWVkGAggASgNEiAKBmFnZW50cxgJIAMoCzIQLmNpdHkuU2F2ZWRBZ2VudBIbCgZyb3V0ZXMYCiADKAsyCy5jaXR5LlJvdXRlEhUKDWNpdHlfcmV2aXNpb24YCyABKAkSIAoIZ2FtZXBsYXkYDCABKAsyDi5jaXR5LkdhbWVwbGF5YgZwcm90bzM");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.Rect
|
* @generated from message city.Rect
|
||||||
@@ -144,6 +144,121 @@ export type Vote = Message<"city.Vote"> & {
|
|||||||
export const VoteSchema: GenMessage<Vote> = /*@__PURE__*/
|
export const VoteSchema: GenMessage<Vote> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 5);
|
messageDesc(file_city, 5);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Equal rights for every connected player; only these three bounded actions exist.
|
||||||
|
*
|
||||||
|
* @generated from message city.Intervene
|
||||||
|
*/
|
||||||
|
export type Intervene = Message<"city.Intervene"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: string action = 1;
|
||||||
|
*/
|
||||||
|
action: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message city.Intervene.
|
||||||
|
* Use `create(InterveneSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const InterveneSchema: GenMessage<Intervene> = /*@__PURE__*/
|
||||||
|
messageDesc(file_city, 6);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message city.Needs
|
||||||
|
*/
|
||||||
|
export type Needs = Message<"city.Needs"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 hunger = 1;
|
||||||
|
*/
|
||||||
|
hunger: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 fatigue = 2;
|
||||||
|
*/
|
||||||
|
fatigue: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint32 morale = 3;
|
||||||
|
*/
|
||||||
|
morale: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message city.Needs.
|
||||||
|
* Use `create(NeedsSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const NeedsSchema: GenMessage<Needs> = /*@__PURE__*/
|
||||||
|
messageDesc(file_city, 7);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message city.GameEvent
|
||||||
|
*/
|
||||||
|
export type GameEvent = Message<"city.GameEvent"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: uint64 minute = 1;
|
||||||
|
*/
|
||||||
|
minute: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: string kind = 2;
|
||||||
|
*/
|
||||||
|
kind: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message city.GameEvent.
|
||||||
|
* Use `create(GameEventSchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const GameEventSchema: GenMessage<GameEvent> = /*@__PURE__*/
|
||||||
|
messageDesc(file_city, 8);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from message city.Gameplay
|
||||||
|
*/
|
||||||
|
export type Gameplay = Message<"city.Gameplay"> & {
|
||||||
|
/**
|
||||||
|
* @generated from field: sint64 treasury = 1;
|
||||||
|
*/
|
||||||
|
treasury: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint64 food = 2;
|
||||||
|
*/
|
||||||
|
food: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: city.Needs needs = 3;
|
||||||
|
*/
|
||||||
|
needs?: Needs | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: repeated city.GameEvent events = 4;
|
||||||
|
*/
|
||||||
|
events: GameEvent[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: repeated string pending = 5;
|
||||||
|
*/
|
||||||
|
pending: string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint64 minute = 6;
|
||||||
|
*/
|
||||||
|
minute: bigint;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: uint64 next_intervention_minute = 7;
|
||||||
|
*/
|
||||||
|
nextInterventionMinute: bigint;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describes the message city.Gameplay.
|
||||||
|
* Use `create(GameplaySchema)` to create a new message.
|
||||||
|
*/
|
||||||
|
export const GameplaySchema: GenMessage<Gameplay> = /*@__PURE__*/
|
||||||
|
messageDesc(file_city, 9);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.CreateCity
|
* @generated from message city.CreateCity
|
||||||
*/
|
*/
|
||||||
@@ -169,7 +284,7 @@ export type CreateCity = Message<"city.CreateCity"> & {
|
|||||||
* Use `create(CreateCitySchema)` to create a new message.
|
* Use `create(CreateCitySchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const CreateCitySchema: GenMessage<CreateCity> = /*@__PURE__*/
|
export const CreateCitySchema: GenMessage<CreateCity> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 6);
|
messageDesc(file_city, 10);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.CityJob
|
* @generated from message city.CityJob
|
||||||
@@ -211,7 +326,7 @@ export type CityJob = Message<"city.CityJob"> & {
|
|||||||
* Use `create(CityJobSchema)` to create a new message.
|
* Use `create(CityJobSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const CityJobSchema: GenMessage<CityJob> = /*@__PURE__*/
|
export const CityJobSchema: GenMessage<CityJob> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 7);
|
messageDesc(file_city, 11);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.ClientMessage
|
* @generated from message city.ClientMessage
|
||||||
@@ -250,6 +365,12 @@ export type ClientMessage = Message<"city.ClientMessage"> & {
|
|||||||
*/
|
*/
|
||||||
value: Vote;
|
value: Vote;
|
||||||
case: "vote";
|
case: "vote";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: city.Intervene intervene = 6;
|
||||||
|
*/
|
||||||
|
value: Intervene;
|
||||||
|
case: "intervene";
|
||||||
} | { case: undefined; value?: undefined };
|
} | { case: undefined; value?: undefined };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -258,7 +379,7 @@ export type ClientMessage = Message<"city.ClientMessage"> & {
|
|||||||
* Use `create(ClientMessageSchema)` to create a new message.
|
* Use `create(ClientMessageSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const ClientMessageSchema: GenMessage<ClientMessage> = /*@__PURE__*/
|
export const ClientMessageSchema: GenMessage<ClientMessage> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 8);
|
messageDesc(file_city, 12);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.Agent
|
* @generated from message city.Agent
|
||||||
@@ -290,7 +411,7 @@ export type Agent = Message<"city.Agent"> & {
|
|||||||
* Use `create(AgentSchema)` to create a new message.
|
* Use `create(AgentSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const AgentSchema: GenMessage<Agent> = /*@__PURE__*/
|
export const AgentSchema: GenMessage<Agent> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 9);
|
messageDesc(file_city, 13);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.Welcome
|
* @generated from message city.Welcome
|
||||||
@@ -327,7 +448,7 @@ export type Welcome = Message<"city.Welcome"> & {
|
|||||||
* Use `create(WelcomeSchema)` to create a new message.
|
* Use `create(WelcomeSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const WelcomeSchema: GenMessage<Welcome> = /*@__PURE__*/
|
export const WelcomeSchema: GenMessage<Welcome> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 10);
|
messageDesc(file_city, 14);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.Snapshot
|
* @generated from message city.Snapshot
|
||||||
@@ -349,7 +470,7 @@ export type Snapshot = Message<"city.Snapshot"> & {
|
|||||||
* Use `create(SnapshotSchema)` to create a new message.
|
* Use `create(SnapshotSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const SnapshotSchema: GenMessage<Snapshot> = /*@__PURE__*/
|
export const SnapshotSchema: GenMessage<Snapshot> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 11);
|
messageDesc(file_city, 15);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.Delta
|
* @generated from message city.Delta
|
||||||
@@ -381,7 +502,7 @@ export type Delta = Message<"city.Delta"> & {
|
|||||||
* Use `create(DeltaSchema)` to create a new message.
|
* Use `create(DeltaSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const DeltaSchema: GenMessage<Delta> = /*@__PURE__*/
|
export const DeltaSchema: GenMessage<Delta> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 12);
|
messageDesc(file_city, 16);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.Error
|
* @generated from message city.Error
|
||||||
@@ -403,7 +524,7 @@ export type Error = Message<"city.Error"> & {
|
|||||||
* Use `create(ErrorSchema)` to create a new message.
|
* Use `create(ErrorSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const ErrorSchema: GenMessage<Error> = /*@__PURE__*/
|
export const ErrorSchema: GenMessage<Error> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 13);
|
messageDesc(file_city, 17);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.VoteState
|
* @generated from message city.VoteState
|
||||||
@@ -440,7 +561,7 @@ export type VoteState = Message<"city.VoteState"> & {
|
|||||||
* Use `create(VoteStateSchema)` to create a new message.
|
* Use `create(VoteStateSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const VoteStateSchema: GenMessage<VoteState> = /*@__PURE__*/
|
export const VoteStateSchema: GenMessage<VoteState> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 14);
|
messageDesc(file_city, 18);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.Clock
|
* @generated from message city.Clock
|
||||||
@@ -467,7 +588,7 @@ export type Clock = Message<"city.Clock"> & {
|
|||||||
* Use `create(ClockSchema)` to create a new message.
|
* Use `create(ClockSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const ClockSchema: GenMessage<Clock> = /*@__PURE__*/
|
export const ClockSchema: GenMessage<Clock> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 15);
|
messageDesc(file_city, 19);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.ServerMessage
|
* @generated from message city.ServerMessage
|
||||||
@@ -518,6 +639,12 @@ export type ServerMessage = Message<"city.ServerMessage"> & {
|
|||||||
*/
|
*/
|
||||||
value: Clock;
|
value: Clock;
|
||||||
case: "clock";
|
case: "clock";
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* @generated from field: city.Gameplay gameplay = 8;
|
||||||
|
*/
|
||||||
|
value: Gameplay;
|
||||||
|
case: "gameplay";
|
||||||
} | { case: undefined; value?: undefined };
|
} | { case: undefined; value?: undefined };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -526,7 +653,7 @@ export type ServerMessage = Message<"city.ServerMessage"> & {
|
|||||||
* Use `create(ServerMessageSchema)` to create a new message.
|
* Use `create(ServerMessageSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const ServerMessageSchema: GenMessage<ServerMessage> = /*@__PURE__*/
|
export const ServerMessageSchema: GenMessage<ServerMessage> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 16);
|
messageDesc(file_city, 20);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.SavedAgent
|
* @generated from message city.SavedAgent
|
||||||
@@ -553,7 +680,7 @@ export type SavedAgent = Message<"city.SavedAgent"> & {
|
|||||||
* Use `create(SavedAgentSchema)` to create a new message.
|
* Use `create(SavedAgentSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const SavedAgentSchema: GenMessage<SavedAgent> = /*@__PURE__*/
|
export const SavedAgentSchema: GenMessage<SavedAgent> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 17);
|
messageDesc(file_city, 21);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.Route
|
* @generated from message city.Route
|
||||||
@@ -580,7 +707,7 @@ export type Route = Message<"city.Route"> & {
|
|||||||
* Use `create(RouteSchema)` to create a new message.
|
* Use `create(RouteSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const RouteSchema: GenMessage<Route> = /*@__PURE__*/
|
export const RouteSchema: GenMessage<Route> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 18);
|
messageDesc(file_city, 22);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @generated from message city.RoomSave
|
* @generated from message city.RoomSave
|
||||||
@@ -640,6 +767,11 @@ export type RoomSave = Message<"city.RoomSave"> & {
|
|||||||
* @generated from field: string city_revision = 11;
|
* @generated from field: string city_revision = 11;
|
||||||
*/
|
*/
|
||||||
cityRevision: string;
|
cityRevision: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @generated from field: city.Gameplay gameplay = 12;
|
||||||
|
*/
|
||||||
|
gameplay?: Gameplay | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -647,5 +779,5 @@ export type RoomSave = Message<"city.RoomSave"> & {
|
|||||||
* Use `create(RoomSaveSchema)` to create a new message.
|
* Use `create(RoomSaveSchema)` to create a new message.
|
||||||
*/
|
*/
|
||||||
export const RoomSaveSchema: GenMessage<RoomSave> = /*@__PURE__*/
|
export const RoomSaveSchema: GenMessage<RoomSave> = /*@__PURE__*/
|
||||||
messageDesc(file_city, 19);
|
messageDesc(file_city, 23);
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
name: citygen
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
init: true
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "${CITYGEN_HOST_IP:-127.0.0.1}:${CITYGEN_PORT:-3000}:3000"
|
||||||
|
environment:
|
||||||
|
CITYGEN_BIND: 0.0.0.0:3000
|
||||||
|
CITYGEN_ADMIN_TOKEN: ${CITYGEN_ADMIN_TOKEN:-}
|
||||||
|
CITYGEN_AGENTS: ${CITYGEN_AGENTS:-1000}
|
||||||
|
CITYGEN_MAX_ROOMS: ${CITYGEN_MAX_ROOMS:-5}
|
||||||
|
CITYGEN_SAVE_INTERVAL: ${CITYGEN_SAVE_INTERVAL:-60}
|
||||||
|
CITYGEN_SAVE_QUOTA_BYTES: ${CITYGEN_SAVE_QUOTA_BYTES:-2147483648}
|
||||||
|
CITYGEN_SIM_THREADS: ${CITYGEN_SIM_THREADS:-4}
|
||||||
|
CITYGEN_MAX_GENERATION_KM2: ${CITYGEN_MAX_GENERATION_KM2:-25}
|
||||||
|
CITYGEN_MAX_BUILDINGS: ${CITYGEN_MAX_BUILDINGS:-10000}
|
||||||
|
CITYGEN_GENERATION_WORKERS: ${CITYGEN_GENERATION_WORKERS:-2}
|
||||||
|
volumes:
|
||||||
|
- cities:/app/public/cities
|
||||||
|
- saves:/app/data
|
||||||
|
stop_grace_period: 120s
|
||||||
|
mem_limit: ${CITYGEN_MEMORY:-4g}
|
||||||
|
cpus: ${CITYGEN_CPUS:-4}
|
||||||
|
pids_limit: 256
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop: [ALL]
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:v3.15.1
|
||||||
|
profiles: [monitoring]
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
command:
|
||||||
|
- --config.file=/etc/prometheus/prometheus.yml
|
||||||
|
- --storage.tsdb.retention.time=7d
|
||||||
|
- --storage.tsdb.retention.size=1GB
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:${CITYGEN_METRICS_PORT:-9090}:9090"
|
||||||
|
volumes:
|
||||||
|
- ./deploy/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- ./deploy/alerts.yml:/etc/prometheus/alerts.yml:ro
|
||||||
|
- metrics:/prometheus
|
||||||
|
mem_limit: 512m
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
volumes:
|
||||||
|
cities:
|
||||||
|
saves:
|
||||||
|
metrics:
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Запуск через Compose
|
||||||
|
|
||||||
|
Из корня репозитория, с работающим Docker Engine:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose config --quiet
|
||||||
|
docker compose build
|
||||||
|
# Необязательно: подготовить город из локальной OSM-выгрузки.
|
||||||
|
docker compose run --rm --no-deps app node --import tsx tools/demo.ts
|
||||||
|
docker compose up -d --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
Открыть `http://localhost:3000/?play`. Можно создать город через интерфейс.
|
||||||
|
Города и сохранения находятся в отдельных постоянных томах `cities` и `saves`.
|
||||||
|
Пересборка и `docker compose down` сохраняют тома. `down -v` удаляет их.
|
||||||
|
Существующий каталог города нельзя перезаписывать, пока на него ссылаются комнаты.
|
||||||
|
|
||||||
|
Приложение работает от пользователя `node`, с лимитами памяти, CPU,
|
||||||
|
процессов и ротацией логов. Значения `.env.example` рассчитаны на старт с
|
||||||
|
1000 жителями; миллион жителей и пять комнат требуют замера и настройки.
|
||||||
|
Порт по умолчанию доступен локально. Для внешнего доступа измените
|
||||||
|
`CITYGEN_HOST_IP` или используйте HTTPS reverse proxy с WebSocket.
|
||||||
|
Встроенный лимит создания видит IP TCP-соединения, поэтому за прокси он
|
||||||
|
общий для всех посетителей; недоверенные X-Forwarded-For не используются.
|
||||||
|
|
||||||
|
Остановка посылает SIGTERM; сервер завершает комнаты и ждёт запись снимков
|
||||||
|
(до 120 секунд). Перед обновлением:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose stop
|
||||||
|
# Сделать резервную копию ОБОИХ томов средствами Docker-хоста.
|
||||||
|
docker compose build
|
||||||
|
docker compose up -d --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка: `docker compose ps`, `docker compose logs --tail=100 app`,
|
||||||
|
`curl --fail http://localhost:3000/health`. Миграция комнат v1→v2 выполняется
|
||||||
|
при открытии комнаты; неизвестная версия остаётся на диске с ошибкой.
|
||||||
|
Квота `CITYGEN_SAVE_QUOTA_BYTES` включает все снимки и временный новый файл
|
||||||
|
до ротации: оставьте запас минимум на один полный снимок. При переполнении
|
||||||
|
запись отклоняется, прежние снимки сохраняются. Квота относится к снимкам;
|
||||||
|
объём городов ограничивайте средствами Docker-хоста.
|
||||||
|
|
||||||
|
## Администрирование
|
||||||
|
|
||||||
|
Задать `CITYGEN_ADMIN_TOKEN` случайной строкой минимум из 32 символов в `.env`
|
||||||
|
и пересоздать контейнер. Пустое значение отключает API. `.env` игнорируется Git
|
||||||
|
и не попадает в build context. `/?admin` показывает комнаты, использование
|
||||||
|
диска и ошибки несовместимых снимков, позволяет сохранить активную комнату.
|
||||||
|
Ответ приходит после записи. Токен остаётся только в памяти страницы.
|
||||||
|
|
||||||
|
API: `GET /api/admin`, `POST /api/admin/rooms/{id}/checkpoint`,
|
||||||
|
заголовок `Authorization: Bearer <token>`. Привилегии оператора не выдаются
|
||||||
|
игрокам и создателям комнат. Удаление миров/городов через API не предусмотрено.
|
||||||
|
|
||||||
|
## Мониторинг
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose --profile monitoring up -d --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
Prometheus на `http://localhost:9090` опрашивает `/metrics` каждые 5 секунд,
|
||||||
|
хранит до 7 дней / 1 ГБ. `alerts.yml` содержит правила недоступности,
|
||||||
|
тиков дольше 50 мс и ошибок сохранения. Предупреждения видны в Prometheus;
|
||||||
|
внешние уведомления требуют отдельного Alertmanager. Зафиксирована
|
||||||
|
[версия 3.15.1](https://github.com/prometheus/prometheus/releases/tag/v3.15.1).
|
||||||
|
|
||||||
|
Метрики: комнаты/игроки, сущности, последний/максимальный тик,
|
||||||
|
байты рассылки, ошибки и последний успешный тик сохранения. Ошибки снимков,
|
||||||
|
медленные тики, запуск/остановка пишутся JSON-строками в stderr.
|
||||||
|
|
||||||
|
Без Engine можно проверить `docker compose --profile monitoring config --quiet`.
|
||||||
|
Полный smoke-test контейнера после запуска: `/health`, вход двух клиентов,
|
||||||
|
команда «Обед», остановка/запуск и проверка восстановленного состояния.
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
groups:
|
||||||
|
- name: citygen
|
||||||
|
rules:
|
||||||
|
- alert: CitygenDown
|
||||||
|
expr: up{job="citygen"} == 0
|
||||||
|
for: 1m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: Citygen cannot be scraped
|
||||||
|
- alert: CitygenSlowTick
|
||||||
|
expr: citygen_room_tick_seconds > 0.05
|
||||||
|
for: 30s
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: Room tick exceeds 50 ms
|
||||||
|
- alert: CitygenSaveFailure
|
||||||
|
expr: increase(citygen_room_save_errors_total[5m]) > 0
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: Room snapshot failed; check disk quota and logs
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 5s
|
||||||
|
rule_files:
|
||||||
|
- /etc/prometheus/alerts.yml
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: citygen
|
||||||
|
static_configs:
|
||||||
|
- targets: [app:3000]
|
||||||
+19
-14
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 1. Цель и рамки
|
## 1. Цель и рамки
|
||||||
|
|
||||||
Игра: симулятор бога в духе RimWorld, но с меньшим влиянием игрока. Город живёт сам (жители, машины, потребности, события), игроки в основном наблюдают и ограниченно вмешиваются (вмешательство в первую версию не входит).
|
Игра: симулятор бога в духе RimWorld, но с меньшим влиянием игрока. Город живёт сам (жители, машины, потребности, события), игроки в основном наблюдают и ограниченно вмешиваются. По запросу автора от 2026-10-30 этап 5 включает базовые команды вмешательства.
|
||||||
|
|
||||||
Исходные требования:
|
Исходные требования:
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
- Клиент остаётся в браузере и только отображает. Вся симуляция и правила на сервере.
|
- Клиент остаётся в браузере и только отображает. Вся симуляция и правила на сервере.
|
||||||
- Сгенерированный город хранится готовым, генерация не выполняется в рантайме комнаты.
|
- Сгенерированный город хранится готовым, генерация не выполняется в рантайме комнаты.
|
||||||
|
|
||||||
Не входит в первую версию: влияние игроков на мир, аккаунты и монетизация, предсказание на клиенте (в наблюдательной игре оно не нужно).
|
Не входит в первую версию: аккаунты и монетизация, предсказание на клиенте (в наблюдательной игре оно не нужно). Влияние ограничено командами этапа 5.
|
||||||
|
|
||||||
## 2. Ключевые решения
|
## 2. Ключевые решения
|
||||||
|
|
||||||
@@ -172,20 +172,22 @@
|
|||||||
- [x] Оптимизация протокола и рендера.
|
- [x] Оптимизация протокола и рендера.
|
||||||
Бинарные дельты, квантизация, объединение пропущенных обновлений под бюджетом трафика, ограничение видимых сущностей и кэш тайлов; динамика пока на Canvas.
|
Бинарные дельты, квантизация, объединение пропущенных обновлений под бюджетом трафика, ограничение видимых сущностей и кэш тайлов; динамика пока на Canvas.
|
||||||
|
|
||||||
**Этап 5. Игровой слой (дальше)**
|
**Этап 5. Базовый игровой слой**
|
||||||
- [ ] Потребности, события, экономика.
|
- [x] Потребности, события, экономика.
|
||||||
- [ ] Ограниченное влияние игроков (команды, права, очереди, лимиты).
|
Усреднённые голод, усталость и настроение населения; сон, питание, производство еды и доход казны. События урожая, болезни и дождя воспроизводятся из seed и игрового времени. Индивидуальный ИИ жителей пока остаётся расписанием дом–работа.
|
||||||
- [ ] Сохранения, восстановление комнат, админка.
|
- [x] Ограниченное влияние игроков (команды, права, очереди, лимиты).
|
||||||
Сохранения и восстановление уже работают; админка, миграции схем и квоты диска остаются следующей частью этапа.
|
Покупка запасов, общий обед и отдых; одинаковые права подключённых игроков, цена из общей казны, ограниченная очередь, общий часовой игровой интервал и интервал сессии. Пауза/скорость остаются голосованием.
|
||||||
|
- [x] Сохранения, восстановление комнат, админка.
|
||||||
|
Схема v2 сохраняет игровой слой и очередь, v1 мигрируется при открытии. Добавлены общая квота снимков, защита прежних файлов при переполнении и админский интерфейс/API с отдельным токеном оператора.
|
||||||
|
|
||||||
**Этап 6. Эксплуатация**
|
**Этап 6. Эксплуатация**
|
||||||
- [ ] Деплой, мониторинг (метрики тика, число сущностей, трафик), логи.
|
- [x] Подготовка Compose, мониторинг (метрики тика, число сущностей, трафик), логи.
|
||||||
Метрики, логи и Dockerfile подготовлены. Docker Engine в текущей среде не запущен; внешний сервер для деплоя не задан.
|
По уточнению автора этап 6 ограничен подготовкой Compose. Добавлены постоянные тома, healthcheck, лимиты, SIGTERM с сохранением, ротация логов и опциональный Prometheus с правилами предупреждений. Проверка `docker compose --profile monitoring config --quiet` проходит. Docker Engine выключен: сборка и запуск контейнера не проверены.
|
||||||
- [x] Создание комнат по запросу (выбор города), лимиты ресурсов.
|
- [x] Создание комнат по запросу (выбор города), лимиты ресурсов.
|
||||||
|
|
||||||
Проверка минимальной сцены: `npm run demo`, `npm run build`, `npm run server:dev`, затем `http://localhost:3000/?play`.
|
Проверка минимальной сцены: `npm run demo`, `npm run build`, `npm run server:dev`, затем `http://localhost:3000/?play`.
|
||||||
Имя репозитория остаётся `citygen`; генератор и существующий рендер сохраняют свои пути в `src/`, новые сетевые модули находятся в `client/`.
|
Имя репозитория остаётся `citygen`; генератор и существующий рендер сохраняют свои пути в `src/`, новые сетевые модули находятся в `client/`.
|
||||||
Состояние и ограничения реализации описаны в `server/README.md`. Этапы 5–6 не объявляются завершёнными готовностью сетевого прототипа.
|
Состояние и ограничения реализации описаны в `server/README.md`, запуск Compose — в `deploy/README.md`. Этап 5 реализует базовую городскую модель, этап 6 — подготовку Compose в согласованном объёме.
|
||||||
|
|
||||||
## 10. Риски
|
## 10. Риски
|
||||||
|
|
||||||
@@ -269,7 +271,10 @@
|
|||||||
- Реальная нагрузка, 2026-10-30: Бальцерс, 1 000 000 жителей, 30 WebSocket-клиентов, 20 с, фоновые сохранения каждые 2 с. Выборка длительности тиков через метрики: p95 36,5 мс; максимум всех тиков по счётчику комнаты 44,7 мс. Средний трафик 4,8 КБ/с на клиента, максимальный 44,7 КБ/с. Ошибок, разрывов и срабатываний ограничения трафика не было. Это короткий локальный тест с небольшими AOI, а не гарантия для любого города, плотности, диска и целевого сервера. Пиковая память процесса со статикой и буферами — около 272 МиБ; память одной ECS отдельно не измерялась.
|
- Реальная нагрузка, 2026-10-30: Бальцерс, 1 000 000 жителей, 30 WebSocket-клиентов, 20 с, фоновые сохранения каждые 2 с. Выборка длительности тиков через метрики: p95 36,5 мс; максимум всех тиков по счётчику комнаты 44,7 мс. Средний трафик 4,8 КБ/с на клиента, максимальный 44,7 КБ/с. Ошибок, разрывов и срабатываний ограничения трафика не было. Это короткий локальный тест с небольшими AOI, а не гарантия для любого города, плотности, диска и целевого сервера. Пиковая память процесса со статикой и буферами — около 272 МиБ; память одной ECS отдельно не измерялась.
|
||||||
- Браузер: повторный вход в сохранённую миллионную комнату до загруженной области — 1,31 с в локальном Chrome.
|
- Браузер: повторный вход в сохранённую миллионную комнату до загруженной области — 1,31 с в локальном Chrome.
|
||||||
|
|
||||||
Детальные пути между этажами, использование мебели жителями, потребности,
|
- Этапы 5–6: 17 TS-тестов, 11 Rust unit-тестов и 2 интеграционных теста проходят. Проверены миграция v1→v2, сохранение очереди команды, детерминизм экономики/событий, влияние потребностей на производство, расход еды, квота без потери последнего снимка, отказ без админского токена и подтверждение записи через API.
|
||||||
события, экономика и вмешательство игроков остаются игровым слоем этапа 5.
|
- Chrome: отображение казны/еды/потребностей, команда «Обед» со списанием ресурсов и снижением голода, пауза, этаж/масштаб, админское сохранение. Загрузка области — 1,12 с локально.
|
||||||
Для эксплуатации также остаются миграции снимков, квоты диска, админка,
|
|
||||||
проверка контейнера и развёртывание в выбранном окружении.
|
Дальнейшее развитие за пределами базового слоя: индивидуальные потребности и
|
||||||
|
выбор целей, детальные переходы между этажами, использование мебели и
|
||||||
|
уличных слотов жителями, транспорт. Подготовленный Compose ещё нужно собрать
|
||||||
|
и запустить на Docker-хосте; внешнее развёртывание в текущий запрос не входит.
|
||||||
+12
-2
@@ -7,10 +7,19 @@ message Subscribe { Rect rect = 1; uint32 lod = 2; }
|
|||||||
message Ping { uint32 nonce = 1; }
|
message Ping { uint32 nonce = 1; }
|
||||||
message Propose { uint32 speed = 1; }
|
message Propose { uint32 speed = 1; }
|
||||||
message Vote { bool yes = 1; }
|
message Vote { bool yes = 1; }
|
||||||
|
// Equal rights for every connected player; only these three bounded actions exist.
|
||||||
|
message Intervene { string action = 1; }
|
||||||
|
message Needs { uint32 hunger = 1; uint32 fatigue = 2; uint32 morale = 3; }
|
||||||
|
message GameEvent { uint64 minute = 1; string kind = 2; }
|
||||||
|
message Gameplay {
|
||||||
|
sint64 treasury = 1; uint64 food = 2; Needs needs = 3;
|
||||||
|
repeated GameEvent events = 4; repeated string pending = 5;
|
||||||
|
uint64 minute = 6; uint64 next_intervention_minute = 7;
|
||||||
|
}
|
||||||
message CreateCity { repeated double bbox = 1; string name = 2; uint32 seed = 3; }
|
message CreateCity { repeated double bbox = 1; string name = 2; uint32 seed = 3; }
|
||||||
message CityJob { string id = 1; string city_id = 2; string status = 3; string room_id = 4; string error = 5; string stage = 6; }
|
message CityJob { string id = 1; string city_id = 2; string status = 3; string room_id = 4; string error = 5; string stage = 6; }
|
||||||
message ClientMessage {
|
message ClientMessage {
|
||||||
oneof body { Join join = 1; Subscribe subscribe = 2; Ping ping = 3; Propose propose = 4; Vote vote = 5; }
|
oneof body { Join join = 1; Subscribe subscribe = 2; Ping ping = 3; Propose propose = 4; Vote vote = 5; Intervene intervene = 6; }
|
||||||
}
|
}
|
||||||
message Agent { uint32 id = 1; sint32 x = 2; sint32 y = 3; uint32 kind = 4; }
|
message Agent { uint32 id = 1; sint32 x = 2; sint32 y = 3; uint32 kind = 4; }
|
||||||
message Welcome { string room = 1; uint64 tick = 2; string city_manifest_url = 3; string session = 4; uint32 protocol_version = 5; }
|
message Welcome { string room = 1; uint64 tick = 2; string city_manifest_url = 3; string session = 4; uint32 protocol_version = 5; }
|
||||||
@@ -20,7 +29,7 @@ message Error { string code = 1; string message = 2; }
|
|||||||
message VoteState { bool active = 1; uint32 speed = 2; uint32 yes = 3; uint32 players = 4; uint32 remaining_seconds = 5; }
|
message VoteState { bool active = 1; uint32 speed = 2; uint32 yes = 3; uint32 players = 4; uint32 remaining_seconds = 5; }
|
||||||
message Clock { uint64 tick = 1; double game_seconds = 2; uint32 speed = 3; }
|
message Clock { uint64 tick = 1; double game_seconds = 2; uint32 speed = 3; }
|
||||||
message ServerMessage {
|
message ServerMessage {
|
||||||
oneof body { Welcome welcome = 1; Snapshot snapshot = 2; Delta delta = 3; Error error = 4; Ping pong = 5; VoteState vote_state = 6; Clock clock = 7; }
|
oneof body { Welcome welcome = 1; Snapshot snapshot = 2; Delta delta = 3; Error error = 4; Ping pong = 5; VoteState vote_state = 6; Clock clock = 7; Gameplay gameplay = 8; }
|
||||||
}
|
}
|
||||||
message SavedAgent { uint32 id = 1; uint32 route = 2; double distance = 3; }
|
message SavedAgent { uint32 id = 1; uint32 route = 2; double distance = 3; }
|
||||||
message Route { repeated sint32 xy = 1; string home_id = 2; string workplace_id = 3; }
|
message Route { repeated sint32 xy = 1; string home_id = 2; string workplace_id = 3; }
|
||||||
@@ -29,4 +38,5 @@ message RoomSave {
|
|||||||
uint64 tick = 5; uint64 seed = 6; double game_seconds = 7; uint32 speed = 8;
|
uint64 tick = 5; uint64 seed = 6; double game_seconds = 7; uint32 speed = 8;
|
||||||
repeated SavedAgent agents = 9; repeated Route routes = 10;
|
repeated SavedAgent agents = 9; repeated Route routes = 10;
|
||||||
string city_revision = 11;
|
string city_revision = 11;
|
||||||
|
Gameplay gameplay = 12;
|
||||||
}
|
}
|
||||||
@@ -24,6 +24,10 @@ try {
|
|||||||
await page.waitForFunction(()=>document.querySelector('.viewer-top [role=status]')?.textContent.includes('в загруженной области'),null,{timeout:60000});
|
await page.waitForFunction(()=>document.querySelector('.viewer-top [role=status]')?.textContent.includes('в загруженной области'),null,{timeout:60000});
|
||||||
const visibleMs=Math.round(performance.now()-started);
|
const visibleMs=Math.round(performance.now()-started);
|
||||||
await page.waitForFunction(()=>document.querySelector('.room-clock')?.textContent.includes('День'),null,{timeout:10000});
|
await page.waitForFunction(()=>document.querySelector('.room-clock')?.textContent.includes('День'),null,{timeout:10000});
|
||||||
|
await page.waitForFunction(()=>document.querySelector('.room-gameplay')?.textContent.includes('Казна'),null,{timeout:10000});
|
||||||
|
await page.locator('[data-action=meal]').click();
|
||||||
|
await page.waitForFunction(()=>document.querySelector('[data-action=meal]')?.disabled,null,{timeout:5000});
|
||||||
|
await page.waitForFunction(()=>document.querySelector('.room-gameplay')?.textContent.includes('Общий обед'),null,{timeout:15000});
|
||||||
await page.getByRole('button',{name:'Пауза',exact:true}).click();
|
await page.getByRole('button',{name:'Пауза',exact:true}).click();
|
||||||
await page.waitForFunction(()=>document.querySelector('.room-clock')?.textContent.includes('×0'),null,{timeout:10000});
|
await page.waitForFunction(()=>document.querySelector('.room-clock')?.textContent.includes('×0'),null,{timeout:10000});
|
||||||
await page.getByRole('button',{name:'1',exact:true}).click();
|
await page.getByRole('button',{name:'1',exact:true}).click();
|
||||||
@@ -31,4 +35,14 @@ try {
|
|||||||
await mkdir('.tools',{recursive:true}); await page.screenshot({path:'.tools/browser-room.png'});
|
await mkdir('.tools',{recursive:true}); await page.screenshot({path:'.tools/browser-room.png'});
|
||||||
assert.deepEqual(errors,[]);
|
assert.deepEqual(errors,[]);
|
||||||
console.log(JSON.stringify({ui:'passed',visibleMs,status:await page.locator('.viewer-top').innerText(),screenshot:resolve('.tools/browser-room.png')}));
|
console.log(JSON.stringify({ui:'passed',visibleMs,status:await page.locator('.viewer-top').innerText(),screenshot:resolve('.tools/browser-room.png')}));
|
||||||
|
if (process.env.CITYGEN_TEST_ADMIN_TOKEN) {
|
||||||
|
await page.goto(`${base}/?admin`);
|
||||||
|
await page.getByLabel('Токен оператора').fill(process.env.CITYGEN_TEST_ADMIN_TOKEN);
|
||||||
|
await page.getByRole('button',{name:'Обновить',exact:true}).click();
|
||||||
|
await page.waitForFunction(()=>document.querySelector('[role=status]')?.textContent.includes('МиБ'),null,{timeout:10000});
|
||||||
|
const saved = page.waitForResponse(r=>r.url().endsWith('/checkpoint') && r.request().method()==='POST');
|
||||||
|
await page.getByRole('button',{name:'Сохранить',exact:true}).first().click();
|
||||||
|
assert.equal((await saved).status(),200);
|
||||||
|
console.log(JSON.stringify({admin:'passed'}));
|
||||||
|
}
|
||||||
} finally {await browser.close();}
|
} finally {await browser.close();}
|
||||||
+29
-3
@@ -42,6 +42,8 @@
|
|||||||
- `CITYGEN_MAX_VISIBLE`: 1000, максимум 5000; `CITYGEN_SIM_THREADS`: до четырёх CPU по умолчанию, общий пул ECS на процесс.
|
- `CITYGEN_MAX_VISIBLE`: 1000, максимум 5000; `CITYGEN_SIM_THREADS`: до четырёх CPU по умолчанию, общий пул ECS на процесс.
|
||||||
- `CITYGEN_EMPTY_TIMEOUT`: 300 с; `CITYGEN_SAVE_INTERVAL`: 60 с.
|
- `CITYGEN_EMPTY_TIMEOUT`: 300 с; `CITYGEN_SAVE_INTERVAL`: 60 с.
|
||||||
- `CITYGEN_KEEP_SNAPSHOTS`: 3.
|
- `CITYGEN_KEEP_SNAPSHOTS`: 3.
|
||||||
|
- `CITYGEN_SAVE_QUOTA_BYTES`: 2147483648 (2 ГиБ, включая временный снимок).
|
||||||
|
- `CITYGEN_ADMIN_TOKEN`: пусто; для включения API требуется минимум 32 символа.
|
||||||
- `CITYGEN_MAX_GENERATION_KM2`: 25; `CITYGEN_MAX_BUILDINGS`: 10000.
|
- `CITYGEN_MAX_GENERATION_KM2`: 25; `CITYGEN_MAX_BUILDINGS`: 10000.
|
||||||
- `CITYGEN_GENERATION_WORKERS`: 2; `CITYGEN_GENERATION_QUEUE`: 8.
|
- `CITYGEN_GENERATION_WORKERS`: 2; `CITYGEN_GENERATION_QUEUE`: 8.
|
||||||
- `CITYGEN_GENERATION_TIMEOUT`: 600 с.
|
- `CITYGEN_GENERATION_TIMEOUT`: 600 с.
|
||||||
@@ -68,6 +70,30 @@
|
|||||||
Боты создают комнату, подключаются, подписываются на 30 областей, адаптируют
|
Боты создают комнату, подключаются, подписываются на 30 областей, адаптируют
|
||||||
LOD и выводят трафик, выборку длительности тиков и ошибки соединений.
|
LOD и выводят трафик, выборку длительности тиков и ошибки соединений.
|
||||||
|
|
||||||
Dockerfile подготавливает приложение для одного сервера. Каталоги городов
|
Compose, постоянные тома, лимиты, healthcheck и профиль Prometheus описаны
|
||||||
и `data/` необходимо подключать постоянными томами. Docker-сборка и внешний
|
в [deploy/README.md](../deploy/README.md).
|
||||||
деплой требуют отдельной проверки в целевом окружении.
|
|
||||||
|
## Игровой слой
|
||||||
|
|
||||||
|
Голод, усталость и настроение моделируются как усреднённые показатели
|
||||||
|
населения комнаты. Они меняются по игровому календарю независимо от камеры.
|
||||||
|
Сон ночью восстанавливает силы; питание трижды в день тратит запасы еды.
|
||||||
|
Голод и усталость снижают производство и доход казны в рабочие часы.
|
||||||
|
Почасовые события (урожай, болезнь, дождь) детерминированы seed и временем.
|
||||||
|
История ограничена 16 событиями. Это базовая экономика общего города;
|
||||||
|
индивидуальные кошельки, машины и детальное использование мебели не включены.
|
||||||
|
|
||||||
|
Все игроки имеют одинаковые права на три команды: покупка запасов
|
||||||
|
(2 единицы казны на жителя), общий обед (1), отдых (3). Цена списывается
|
||||||
|
при приёме, действие исполняется в следующую игровую минуту. Сервер проверяет
|
||||||
|
ресурсы, паузу, очередь (до 8), общий интервал 60 игровых минут и интервал
|
||||||
|
сессии 30 реальных секунд. Общий интервал переживает переподключение.
|
||||||
|
Пауза и скорость по-прежнему требуют большинства голосов.
|
||||||
|
|
||||||
|
Снимки v2 сохраняют экономику, потребности, историю, очередь и общий интервал.
|
||||||
|
Для v1 игровой слой инициализируется по числу жителей и сохранённому времени;
|
||||||
|
позиции, seed и расписания сохраняются. Неизвестные версии не перезаписываются.
|
||||||
|
Квота проверяется под общей блокировкой писателей комнат; при нехватке места
|
||||||
|
прежние снимки остаются. Один снимок ограничен 128 МиБ.
|
||||||
|
`/?admin` и API с Bearer-токеном показывают каталог, ошибки, квоту
|
||||||
|
и позволяют сохранить активную комнату.
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
//! Coarse city-wide needs/economy. Updates cost O(game minutes), independent of
|
||||||
|
//! population and camera. Residents keep their deterministic commuter schedule.
|
||||||
|
use crate::protocol::{GameEvent, Gameplay, Needs};
|
||||||
|
use bevy_ecs::prelude::Resource;
|
||||||
|
|
||||||
|
#[derive(Resource, Clone)]
|
||||||
|
pub struct Game(pub Gameplay);
|
||||||
|
impl Game {
|
||||||
|
pub fn new(population: usize, seconds: f64) -> Self {
|
||||||
|
Self(Gameplay {
|
||||||
|
treasury: population as i64 * 100,
|
||||||
|
food: population as u64 * 2,
|
||||||
|
needs: Some(Needs {
|
||||||
|
hunger: 20,
|
||||||
|
fatigue: 20,
|
||||||
|
morale: 70,
|
||||||
|
}),
|
||||||
|
minute: (seconds / 60.0) as u64,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn validate(state: &Gameplay, seconds: f64, population: usize) -> Result<(), String> {
|
||||||
|
let needs = state.needs.as_ref().ok_or("Missing saved needs")?;
|
||||||
|
if state.minute != (seconds / 60.0) as u64
|
||||||
|
|| state.treasury < 0
|
||||||
|
|| state.food > (population as u64).saturating_mul(100).max(100)
|
||||||
|
|| [needs.hunger, needs.fatigue, needs.morale]
|
||||||
|
.iter()
|
||||||
|
.any(|n| *n > 100)
|
||||||
|
|| state.events.len() > 16
|
||||||
|
|| state.pending.len() > 8
|
||||||
|
|| state.events.iter().any(|e| {
|
||||||
|
e.minute > state.minute
|
||||||
|
|| !["harvest", "illness", "rain", "supplies", "meal", "rest"]
|
||||||
|
.contains(&e.kind.as_str())
|
||||||
|
})
|
||||||
|
|| state
|
||||||
|
.pending
|
||||||
|
.iter()
|
||||||
|
.any(|a| Self::cost(a, population).is_none())
|
||||||
|
{
|
||||||
|
return Err("Invalid saved gameplay".into());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
pub fn cost(action: &str, population: usize) -> Option<i64> {
|
||||||
|
let unit = population.max(1) as i64;
|
||||||
|
match action {
|
||||||
|
"supplies" => Some(unit * 2),
|
||||||
|
"meal" => Some(unit),
|
||||||
|
"rest" => Some(unit * 3),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn enqueue(&mut self, action: &str, population: usize, paused: bool) -> Result<(), String> {
|
||||||
|
let cost = Self::cost(action, population).ok_or("Unknown intervention")?;
|
||||||
|
if paused {
|
||||||
|
return Err("Commands are unavailable while paused".into());
|
||||||
|
}
|
||||||
|
if self.0.pending.len() >= 8 || self.0.minute < self.0.next_intervention_minute {
|
||||||
|
return Err("City intervention cooldown: one command per game hour".into());
|
||||||
|
}
|
||||||
|
if self.0.treasury < cost {
|
||||||
|
return Err("Insufficient treasury".into());
|
||||||
|
}
|
||||||
|
if action == "meal" && self.0.food < population as u64 {
|
||||||
|
return Err("Insufficient food; purchase supplies first".into());
|
||||||
|
}
|
||||||
|
self.0.treasury -= cost;
|
||||||
|
self.0.pending.push(action.into());
|
||||||
|
self.0.next_intervention_minute = self.0.minute + 60;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
fn event(&mut self, kind: &str) {
|
||||||
|
self.0.events.push(GameEvent {
|
||||||
|
minute: self.0.minute,
|
||||||
|
kind: kind.into(),
|
||||||
|
});
|
||||||
|
if self.0.events.len() > 16 {
|
||||||
|
self.0.events.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn advance(&mut self, seconds: f64, population: usize, seed: u64) {
|
||||||
|
let until = (seconds / 60.0) as u64;
|
||||||
|
let population = population as u64;
|
||||||
|
while self.0.minute < until {
|
||||||
|
self.0.minute += 1;
|
||||||
|
for action in std::mem::take(&mut self.0.pending) {
|
||||||
|
match action.as_str() {
|
||||||
|
"supplies" => {
|
||||||
|
self.0.food = (self.0.food + population * 2).min(population * 100)
|
||||||
|
}
|
||||||
|
"meal" => {
|
||||||
|
let fed = self.0.food >= population;
|
||||||
|
if fed {
|
||||||
|
self.0.food -= population;
|
||||||
|
}
|
||||||
|
let needs = self.0.needs.as_mut().unwrap();
|
||||||
|
if fed {
|
||||||
|
needs.hunger = needs.hunger.saturating_sub(40);
|
||||||
|
needs.morale = (needs.morale + 5).min(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"rest" => {
|
||||||
|
let n = self.0.needs.as_mut().unwrap();
|
||||||
|
n.fatigue = n.fatigue.saturating_sub(30);
|
||||||
|
n.morale = (n.morale + 10).min(100);
|
||||||
|
}
|
||||||
|
_ => unreachable!(),
|
||||||
|
}
|
||||||
|
self.event(&action);
|
||||||
|
}
|
||||||
|
if self.0.minute % 10 == 0 {
|
||||||
|
let hour = (self.0.minute / 60) % 24;
|
||||||
|
let n = self.0.needs.as_mut().unwrap();
|
||||||
|
n.hunger = (n.hunger + 1).min(100);
|
||||||
|
n.fatigue = if hour < 7 || hour >= 22 {
|
||||||
|
n.fatigue.saturating_sub(3)
|
||||||
|
} else {
|
||||||
|
(n.fatigue + 1).min(100)
|
||||||
|
};
|
||||||
|
if n.hunger > 70 || n.fatigue > 80 {
|
||||||
|
n.morale = n.morale.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.0.minute % 60 == 0 {
|
||||||
|
let hour = (self.0.minute / 60) % 24;
|
||||||
|
if (8..17).contains(&hour) {
|
||||||
|
let n = self.0.needs.as_ref().unwrap();
|
||||||
|
// Hunger and fatigue reduce labour output and tax receipts.
|
||||||
|
let productivity = (200 - n.hunger - n.fatigue) as u64;
|
||||||
|
let output = population * productivity / 200;
|
||||||
|
self.0.treasury = self.0.treasury.saturating_add(output as i64);
|
||||||
|
self.0.food = (self.0.food + output / 3).min(population * 100);
|
||||||
|
}
|
||||||
|
if [7, 13, 19].contains(&hour) {
|
||||||
|
let served = self.0.food.min(population);
|
||||||
|
self.0.food -= served;
|
||||||
|
let reduction = (served * 25 / population.max(1)) as u32;
|
||||||
|
let n = self.0.needs.as_mut().unwrap();
|
||||||
|
n.hunger = n.hunger.saturating_sub(reduction);
|
||||||
|
}
|
||||||
|
let random = (seed ^ (self.0.minute / 60).wrapping_mul(0x9e3779b97f4a7c15))
|
||||||
|
.wrapping_mul(0xbf58476d1ce4e5b9);
|
||||||
|
match random % 24 {
|
||||||
|
0 => {
|
||||||
|
self.0.food = (self.0.food + population).min(population * 100);
|
||||||
|
self.event("harvest");
|
||||||
|
}
|
||||||
|
1 => {
|
||||||
|
let n = self.0.needs.as_mut().unwrap();
|
||||||
|
n.fatigue = (n.fatigue + 10).min(100);
|
||||||
|
self.event("illness");
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
let n = self.0.needs.as_mut().unwrap();
|
||||||
|
n.morale = n.morale.saturating_sub(5);
|
||||||
|
self.event("rain");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn needs_reduce_output_and_food_is_consumed() {
|
||||||
|
let mut healthy = Game::new(1000, 7.0 * 3600.0);
|
||||||
|
let mut hungry = healthy.clone();
|
||||||
|
hungry.0.needs.as_mut().unwrap().hunger = 100;
|
||||||
|
hungry.0.needs.as_mut().unwrap().fatigue = 100;
|
||||||
|
healthy.advance(8.0 * 3600.0, 1000, 7);
|
||||||
|
hungry.advance(8.0 * 3600.0, 1000, 7);
|
||||||
|
assert!(healthy.0.treasury > hungry.0.treasury);
|
||||||
|
let mut meal = Game::new(1000, 12.0 * 3600.0);
|
||||||
|
meal.advance(13.0 * 3600.0, 1000, 7);
|
||||||
|
assert!(meal.0.food < 2000, "scheduled meals consume reserves");
|
||||||
|
meal.0.food = 0;
|
||||||
|
assert!(meal.enqueue("meal", 1000, false).is_err());
|
||||||
|
meal.0.treasury = 0;
|
||||||
|
assert!(meal.enqueue("supplies", 1000, false).is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn commands_budget_cooldown_and_deterministic_recovery() {
|
||||||
|
let mut game = Game::new(1000, 0.0);
|
||||||
|
assert!(game.enqueue("delete", 1000, false).is_err());
|
||||||
|
assert!(game.enqueue("meal", 1000, true).is_err());
|
||||||
|
game.enqueue("meal", 1000, false).unwrap();
|
||||||
|
assert_eq!(game.0.treasury, 99000);
|
||||||
|
assert!(game.enqueue("meal", 1000, false).is_err());
|
||||||
|
let mut restored = Game(game.0.clone());
|
||||||
|
for minute in 1..=2880 {
|
||||||
|
game.advance(minute as f64 * 60.0, 1000, 7);
|
||||||
|
restored.advance(minute as f64 * 60.0, 1000, 7);
|
||||||
|
}
|
||||||
|
assert_eq!(game.0, restored.0);
|
||||||
|
assert!(game.0.pending.is_empty());
|
||||||
|
assert!(!game.0.events.is_empty());
|
||||||
|
Game::validate(&game.0, 2880.0 * 60.0, 1000).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
+164
-11
@@ -9,7 +9,7 @@ use axum::{
|
|||||||
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
|
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
|
||||||
ConnectInfo, Path, State,
|
ConnectInfo, Path, State,
|
||||||
},
|
},
|
||||||
http::StatusCode,
|
http::{HeaderMap, StatusCode},
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::get,
|
routing::get,
|
||||||
Json, Router,
|
Json, Router,
|
||||||
@@ -48,6 +48,8 @@ pub struct Config {
|
|||||||
pub max_visible: usize,
|
pub max_visible: usize,
|
||||||
pub osm_fixture: Option<PathBuf>,
|
pub osm_fixture: Option<PathBuf>,
|
||||||
pub project_root: PathBuf,
|
pub project_root: PathBuf,
|
||||||
|
pub save_quota_bytes: u64,
|
||||||
|
pub admin_token: Option<String>,
|
||||||
}
|
}
|
||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -68,6 +70,8 @@ impl Default for Config {
|
|||||||
max_visible: 1000,
|
max_visible: 1000,
|
||||||
osm_fixture: None,
|
osm_fixture: None,
|
||||||
project_root: std::env::current_dir().unwrap_or_else(|_| ".".into()),
|
project_root: std::env::current_dir().unwrap_or_else(|_| ".".into()),
|
||||||
|
save_quota_bytes: 2 * 1024 * 1024 * 1024,
|
||||||
|
admin_token: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +97,8 @@ struct RoomMetrics {
|
|||||||
entities: AtomicU64,
|
entities: AtomicU64,
|
||||||
bytes: AtomicU64,
|
bytes: AtomicU64,
|
||||||
max_micros: AtomicU64,
|
max_micros: AtomicU64,
|
||||||
|
save_errors: AtomicU64,
|
||||||
|
saved_tick: AtomicU64,
|
||||||
}
|
}
|
||||||
pub(crate) enum Command {
|
pub(crate) enum Command {
|
||||||
Join {
|
Join {
|
||||||
@@ -103,6 +109,7 @@ pub(crate) enum Command {
|
|||||||
Leave(String),
|
Leave(String),
|
||||||
Input(String, p::ClientMessage),
|
Input(String, p::ClientMessage),
|
||||||
Stop,
|
Stop,
|
||||||
|
Checkpoint(tokio::sync::oneshot::Sender<Result<(), String>>),
|
||||||
}
|
}
|
||||||
struct Player {
|
struct Player {
|
||||||
nickname: String,
|
nickname: String,
|
||||||
@@ -111,6 +118,7 @@ struct Player {
|
|||||||
lod: u32,
|
lod: u32,
|
||||||
known: HashMap<u32, p::Agent>,
|
known: HashMap<u32, p::Agent>,
|
||||||
last_proposal: Option<Instant>,
|
last_proposal: Option<Instant>,
|
||||||
|
last_intervention: Option<Instant>,
|
||||||
traffic: Traffic,
|
traffic: Traffic,
|
||||||
metrics: Arc<RoomMetrics>,
|
metrics: Arc<RoomMetrics>,
|
||||||
}
|
}
|
||||||
@@ -193,6 +201,8 @@ impl App {
|
|||||||
|| config.generation_queue == 0
|
|| config.generation_queue == 0
|
||||||
|| config.max_visible == 0
|
|| config.max_visible == 0
|
||||||
|| config.max_visible > 5000
|
|| config.max_visible > 5000
|
||||||
|
|| config.save_quota_bytes == 0
|
||||||
|
|| config.admin_token.as_ref().is_some_and(|t| t.len() < 32)
|
||||||
|| !config.max_generation_km2.is_finite()
|
|| !config.max_generation_km2.is_finite()
|
||||||
|| config.max_generation_km2 <= 0.0
|
|| config.max_generation_km2 <= 0.0
|
||||||
{
|
{
|
||||||
@@ -360,6 +370,11 @@ pub fn router(app: App) -> Router {
|
|||||||
.route("/api/rooms/{id}/ws", get(upgrade))
|
.route("/api/rooms/{id}/ws", get(upgrade))
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.route("/metrics", get(metrics))
|
.route("/metrics", get(metrics))
|
||||||
|
.route("/api/admin", get(admin_status))
|
||||||
|
.route(
|
||||||
|
"/api/admin/rooms/{id}/checkpoint",
|
||||||
|
axum::routing::post(admin_checkpoint),
|
||||||
|
)
|
||||||
.nest("/cities", city_files)
|
.nest("/cities", city_files)
|
||||||
.fallback_service(tower_http::services::ServeDir::new("dist"))
|
.fallback_service(tower_http::services::ServeDir::new("dist"))
|
||||||
.with_state(app)
|
.with_state(app)
|
||||||
@@ -373,6 +388,83 @@ async fn cache_city(mut response: axum::response::Response) -> axum::response::R
|
|||||||
}
|
}
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn authorize(app: &App, headers: &HeaderMap) -> Result<(), ApiError> {
|
||||||
|
let expected = app
|
||||||
|
.config
|
||||||
|
.admin_token
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Administration is disabled"))?;
|
||||||
|
let received = headers
|
||||||
|
.get(axum::http::header::AUTHORIZATION)
|
||||||
|
.and_then(|h| h.to_str().ok())
|
||||||
|
.and_then(|h| h.strip_prefix("Bearer "))
|
||||||
|
.unwrap_or("");
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let a = Sha256::digest(expected.as_bytes());
|
||||||
|
let b = Sha256::digest(received.as_bytes());
|
||||||
|
if a.iter()
|
||||||
|
.zip(b.iter())
|
||||||
|
.fold(0u8, |diff, (a, b)| diff | (a ^ b))
|
||||||
|
!= 0
|
||||||
|
{
|
||||||
|
return Err(api_error(
|
||||||
|
StatusCode::UNAUTHORIZED,
|
||||||
|
"Administrator token required",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn admin_status(
|
||||||
|
State(app): State<App>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
authorize(&app, &headers)?;
|
||||||
|
let root = app.config.saves.clone();
|
||||||
|
let disk = tokio::task::spawn_blocking(move || {
|
||||||
|
let mut snapshots = vec![];
|
||||||
|
for entry in std::fs::read_dir(&root).map_err(|e| e.to_string())?.flatten() {
|
||||||
|
let id = entry.file_name().to_string_lossy().to_string();
|
||||||
|
if !entry.file_type().map_err(|e| e.to_string())?.is_dir() || !safe_id(&id) { continue; }
|
||||||
|
let result = storage::load(&root, &id);
|
||||||
|
snapshots.push(serde_json::json!({"id":id,"error":result.as_ref().err(),"version":result.as_ref().ok().map(|s|s.version),"tick":result.as_ref().ok().map(|s|s.tick)}));
|
||||||
|
}
|
||||||
|
Ok::<_, String>((storage::disk_bytes(&root)?, snapshots))
|
||||||
|
}).await.map_err(|e|api_error(StatusCode::INTERNAL_SERVER_ERROR,e.to_string()))?.map_err(|e|api_error(StatusCode::INTERNAL_SERVER_ERROR,e))?;
|
||||||
|
let rooms = app.rooms.lock().unwrap().iter().map(|(id,r)| serde_json::json!({"id":id,"cityId":r.city_id,"active":r.active.load(Ordering::Relaxed),"players":r.players.load(Ordering::Relaxed),"tick":r.metrics.tick.load(Ordering::Relaxed),"savedTick":r.metrics.saved_tick.load(Ordering::Relaxed),"saveErrors":r.metrics.save_errors.load(Ordering::Relaxed)})).collect::<Vec<_>>();
|
||||||
|
Ok(Json(
|
||||||
|
serde_json::json!({"rooms":rooms,"snapshots":disk.1,"saveBytes":disk.0,"saveQuotaBytes":app.config.save_quota_bytes}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
async fn admin_checkpoint(
|
||||||
|
State(app): State<App>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
|
authorize(&app, &headers)?;
|
||||||
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||||
|
{
|
||||||
|
let rooms = app.rooms.lock().unwrap();
|
||||||
|
let room = rooms
|
||||||
|
.get(&id)
|
||||||
|
.ok_or_else(|| api_error(StatusCode::NOT_FOUND, "Room not found"))?;
|
||||||
|
if !room.active.load(Ordering::Relaxed) {
|
||||||
|
return Err(api_error(
|
||||||
|
StatusCode::CONFLICT,
|
||||||
|
"Room is already saved and inactive",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
room.commands
|
||||||
|
.try_send(Command::Checkpoint(tx))
|
||||||
|
.map_err(|_| api_error(StatusCode::TOO_MANY_REQUESTS, "Room command queue is busy"))?;
|
||||||
|
}
|
||||||
|
tokio::time::timeout(Duration::from_secs(20), rx)
|
||||||
|
.await
|
||||||
|
.map_err(|_| api_error(StatusCode::GATEWAY_TIMEOUT, "Checkpoint timed out"))?
|
||||||
|
.map_err(|_| api_error(StatusCode::SERVICE_UNAVAILABLE, "Room stopped"))?
|
||||||
|
.map_err(|e| api_error(StatusCode::INSUFFICIENT_STORAGE, e))?;
|
||||||
|
Ok(Json(serde_json::json!({"saved":true})))
|
||||||
|
}
|
||||||
async fn create_city(
|
async fn create_city(
|
||||||
State(app): State<App>,
|
State(app): State<App>,
|
||||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||||
@@ -533,6 +625,7 @@ async fn metrics(State(app): State<App>) -> String {
|
|||||||
app.active()
|
app.active()
|
||||||
);
|
);
|
||||||
for (id, room) in app.rooms.lock().unwrap().iter() {
|
for (id, room) in app.rooms.lock().unwrap().iter() {
|
||||||
|
text.push_str(&format!("citygen_room_save_errors_total{{room=\"{id}\"}} {}\ncitygen_room_saved_tick{{room=\"{id}\"}} {}\n", room.metrics.save_errors.load(Ordering::Relaxed), room.metrics.saved_tick.load(Ordering::Relaxed)));
|
||||||
text.push_str(&format!(
|
text.push_str(&format!(
|
||||||
"citygen_room_tick_max_seconds{{room=\"{id}\"}} {}\n",
|
"citygen_room_tick_max_seconds{{room=\"{id}\"}} {}\n",
|
||||||
room.metrics.max_micros.load(Ordering::Relaxed) as f64 / 1_000_000.0
|
room.metrics.max_micros.load(Ordering::Relaxed) as f64 / 1_000_000.0
|
||||||
@@ -648,13 +741,31 @@ fn room_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _lifecycle = Lifecycle(active.clone(), player_count.clone());
|
let _lifecycle = Lifecycle(active.clone(), player_count.clone());
|
||||||
let (save_tx, save_rx) = mpsc::sync_channel::<crate::simulation::Checkpoint>(1);
|
let (save_tx, save_rx) = mpsc::sync_channel::<(
|
||||||
|
crate::simulation::Checkpoint,
|
||||||
|
Option<tokio::sync::oneshot::Sender<Result<(), String>>>,
|
||||||
|
)>(1);
|
||||||
let save_root = config.saves.clone();
|
let save_root = config.saves.clone();
|
||||||
let keep = config.keep_snapshots;
|
let keep = config.keep_snapshots;
|
||||||
|
let quota = config.save_quota_bytes;
|
||||||
|
let save_metrics = metrics.clone();
|
||||||
let save_thread = std::thread::spawn(move || {
|
let save_thread = std::thread::spawn(move || {
|
||||||
while let Ok(snapshot) = save_rx.recv() {
|
while let Ok((snapshot, response)) = save_rx.recv() {
|
||||||
if let Err(e) = storage::save(&save_root, &snapshot.materialize(), keep) {
|
let snapshot = snapshot.materialize();
|
||||||
eprintln!("snapshot failed: {e}");
|
let result = storage::save_with_quota(&save_root, &snapshot, keep, quota);
|
||||||
|
if let Err(e) = &result {
|
||||||
|
save_metrics.save_errors.fetch_add(1, Ordering::Relaxed);
|
||||||
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({"event":"snapshot_failed","room":snapshot.id,"error":e})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
save_metrics
|
||||||
|
.saved_tick
|
||||||
|
.store(snapshot.tick, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
if let Some(response) = response {
|
||||||
|
let _ = response.send(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -669,6 +780,15 @@ fn room_loop(
|
|||||||
if let Ok(command) = rx.recv_timeout(wait) {
|
if let Ok(command) = rx.recv_timeout(wait) {
|
||||||
match command {
|
match command {
|
||||||
Command::Stop => stopping = true,
|
Command::Stop => stopping = true,
|
||||||
|
Command::Checkpoint(response) => {
|
||||||
|
if let Err(e) = save_tx.try_send((sim.checkpoint(), Some(response))) {
|
||||||
|
let (mpsc::TrySendError::Full((_, response))
|
||||||
|
| mpsc::TrySendError::Disconnected((_, response))) = e;
|
||||||
|
if let Some(response) = response {
|
||||||
|
let _ = response.send(Err("Snapshot writer is busy".into()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Command::Leave(id) => {
|
Command::Leave(id) => {
|
||||||
players.remove(&id);
|
players.remove(&id);
|
||||||
if let Some(b) = &mut ballot {
|
if let Some(b) = &mut ballot {
|
||||||
@@ -703,6 +823,7 @@ fn room_loop(
|
|||||||
lod: 0,
|
lod: 0,
|
||||||
known: HashMap::new(),
|
known: HashMap::new(),
|
||||||
last_proposal: None,
|
last_proposal: None,
|
||||||
|
last_intervention: None,
|
||||||
traffic: Traffic::new(),
|
traffic: Traffic::new(),
|
||||||
metrics: metrics.clone(),
|
metrics: metrics.clone(),
|
||||||
};
|
};
|
||||||
@@ -798,6 +919,32 @@ fn room_loop(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(client_message::Body::Intervene(command)) => {
|
||||||
|
if player
|
||||||
|
.last_intervention
|
||||||
|
.is_some_and(|at| at.elapsed() < Duration::from_secs(30))
|
||||||
|
{
|
||||||
|
reply(
|
||||||
|
player,
|
||||||
|
error("COMMAND_LIMIT", "Wait 30 seconds between commands"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let paused = sim.clock().speed == 0;
|
||||||
|
let count = sim.world.entities().len() as usize;
|
||||||
|
let result = sim
|
||||||
|
.world
|
||||||
|
.resource_mut::<crate::gameplay::Game>()
|
||||||
|
.enqueue(&command.action, count, paused);
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
player.last_intervention = Some(Instant::now());
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
reply(player, error("COMMAND_LIMIT", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some(client_message::Body::Vote(v)) => {
|
Some(client_message::Body::Vote(v)) => {
|
||||||
if let Some(b) = &mut ballot {
|
if let Some(b) = &mut ballot {
|
||||||
if Instant::now() < b.deadline
|
if Instant::now() < b.deadline
|
||||||
@@ -886,6 +1033,14 @@ fn room_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if clock.tick % 10 == 0 {
|
if clock.tick % 10 == 0 {
|
||||||
|
if !reply(
|
||||||
|
player,
|
||||||
|
message(server_message::Body::Gameplay(
|
||||||
|
sim.world.resource::<crate::gameplay::Game>().0.clone(),
|
||||||
|
)),
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let b = ballot.as_ref();
|
let b = ballot.as_ref();
|
||||||
if !reply(
|
if !reply(
|
||||||
player,
|
player,
|
||||||
@@ -923,10 +1078,8 @@ fn room_loop(
|
|||||||
}
|
}
|
||||||
if begin.elapsed() > Duration::from_millis(50) {
|
if begin.elapsed() > Duration::from_millis(50) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"room={} tick={} elapsed_ms={}",
|
"{}",
|
||||||
sim.id,
|
serde_json::json!({"event":"slow_tick","room":sim.id,"tick":clock.tick,"elapsed_ms":begin.elapsed().as_millis()})
|
||||||
clock.tick,
|
|
||||||
begin.elapsed().as_millis()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
next_tick += Duration::from_millis(100);
|
next_tick += Duration::from_millis(100);
|
||||||
@@ -934,7 +1087,7 @@ fn room_loop(
|
|||||||
next_tick = Instant::now() + Duration::from_millis(100);
|
next_tick = Instant::now() + Duration::from_millis(100);
|
||||||
}
|
}
|
||||||
if saved_at.elapsed() >= config.save_interval {
|
if saved_at.elapsed() >= config.save_interval {
|
||||||
if let Err(error) = save_tx.try_send(sim.checkpoint()) {
|
if let Err(error) = save_tx.try_send((sim.checkpoint(), None)) {
|
||||||
eprintln!("snapshot writer is busy or unavailable: {error}");
|
eprintln!("snapshot writer is busy or unavailable: {error}");
|
||||||
}
|
}
|
||||||
saved_at = Instant::now();
|
saved_at = Instant::now();
|
||||||
@@ -954,7 +1107,7 @@ fn room_loop(
|
|||||||
.store(sim.world.entities().len() as u64, Ordering::Relaxed);
|
.store(sim.world.entities().len() as u64, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if save_tx.send(sim.checkpoint()).is_err() {
|
if save_tx.send((sim.checkpoint(), None)).is_err() {
|
||||||
eprintln!("final snapshot writer is unavailable");
|
eprintln!("final snapshot writer is unavailable");
|
||||||
}
|
}
|
||||||
drop(save_tx);
|
drop(save_tx);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod protocol {
|
|||||||
include!(concat!(env!("OUT_DIR"), "/city.rs"));
|
include!(concat!(env!("OUT_DIR"), "/city.rs"));
|
||||||
}
|
}
|
||||||
pub mod city;
|
pub mod city;
|
||||||
|
pub mod gameplay;
|
||||||
pub mod gateway;
|
pub mod gateway;
|
||||||
pub mod generation;
|
pub mod generation;
|
||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
|
|||||||
+24
-2
@@ -28,6 +28,12 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
if let Ok(value) = std::env::var("CITYGEN_KEEP_SNAPSHOTS") {
|
if let Ok(value) = std::env::var("CITYGEN_KEEP_SNAPSHOTS") {
|
||||||
config.keep_snapshots = value.parse()?;
|
config.keep_snapshots = value.parse()?;
|
||||||
}
|
}
|
||||||
|
if let Ok(value) = std::env::var("CITYGEN_SAVE_QUOTA_BYTES") {
|
||||||
|
config.save_quota_bytes = value.parse()?;
|
||||||
|
}
|
||||||
|
config.admin_token = std::env::var("CITYGEN_ADMIN_TOKEN")
|
||||||
|
.ok()
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
if let Ok(value) = std::env::var("CITYGEN_MAX_GENERATION_KM2") {
|
if let Ok(value) = std::env::var("CITYGEN_MAX_GENERATION_KM2") {
|
||||||
config.max_generation_km2 = value.parse()?;
|
config.max_generation_km2 = value.parse()?;
|
||||||
}
|
}
|
||||||
@@ -49,17 +55,33 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let app = App::new(config).map_err(std::io::Error::other)?;
|
let app = App::new(config).map_err(std::io::Error::other)?;
|
||||||
let address = std::env::var("CITYGEN_BIND").unwrap_or("127.0.0.1:3000".into());
|
let address = std::env::var("CITYGEN_BIND").unwrap_or("127.0.0.1:3000".into());
|
||||||
let listener = tokio::net::TcpListener::bind(&address).await?;
|
let listener = tokio::net::TcpListener::bind(&address).await?;
|
||||||
println!("Citygen: http://{address}");
|
eprintln!(
|
||||||
|
"{}",
|
||||||
|
serde_json::json!({"event":"server_started","address":address})
|
||||||
|
);
|
||||||
let shutdown_app = app.clone();
|
let shutdown_app = app.clone();
|
||||||
axum::serve(
|
axum::serve(
|
||||||
listener,
|
listener,
|
||||||
gateway::router(app.clone()).into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
gateway::router(app.clone()).into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||||
)
|
)
|
||||||
.with_graceful_shutdown(async move {
|
.with_graceful_shutdown(async move {
|
||||||
let _ = tokio::signal::ctrl_c().await;
|
shutdown_signal().await;
|
||||||
let _ = tokio::task::spawn_blocking(move || shutdown_app.stop()).await;
|
let _ = tokio::task::spawn_blocking(move || shutdown_app.stop()).await;
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
app.stop();
|
app.stop();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
let mut terminate =
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("SIGTERM handler");
|
||||||
|
tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {} }
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
eprintln!("{}", serde_json::json!({"event":"server_stopping"}));
|
||||||
|
}
|
||||||
@@ -225,6 +225,7 @@ impl Simulation {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
});
|
});
|
||||||
world.insert_resource(PositionChanges::default());
|
world.insert_resource(PositionChanges::default());
|
||||||
|
world.insert_resource(crate::gameplay::Game::new(count as usize, 7.9 * 3600.0));
|
||||||
world.spawn_batch((0..count).map(|i| {
|
world.spawn_batch((0..count).map(|i| {
|
||||||
(
|
(
|
||||||
Resident {
|
Resident {
|
||||||
@@ -268,6 +269,12 @@ impl Simulation {
|
|||||||
clock.tick += 1;
|
clock.tick += 1;
|
||||||
clock.seconds += clock.speed as f64 * 6.0;
|
clock.seconds += clock.speed as f64 * 6.0;
|
||||||
}
|
}
|
||||||
|
let seconds = self.clock().seconds;
|
||||||
|
self.world.resource_mut::<crate::gameplay::Game>().advance(
|
||||||
|
seconds,
|
||||||
|
self.population.len(),
|
||||||
|
self.seed,
|
||||||
|
);
|
||||||
self.schedule.run(&mut self.world);
|
self.schedule.run(&mut self.world);
|
||||||
}
|
}
|
||||||
pub fn clock(&self) -> Clock {
|
pub fn clock(&self) -> Clock {
|
||||||
@@ -387,7 +394,7 @@ impl Simulation {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let metadata = RoomSave {
|
let metadata = RoomSave {
|
||||||
version: 1,
|
version: 2,
|
||||||
id: self.id.clone(),
|
id: self.id.clone(),
|
||||||
city_id: self.city_id.clone(),
|
city_id: self.city_id.clone(),
|
||||||
city_revision: self.city_revision.clone(),
|
city_revision: self.city_revision.clone(),
|
||||||
@@ -398,6 +405,7 @@ impl Simulation {
|
|||||||
speed: clock.speed,
|
speed: clock.speed,
|
||||||
agents: vec![],
|
agents: vec![],
|
||||||
routes,
|
routes,
|
||||||
|
gameplay: Some(self.world.resource::<crate::gameplay::Game>().0.clone()),
|
||||||
};
|
};
|
||||||
Checkpoint {
|
Checkpoint {
|
||||||
metadata,
|
metadata,
|
||||||
@@ -405,10 +413,13 @@ impl Simulation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
pub fn restore(saved: RoomSave) -> Result<Self, String> {
|
pub fn restore(saved: RoomSave) -> Result<Self, String> {
|
||||||
if saved.version != 1 || saved.city_version != 1 {
|
if ![1, 2].contains(&saved.version) || saved.city_version != 1 {
|
||||||
return Err("Incompatible room snapshot".into());
|
return Err("Incompatible room snapshot".into());
|
||||||
}
|
}
|
||||||
if !saved.game_seconds.is_finite() || ![0, 1, 2, 4].contains(&saved.speed) {
|
if !saved.game_seconds.is_finite()
|
||||||
|
|| saved.game_seconds < 0.0
|
||||||
|
|| ![0, 1, 2, 4].contains(&saved.speed)
|
||||||
|
{
|
||||||
return Err("Invalid room clock".into());
|
return Err("Invalid room clock".into());
|
||||||
}
|
}
|
||||||
let mut ids = std::collections::HashSet::new();
|
let mut ids = std::collections::HashSet::new();
|
||||||
@@ -427,6 +438,14 @@ impl Simulation {
|
|||||||
paths.push(route.xy.chunks_exact(2).map(|p| [p[0], p[1]]).collect());
|
paths.push(route.xy.chunks_exact(2).map(|p| [p[0], p[1]]).collect());
|
||||||
}
|
}
|
||||||
let mut s = Self::new(saved.id, saved.city_id, saved.seed, 0, paths)?;
|
let mut s = Self::new(saved.id, saved.city_id, saved.seed, 0, paths)?;
|
||||||
|
let gameplay = if saved.version == 1 {
|
||||||
|
crate::gameplay::Game::new(saved.agents.len(), saved.game_seconds)
|
||||||
|
} else {
|
||||||
|
let state = saved.gameplay.ok_or("Missing saved gameplay")?;
|
||||||
|
crate::gameplay::Game::validate(&state, saved.game_seconds, saved.agents.len())?;
|
||||||
|
crate::gameplay::Game(state)
|
||||||
|
};
|
||||||
|
s.world.insert_resource(gameplay);
|
||||||
s.goals(
|
s.goals(
|
||||||
saved
|
saved
|
||||||
.routes
|
.routes
|
||||||
@@ -545,4 +564,31 @@ mod tests {
|
|||||||
a.step();
|
a.step();
|
||||||
assert_eq!(a.clock().seconds, seconds);
|
assert_eq!(a.clock().seconds, seconds);
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn legacy_snapshot_migrates_and_pending_commands_survive_restart() {
|
||||||
|
let mut sim =
|
||||||
|
Simulation::new("r".into(), "c".into(), 7, 10, vec![vec![[0, 0], [100, 0]]]).unwrap();
|
||||||
|
let mut legacy = sim.save();
|
||||||
|
legacy.version = 1;
|
||||||
|
legacy.gameplay = None;
|
||||||
|
let mut migrated = Simulation::restore(legacy).unwrap();
|
||||||
|
assert_eq!(migrated.save().version, 2);
|
||||||
|
migrated
|
||||||
|
.world
|
||||||
|
.resource_mut::<crate::gameplay::Game>()
|
||||||
|
.enqueue("supplies", 10, false)
|
||||||
|
.unwrap();
|
||||||
|
let saved = migrated.save();
|
||||||
|
let mut restored = Simulation::restore(saved.clone()).unwrap();
|
||||||
|
assert_eq!(saved, restored.save());
|
||||||
|
for _ in 0..100 {
|
||||||
|
migrated.step();
|
||||||
|
restored.step();
|
||||||
|
}
|
||||||
|
assert_eq!(migrated.save(), restored.save());
|
||||||
|
assert!(restored.save().gameplay.unwrap().pending.is_empty());
|
||||||
|
let mut invalid = restored.save();
|
||||||
|
invalid.version = 99;
|
||||||
|
assert!(Simulation::restore(invalid).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+56
-4
@@ -7,11 +7,46 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub fn save(root: &Path, snapshot: &RoomSave, keep: usize) -> Result<(), String> {
|
pub fn save(root: &Path, snapshot: &RoomSave, keep: usize) -> Result<(), String> {
|
||||||
|
save_with_quota(root, snapshot, keep, u64::MAX)
|
||||||
|
}
|
||||||
|
// Serialize quota checks and writes across room writer threads. Never evict the
|
||||||
|
// last snapshot of another room to make space for the current one.
|
||||||
|
static WRITES: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||||
|
pub fn disk_bytes(root: &Path) -> Result<u64, String> {
|
||||||
|
let mut bytes = 0u64;
|
||||||
|
for entry in fs::read_dir(root).map_err(|e| e.to_string())? {
|
||||||
|
let entry = entry.map_err(|e| e.to_string())?;
|
||||||
|
let kind = entry.file_type().map_err(|e| e.to_string())?;
|
||||||
|
if kind.is_symlink() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bytes = bytes.saturating_add(if kind.is_dir() {
|
||||||
|
disk_bytes(&entry.path())?
|
||||||
|
} else {
|
||||||
|
entry.metadata().map_err(|e| e.to_string())?.len()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(bytes)
|
||||||
|
}
|
||||||
|
pub fn save_with_quota(
|
||||||
|
root: &Path,
|
||||||
|
snapshot: &RoomSave,
|
||||||
|
keep: usize,
|
||||||
|
quota: u64,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let _guard = WRITES.lock().map_err(|_| "Snapshot lock poisoned")?;
|
||||||
if !safe_id(&snapshot.id) {
|
if !safe_id(&snapshot.id) {
|
||||||
return Err("Invalid room ID".into());
|
return Err("Invalid room ID".into());
|
||||||
}
|
}
|
||||||
let folder = root.join(&snapshot.id);
|
let folder = root.join(&snapshot.id);
|
||||||
fs::create_dir_all(&folder).map_err(|e| e.to_string())?;
|
fs::create_dir_all(&folder).map_err(|e| e.to_string())?;
|
||||||
|
let bytes = snapshot.encode_to_vec();
|
||||||
|
if bytes.len() > 128 * 1024 * 1024 {
|
||||||
|
return Err("Snapshot exceeds 128 MiB".into());
|
||||||
|
}
|
||||||
|
if disk_bytes(root)?.saturating_add(bytes.len() as u64) > quota {
|
||||||
|
return Err("Snapshot disk quota exceeded; previous snapshots preserved".into());
|
||||||
|
}
|
||||||
let name = format!("{:020}.pb", snapshot.tick);
|
let name = format!("{:020}.pb", snapshot.tick);
|
||||||
let file = folder.join(&name);
|
let file = folder.join(&name);
|
||||||
// Replace through a complete temporary file, including control changes that
|
// Replace through a complete temporary file, including control changes that
|
||||||
@@ -19,9 +54,7 @@ pub fn save(root: &Path, snapshot: &RoomSave, keep: usize) -> Result<(), String>
|
|||||||
{
|
{
|
||||||
let temporary = folder.join(format!("{name}.tmp"));
|
let temporary = folder.join(format!("{name}.tmp"));
|
||||||
let mut output = fs::File::create(&temporary).map_err(|e| e.to_string())?;
|
let mut output = fs::File::create(&temporary).map_err(|e| e.to_string())?;
|
||||||
output
|
output.write_all(&bytes).map_err(|e| e.to_string())?;
|
||||||
.write_all(&snapshot.encode_to_vec())
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
output.sync_all().map_err(|e| e.to_string())?;
|
output.sync_all().map_err(|e| e.to_string())?;
|
||||||
drop(output);
|
drop(output);
|
||||||
fs::rename(temporary, file).map_err(|e| e.to_string())?;
|
fs::rename(temporary, file).map_err(|e| e.to_string())?;
|
||||||
@@ -51,12 +84,15 @@ pub fn load(root: &Path, id: &str) -> Result<RoomSave, String> {
|
|||||||
files.reverse();
|
files.reverse();
|
||||||
// Try an older intact file if the latest is damaged.
|
// Try an older intact file if the latest is damaged.
|
||||||
for file in files {
|
for file in files {
|
||||||
|
if fs::metadata(&file).map_or(true, |m| m.len() > 128 * 1024 * 1024) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Ok(bytes) = fs::read(file) {
|
if let Ok(bytes) = fs::read(file) {
|
||||||
if let Ok(snapshot) = RoomSave::decode(&bytes[..]) {
|
if let Ok(snapshot) = RoomSave::decode(&bytes[..]) {
|
||||||
if snapshot.id != id {
|
if snapshot.id != id {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if snapshot.version != 1 || snapshot.city_version != 1 {
|
if ![1, 2].contains(&snapshot.version) || snapshot.city_version != 1 {
|
||||||
return Err("Incompatible room snapshot".into());
|
return Err("Incompatible room snapshot".into());
|
||||||
}
|
}
|
||||||
return Ok(snapshot);
|
return Ok(snapshot);
|
||||||
@@ -91,4 +127,20 @@ mod tests {
|
|||||||
assert_eq!(load(dir.path(), "r").unwrap().tick, 3);
|
assert_eq!(load(dir.path(), "r").unwrap().tick, 3);
|
||||||
assert!(load(dir.path(), "../secret").is_err());
|
assert!(load(dir.path(), "../secret").is_err());
|
||||||
}
|
}
|
||||||
|
#[test]
|
||||||
|
fn quota_preserves_committed_snapshot() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let mut snapshot = RoomSave {
|
||||||
|
version: 2,
|
||||||
|
city_version: 1,
|
||||||
|
id: "r".into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
save(dir.path(), &snapshot, 3).unwrap();
|
||||||
|
let size = disk_bytes(dir.path()).unwrap();
|
||||||
|
snapshot.tick = 1;
|
||||||
|
assert!(save_with_quota(dir.path(), &snapshot, 3, size).is_err());
|
||||||
|
assert_eq!(load(dir.path(), "r").unwrap().tick, 0);
|
||||||
|
assert_eq!(disk_bytes(dir.path()).unwrap(), size);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,19 @@ async fn read(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
async fn http_request(
|
||||||
|
address: std::net::SocketAddr,
|
||||||
|
path: &str,
|
||||||
|
method: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> String {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
let mut socket = tokio::net::TcpStream::connect(address).await.unwrap();
|
||||||
|
socket.write_all(format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer {token}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").as_bytes()).await.unwrap();
|
||||||
|
let mut response = String::new();
|
||||||
|
socket.read_to_string(&mut response).await.unwrap();
|
||||||
|
response
|
||||||
|
}
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn thirty_observers_snapshot_delta_vote_and_restart() {
|
async fn thirty_observers_snapshot_delta_vote_and_restart() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -38,6 +51,7 @@ async fn thirty_observers_snapshot_delta_vote_and_restart() {
|
|||||||
let app = App::new(Config {
|
let app = App::new(Config {
|
||||||
cities: dir.path().join("cities"),
|
cities: dir.path().join("cities"),
|
||||||
saves: dir.path().join("rooms"),
|
saves: dir.path().join("rooms"),
|
||||||
|
admin_token: Some("test-only-administrator-token-32-chars".into()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -63,6 +77,26 @@ async fn thirty_observers_snapshot_delta_vote_and_restart() {
|
|||||||
let value: serde_json::Value =
|
let value: serde_json::Value =
|
||||||
serde_json::from_str(response.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
serde_json::from_str(response.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||||
let id = value["id"].as_str().unwrap().to_string();
|
let id = value["id"].as_str().unwrap().to_string();
|
||||||
|
assert!(http_request(address, "/api/admin", "GET", "wrong")
|
||||||
|
.await
|
||||||
|
.starts_with("HTTP/1.1 401"));
|
||||||
|
assert!(http_request(
|
||||||
|
address,
|
||||||
|
"/api/admin",
|
||||||
|
"GET",
|
||||||
|
"test-only-administrator-token-32-chars"
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.starts_with("HTTP/1.1 200"));
|
||||||
|
let checkpoint = http_request(
|
||||||
|
address,
|
||||||
|
&format!("/api/admin/rooms/{id}/checkpoint"),
|
||||||
|
"POST",
|
||||||
|
"test-only-administrator-token-32-chars",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(checkpoint.starts_with("HTTP/1.1 200"), "{checkpoint}");
|
||||||
|
assert!(citygen_server::storage::load(&dir.path().join("rooms"), &id).is_ok());
|
||||||
let mut sockets = vec![];
|
let mut sockets = vec![];
|
||||||
for i in 0..30 {
|
for i in 0..30 {
|
||||||
let (mut socket, _) =
|
let (mut socket, _) =
|
||||||
@@ -117,6 +151,37 @@ async fn thirty_observers_snapshot_delta_vote_and_restart() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 31st observer must be rejected without affecting existing players.
|
// 31st observer must be rejected without affecting existing players.
|
||||||
|
let command = p::ClientMessage {
|
||||||
|
body: Some(client_message::Body::Intervene(p::Intervene {
|
||||||
|
action: "meal".into(),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
.encode_to_vec();
|
||||||
|
s.send(tokio_tungstenite::tungstenite::Message::Binary(
|
||||||
|
command.clone().into(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
s.send(tokio_tungstenite::tungstenite::Message::Binary(
|
||||||
|
command.into(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
loop {
|
||||||
|
if let Some(server_message::Body::Error(error)) = read(s).await.body {
|
||||||
|
assert_eq!(error.code, "COMMAND_LIMIT");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
if let Some(server_message::Body::Gameplay(state)) = read(s).await.body {
|
||||||
|
assert!(
|
||||||
|
state.pending.iter().any(|p| p == "meal")
|
||||||
|
|| state.events.iter().any(|e| e.kind == "meal")
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
let (mut extra, _) =
|
let (mut extra, _) =
|
||||||
tokio_tungstenite::connect_async(format!("ws://{address}/api/rooms/{id}/ws"))
|
tokio_tungstenite::connect_async(format!("ws://{address}/api/rooms/{id}/ws"))
|
||||||
.await
|
.await
|
||||||
@@ -232,6 +297,14 @@ async fn thirty_observers_snapshot_delta_vote_and_restart() {
|
|||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert!(http_request(
|
||||||
|
address,
|
||||||
|
"/api/admin",
|
||||||
|
"GET",
|
||||||
|
"test-only-administrator-token-32-chars"
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.starts_with("HTTP/1.1 404"));
|
||||||
assert!(
|
assert!(
|
||||||
matches!(read(&mut socket).await.body,Some(server_message::Body::Welcome(w)) if w.tick>=saved.tick)
|
matches!(read(&mut socket).await.body,Some(server_message::Body::Welcome(w)) if w.tick>=saved.tick)
|
||||||
);
|
);
|
||||||
|
|||||||
+17
@@ -7,6 +7,7 @@ import type { World } from './types';
|
|||||||
import { TileLoader } from '../client/tiles';
|
import { TileLoader } from '../client/tiles';
|
||||||
import type { Bounds } from './city/format';
|
import type { Bounds } from './city/format';
|
||||||
import { RoomConnection } from '../client/connection';
|
import { RoomConnection } from '../client/connection';
|
||||||
|
import { showAdmin } from '../client/admin';
|
||||||
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
import { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
||||||
import { CityJobSchema, CreateCitySchema } from '../client/generated/city_pb';
|
import { CityJobSchema, CreateCitySchema } from '../client/generated/city_pb';
|
||||||
|
|
||||||
@@ -325,6 +326,14 @@ async function showTiledCity(url: string, connection?: RoomConnection) {
|
|||||||
}
|
}
|
||||||
const vote = document.createElement('span'); vote.className = 'room-vote'; vote.setAttribute('role','status'); controls.appendChild(vote);
|
const vote = document.createElement('span'); vote.className = 'room-vote'; vote.setAttribute('role','status'); controls.appendChild(vote);
|
||||||
tiledViewer.root.querySelector('.viewer-top')!.appendChild(controls);
|
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;
|
(window as any).viewer = viewer;
|
||||||
hideProgress();
|
hideProgress();
|
||||||
@@ -361,6 +370,13 @@ async function showLobby() {
|
|||||||
status: text => { status.textContent = text; viewer?.setLoadStatus(text); },
|
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}`; },
|
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} игроков`; },
|
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;
|
roomConnection = connection;
|
||||||
};
|
};
|
||||||
@@ -394,6 +410,7 @@ async function showLobby() {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
|
if (params.has('admin')) return showAdmin(app);
|
||||||
if (params.has('play')) return showLobby();
|
if (params.has('play')) return showLobby();
|
||||||
if (params.has('city')) return showTiledCity(params.get('city')!);
|
if (params.has('city')) return showTiledCity(params.get('city')!);
|
||||||
if (params.has('last') && (await hasWorld())) {
|
if (params.has('last') && (await hasWorld())) {
|
||||||
|
|||||||
Reference in new issue
Block a user