Fix city generation retries and remove city-wide economy mechanics
This commit is contained in:
1 parent
85df37c26e
commit
63f0c72ff3
21 files changed
+252
-585
No files matched your search
+14
-24
@@ -25,7 +25,10 @@
|
||||
Ник уникален в комнате, сессия выдаётся сервером. Пауза и скорости 1/2/4
|
||||
принимаются большинством более половины подключённых игроков. Голосование
|
||||
длится 20 с; интервал предложений игрока — 30 с. Владелец и привилегии
|
||||
создателя отсутствуют. Создание ограничено одним запросом с IP в минуту.
|
||||
создателя отсутствуют. Создание ограничено одним новым городом и одной новой
|
||||
комнатой с IP в минуту (независимые лимиты). Повторный запрос той же области
|
||||
возвращает уже принятую задачу без расхода лимита. Отказ 429 содержит
|
||||
`Retry-After` и `retryAfterSeconds` с оставшимся временем ожидания.
|
||||
|
||||
Снимки Protobuf кодируются и записываются отдельным ограниченным по очереди
|
||||
потоком, атомарно заменяются и ротируются; голоса и подключения
|
||||
@@ -53,6 +56,10 @@
|
||||
`CityJob` из общей схемы передаются по HTTP как `application/x-protobuf`.
|
||||
Каталог и создание комнаты используют JSON. Задания очереди не переживают
|
||||
перезапуск; готовые города и снимки комнат сохраняются на диске.
|
||||
Ссылка `/?create-city&job=ID` возобновляет ожидание после обновления страницы.
|
||||
Серверные запросы Overpass используют User-Agent приложения и таймаут 90 с
|
||||
(браузерный отладочный генератор — 45 с). Прогресс включает выбранный сервер
|
||||
и повторные попытки; технический stderr воркера остаётся в логах сервера.
|
||||
|
||||
`/health` проверяет доступность; `/metrics` отдаёт число игроков/комнат,
|
||||
тик, время последнего и максимального шага, число сущностей и байты очереди рассылки каждой
|
||||
@@ -73,27 +80,10 @@ LOD и выводят трафик, выборку длительности ти
|
||||
Compose, постоянные тома, лимиты, healthcheck и профиль Prometheus описаны
|
||||
в [deploy/README.md](../deploy/README.md).
|
||||
|
||||
## Игровой слой
|
||||
## Совместимость сохранений
|
||||
|
||||
Голод, усталость и настроение моделируются как усреднённые показатели
|
||||
населения комнаты. Они меняются по игровому календарю независимо от камеры.
|
||||
Сон ночью восстанавливает силы; питание трижды в день тратит запасы еды.
|
||||
Голод и усталость снижают производство и доход казны в рабочие часы.
|
||||
Почасовые события (урожай, болезнь, дождь) детерминированы seed и временем.
|
||||
История ограничена 16 событиями. Это базовая экономика общего города;
|
||||
индивидуальные кошельки, машины и детальное использование мебели не включены.
|
||||
|
||||
Все игроки имеют одинаковые права на три команды: покупка запасов
|
||||
(2 единицы казны на жителя), общий обед (1), отдых (3). Цена списывается
|
||||
при приёме, действие исполняется в следующую игровую минуту. Сервер проверяет
|
||||
ресурсы, паузу, очередь (до 8), общий интервал 60 игровых минут и интервал
|
||||
сессии 30 реальных секунд. Общий интервал переживает переподключение.
|
||||
Пауза и скорость по-прежнему требуют большинства голосов.
|
||||
|
||||
Снимки v2 сохраняют экономику, потребности, историю, очередь и общий интервал.
|
||||
Для v1 игровой слой инициализируется по числу жителей и сохранённому времени;
|
||||
позиции, seed и расписания сохраняются. Неизвестные версии не перезаписываются.
|
||||
Квота проверяется под общей блокировкой писателей комнат; при нехватке места
|
||||
прежние снимки остаются. Один снимок ограничен 128 МиБ.
|
||||
`/?admin` и API с Bearer-токеном показывают каталог, ошибки, квоту
|
||||
и позволяют сохранить активную комнату.
|
||||
Снимки v3 загружаются вместе с прежними v1/v2. Удалённые поля городской
|
||||
экономики отбрасываются; жители, маршруты, время и seed сохраняются.
|
||||
Общегородская казна, запасы, усреднённые потребности и массовые команды удалены.
|
||||
Пауза и скорость по-прежнему принимаются голосованием.
|
||||
Квота снимков и администрирование продолжают работать.
|
||||
@@ -1,205 +0,0 @@
|
||||
//! 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();
|
||||
}
|
||||
}
|
||||
+41
-60
@@ -79,7 +79,7 @@ impl Default for Config {
|
||||
pub struct App {
|
||||
pub config: Config,
|
||||
rooms: Arc<Mutex<HashMap<String, RoomHandle>>>,
|
||||
limits: Arc<Mutex<HashMap<std::net::IpAddr, Instant>>>,
|
||||
limits: Arc<Mutex<HashMap<(std::net::IpAddr, &'static str), Instant>>>,
|
||||
pub jobs: crate::generation::Jobs,
|
||||
}
|
||||
struct RoomHandle {
|
||||
@@ -118,7 +118,6 @@ struct Player {
|
||||
lod: u32,
|
||||
known: HashMap<u32, p::Agent>,
|
||||
last_proposal: Option<Instant>,
|
||||
last_intervention: Option<Instant>,
|
||||
traffic: Traffic,
|
||||
metrics: Arc<RoomMetrics>,
|
||||
}
|
||||
@@ -190,6 +189,14 @@ type ApiError = (StatusCode, Json<serde_json::Value>);
|
||||
fn api_error(code: StatusCode, text: impl Into<String>) -> ApiError {
|
||||
(code, Json(serde_json::json!({"error":text.into()})))
|
||||
}
|
||||
fn creation_throttled(at: Instant, now: Instant) -> axum::response::Response {
|
||||
let seconds = 60u64
|
||||
.saturating_sub(now.duration_since(at).as_secs())
|
||||
.max(1);
|
||||
(StatusCode::TOO_MANY_REQUESTS,
|
||||
[(axum::http::header::RETRY_AFTER, seconds.to_string())],
|
||||
Json(serde_json::json!({"error":format!("Слишком частые запросы создания. Повторите через {seconds} с."),"retryAfterSeconds":seconds}))).into_response()
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new(config: Config) -> Result<Self, String> {
|
||||
@@ -469,7 +476,7 @@ async fn create_city(
|
||||
State(app): State<App>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
body: axum::body::Bytes,
|
||||
) -> Result<impl IntoResponse, ApiError> {
|
||||
) -> Result<axum::response::Response, ApiError> {
|
||||
let input = p::CreateCity::decode(&body[..]).map_err(|_| {
|
||||
api_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -484,25 +491,37 @@ async fn create_city(
|
||||
name: input.name,
|
||||
seed: input.seed,
|
||||
};
|
||||
crate::generation::normalize(&request, app.config.max_generation_km2)
|
||||
let bbox = crate::generation::normalize(&request, app.config.max_generation_km2)
|
||||
.map_err(|e| api_error(StatusCode::BAD_REQUEST, e))?;
|
||||
{
|
||||
let city_id = crate::generation::cache_id(bbox, request.seed);
|
||||
let job = {
|
||||
let mut limits = app.limits.lock().unwrap();
|
||||
// Resuming an accepted job does not create work or spend another token.
|
||||
if let Some(job) = app
|
||||
.jobs
|
||||
.items
|
||||
.lock()
|
||||
.unwrap()
|
||||
.values()
|
||||
.find(|j| j.city_id == city_id && j.status != "failed")
|
||||
.cloned()
|
||||
{
|
||||
return Ok(job_response(job, String::new()).into_response());
|
||||
}
|
||||
let now = Instant::now();
|
||||
limits.retain(|_, at| now.duration_since(*at) < Duration::from_secs(60));
|
||||
if limits.contains_key(&peer.ip()) {
|
||||
return Err(api_error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"One creation per IP per minute",
|
||||
));
|
||||
let key = (peer.ip(), "city");
|
||||
if let Some(at) = limits.get(&key) {
|
||||
return Ok(creation_throttled(*at, now));
|
||||
}
|
||||
limits.insert(peer.ip(), now);
|
||||
}
|
||||
let job = app
|
||||
.jobs
|
||||
.submit(app.clone(), request)
|
||||
.map_err(|e| api_error(StatusCode::CONFLICT, e))?;
|
||||
Ok(job_response(job, String::new()))
|
||||
let job = app
|
||||
.jobs
|
||||
.submit(app.clone(), request)
|
||||
.map_err(|e| api_error(StatusCode::CONFLICT, e))?;
|
||||
limits.insert(key, now);
|
||||
job
|
||||
};
|
||||
Ok(job_response(job, String::new()).into_response())
|
||||
}
|
||||
async fn city_job(
|
||||
State(app): State<App>,
|
||||
@@ -586,7 +605,7 @@ async fn create_room(
|
||||
State(app): State<App>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
Json(request): Json<CreateRoom>,
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
) -> Result<axum::response::Response, ApiError> {
|
||||
if !safe_id(&request.city_id) {
|
||||
return Err(api_error(StatusCode::BAD_REQUEST, "Invalid city ID"));
|
||||
}
|
||||
@@ -594,13 +613,10 @@ async fn create_room(
|
||||
let mut limits = app.limits.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
limits.retain(|_, at| now.duration_since(*at) < Duration::from_secs(60));
|
||||
if limits.contains_key(&peer.ip()) {
|
||||
return Err(api_error(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"One room creation per IP per minute",
|
||||
));
|
||||
if let Some(at) = limits.get(&(peer.ip(), "room")) {
|
||||
return Ok(creation_throttled(*at, now));
|
||||
}
|
||||
limits.insert(peer.ip(), now);
|
||||
limits.insert((peer.ip(), "room"), now);
|
||||
}
|
||||
let id = uuid::Uuid::new_v4().simple().to_string();
|
||||
let room_id = id.clone();
|
||||
@@ -610,7 +626,7 @@ async fn create_room(
|
||||
.await
|
||||
.map_err(|e| api_error(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.map_err(|e| api_error(StatusCode::CONFLICT, e))?;
|
||||
Ok(Json(serde_json::json!({"id":id})))
|
||||
Ok(Json(serde_json::json!({"id":id})).into_response())
|
||||
}
|
||||
async fn metrics(State(app): State<App>) -> String {
|
||||
let players: usize = app
|
||||
@@ -823,7 +839,6 @@ fn room_loop(
|
||||
lod: 0,
|
||||
known: HashMap::new(),
|
||||
last_proposal: None,
|
||||
last_intervention: None,
|
||||
traffic: Traffic::new(),
|
||||
metrics: metrics.clone(),
|
||||
};
|
||||
@@ -919,32 +934,6 @@ 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)) => {
|
||||
if let Some(b) = &mut ballot {
|
||||
if Instant::now() < b.deadline
|
||||
@@ -1033,14 +1022,6 @@ fn room_loop(
|
||||
}
|
||||
}
|
||||
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();
|
||||
if !reply(
|
||||
player,
|
||||
|
||||
@@ -119,6 +119,10 @@ impl Jobs {
|
||||
current.room_id = Some(room);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({"event":"generation_failed","job":task.id,"error":e})
|
||||
);
|
||||
current.status = "failed".into();
|
||||
current.error = Some(e);
|
||||
}
|
||||
@@ -183,9 +187,23 @@ impl Jobs {
|
||||
.map_err(|e| e.to_string())?;
|
||||
let error = reader.await.unwrap_or_default();
|
||||
if !status.success() {
|
||||
let readable = error
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(line.strip_prefix("CITYGEN_ERROR:")?).ok()?;
|
||||
value["message"].as_str().map(str::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
"Ошибка воркера генерации. Подробности в логах сервера.".into()
|
||||
});
|
||||
eprintln!(
|
||||
"{}",
|
||||
serde_json::json!({"event":"generation_worker_failed","job":job.id,"stderr":error})
|
||||
);
|
||||
return Err(format!(
|
||||
"City generation failed: {}",
|
||||
error.chars().take(2000).collect::<String>()
|
||||
"Не удалось создать город: {}",
|
||||
readable.chars().take(2000).collect::<String>()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ pub mod protocol {
|
||||
include!(concat!(env!("OUT_DIR"), "/city.rs"));
|
||||
}
|
||||
pub mod city;
|
||||
pub mod gameplay;
|
||||
pub mod gateway;
|
||||
pub mod generation;
|
||||
pub mod navigation;
|
||||
|
||||
+18
-38
@@ -225,7 +225,6 @@ impl Simulation {
|
||||
..Default::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| {
|
||||
(
|
||||
Resident {
|
||||
@@ -269,12 +268,6 @@ impl Simulation {
|
||||
clock.tick += 1;
|
||||
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);
|
||||
}
|
||||
pub fn clock(&self) -> Clock {
|
||||
@@ -394,7 +387,7 @@ impl Simulation {
|
||||
})
|
||||
.collect();
|
||||
let metadata = RoomSave {
|
||||
version: 2,
|
||||
version: 3,
|
||||
id: self.id.clone(),
|
||||
city_id: self.city_id.clone(),
|
||||
city_revision: self.city_revision.clone(),
|
||||
@@ -405,7 +398,6 @@ impl Simulation {
|
||||
speed: clock.speed,
|
||||
agents: vec![],
|
||||
routes,
|
||||
gameplay: Some(self.world.resource::<crate::gameplay::Game>().0.clone()),
|
||||
};
|
||||
Checkpoint {
|
||||
metadata,
|
||||
@@ -413,7 +405,7 @@ impl Simulation {
|
||||
}
|
||||
}
|
||||
pub fn restore(saved: RoomSave) -> Result<Self, String> {
|
||||
if ![1, 2].contains(&saved.version) || saved.city_version != 1 {
|
||||
if ![1, 2, 3].contains(&saved.version) || saved.city_version != 1 {
|
||||
return Err("Incompatible room snapshot".into());
|
||||
}
|
||||
if !saved.game_seconds.is_finite()
|
||||
@@ -438,14 +430,6 @@ impl Simulation {
|
||||
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 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(
|
||||
saved
|
||||
.routes
|
||||
@@ -565,29 +549,25 @@ mod tests {
|
||||
assert_eq!(a.clock().seconds, seconds);
|
||||
}
|
||||
#[test]
|
||||
fn legacy_snapshot_migrates_and_pending_commands_survive_restart() {
|
||||
fn legacy_snapshots_restore_without_retired_economy() {
|
||||
use prost::Message;
|
||||
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();
|
||||
for version in [1, 2] {
|
||||
let mut legacy = sim.save();
|
||||
legacy.version = version;
|
||||
let mut bytes = legacy.encode_to_vec();
|
||||
// Former field 12 with treasury=100; unknown fields are discarded.
|
||||
bytes.extend_from_slice(&[0x62, 0x02, 0x08, 0x64]);
|
||||
let mut restored = Simulation::restore(RoomSave::decode(&bytes[..]).unwrap()).unwrap();
|
||||
let migrated = restored.save();
|
||||
assert_eq!(migrated.version, 3);
|
||||
assert_eq!(migrated.agents, legacy.agents);
|
||||
assert_eq!(migrated.game_seconds, legacy.game_seconds);
|
||||
assert_eq!(migrated.routes, legacy.routes);
|
||||
assert_eq!(migrated.seed, legacy.seed);
|
||||
}
|
||||
assert_eq!(migrated.save(), restored.save());
|
||||
assert!(restored.save().gameplay.unwrap().pending.is_empty());
|
||||
let mut invalid = restored.save();
|
||||
let mut invalid = sim.save();
|
||||
invalid.version = 99;
|
||||
assert!(Simulation::restore(invalid).is_err());
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ pub fn load(root: &Path, id: &str) -> Result<RoomSave, String> {
|
||||
if snapshot.id != id {
|
||||
continue;
|
||||
}
|
||||
if ![1, 2].contains(&snapshot.version) || snapshot.city_version != 1 {
|
||||
if ![1, 2, 3].contains(&snapshot.version) || snapshot.city_version != 1 {
|
||||
return Err("Incompatible room snapshot".into());
|
||||
}
|
||||
return Ok(snapshot);
|
||||
|
||||
@@ -102,14 +102,26 @@ async fn isolated_typescript_worker_exports_city_and_opens_room_without_network(
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(duplicate.id, job.id);
|
||||
let (status, _) = request(
|
||||
let (status, body) = request(
|
||||
address,
|
||||
"POST",
|
||||
"/api/cities/jobs",
|
||||
&message.encode_to_vec(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 200, "repeated POST resumes the accepted job");
|
||||
assert_eq!(CityJob::decode(&body[..]).unwrap().id, job.id);
|
||||
let different = CreateCity { seed: 2, ..message };
|
||||
let (status, body) = request(
|
||||
address,
|
||||
"POST",
|
||||
"/api/cities/jobs",
|
||||
&different.encode_to_vec(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, 429);
|
||||
let error: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(error["retryAfterSeconds"].as_u64().unwrap() <= 60);
|
||||
app.stop();
|
||||
server.abort();
|
||||
assert_eq!(
|
||||
|
||||
@@ -151,37 +151,6 @@ async fn thirty_observers_snapshot_delta_vote_and_restart() {
|
||||
}
|
||||
}
|
||||
// 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, _) =
|
||||
tokio_tungstenite::connect_async(format!("ws://{address}/api/rooms/{id}/ws"))
|
||||
.await
|
||||
|
||||
Reference in new issue
Block a user