diff --git a/CHANGELOG.md b/CHANGELOG.md index 62deb9a..9189eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Notable changes to this project are documented here. - The server runs as a gateway that starts `SIM_SHARDS` (default 3) shard processes, steps them in lockstep and shows them to the UI as one world. Shards exchange entities and messages directly over WebSockets. - Every shard keeps its own database, configured with `SHARD_DATABASE_URL` (default `sqlite://sim-shard-{shard}.db?mode=rwc`). - The Picard console reaches the actors of the other shards as `@shard-N` (`peers`, `@shard-1 actors`, `send @shard-1 PATH JSON`); `/api/actors/ws?shard=N` connects to shard `N` directly. +- The UI draws markets, workplaces, carts, merchants, crop fields (gold when ripe), all homes and the shard borders, and shows each person's job and coins plus economy and employment statistics. ### Changed diff --git a/src/world.rs b/src/world.rs index 221f336..41ca76d 100644 --- a/src/world.rs +++ b/src/world.rs @@ -3455,7 +3455,7 @@ impl World { let actor_stats = self.actor_system.stats(); let _ = write!( s, - "{{\"paused\":{},\"speed\":{},\"seed\":{},\"rev\":{},\"tick\":{},\"day\":{},\"year\":{},\"month\":{},\"week\":{},\"date_day\":{},\"hour\":{:.2},\"daylight\":{:.3},\"limits\":{{\"speed\":{},\"stock\":{},\"person_inventory\":{},\"hunger\":{},\"animal_energy\":{},\"resource\":{},\"spawn\":{}}},\"stock\":{{\"food\":{},\"wood\":{},\"stone\":{},\"tools\":{}}},\"home\":[{},{}],", + "{{\"paused\":{},\"speed\":{},\"seed\":{},\"rev\":{},\"tick\":{},\"day\":{},\"year\":{},\"month\":{},\"week\":{},\"date_day\":{},\"hour\":{:.2},\"daylight\":{:.3},\"limits\":{{\"speed\":{},\"stock\":{},\"person_inventory\":{},\"hunger\":{},\"animal_energy\":{},\"resource\":{},\"spawn\":{}}},\"stock\":{{\"food\":{},\"wood\":{},\"stone\":{},\"tools\":{}}},\"home\":[{},{}],\"crop_ripe\":{},", self.paused, self.speed, self.seed, @@ -3480,7 +3480,8 @@ impl World { self.stone, self.tools, self.home.0, - self.home.1 + self.home.1, + self.config.crop_ripe ); self.write_calendar_json(&mut s); self.write_entities_json(&mut s); diff --git a/ui/src/canvas.rs b/ui/src/canvas.rs index 8686af0..84aa4d2 100644 --- a/ui/src/canvas.rs +++ b/ui/src/canvas.rs @@ -6,6 +6,18 @@ const COLORS: [&str; 7] = [ "#10305e", "#2a62a8", "#e0d08a", "#6aaa4a", "#3f7d3a", "#8a8a6a", "#aaaaaa", ]; +fn trade_color(trade: &str) -> &'static str { + match trade { + "farm" => "#9acd32", + "woodcutter" => "#2e5e2e", + "quarry" => "#8c8c8c", + "hunter" => "#8b4513", + "toolmaker" => "#4682b4", + "cartwright" => "#a0522d", + _ => "#cccccc", + } +} + pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option) { canvas.set_width(canvas_len(t.w)); canvas.set_height(canvas_len(t.h)); @@ -42,6 +54,20 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option match r.2 { 1 => dot(r.0, r.1, 3.5, "#1d4a1d"), 2 => dot(r.0, r.1, 2.0, "#e85a8a"), + 4 => { + // Fields turn from green to gold as they ripen. + ctx.set_fill_style_str(if r.3 >= s.crop_ripe { + "#d9b23a" + } else { + "#8fbf4a" + }); + ctx.fill_rect( + tile_offset(r.0) + 1.0, + tile_offset(r.1) + 1.0, + T - 2.0, + T - 2.0, + ); + } _ => { ctx.set_fill_style_str("#666"); ctx.fill_rect(tile_offset(r.0) + 2.0, tile_offset(r.1) + 2.0, 6.0, 6.0); @@ -58,7 +84,47 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option ); } ctx.set_fill_style_str("#b05a2a"); - ctx.fill_rect(tile_offset(s.home.0), tile_offset(s.home.1), T, T); + for home in s.homes.iter().chain(std::iter::once(&s.home)) { + ctx.fill_rect(tile_offset(home.0), tile_offset(home.1), T, T); + } + for w in &s.workplaces { + ctx.set_fill_style_str(trade_color(&w.3)); + ctx.fill_rect(tile_offset(w.1), tile_offset(w.2), T, T); + ctx.set_stroke_style_str("#222"); + ctx.set_line_width(1.0); + ctx.stroke_rect( + tile_offset(w.1) + 0.5, + tile_offset(w.2) + 0.5, + T - 1.0, + T - 1.0, + ); + } + for m in &s.markets { + ctx.set_fill_style_str("#e8c547"); + ctx.fill_rect( + tile_offset(m.1) - 2.0, + tile_offset(m.2) - 2.0, + T + 4.0, + T + 4.0, + ); + ctx.set_stroke_style_str("#6b4a00"); + ctx.set_line_width(2.0); + ctx.stroke_rect( + tile_offset(m.1) - 2.0, + tile_offset(m.2) - 2.0, + T + 4.0, + T + 4.0, + ); + } + ctx.set_fill_style_str("#c58a4a"); + for c in &s.carts { + ctx.fill_rect( + tile_offset(c.1) + 1.0, + tile_offset(c.2) + 3.0, + T - 2.0, + T - 6.0, + ); + } for a in &s.rabbits { dot(a.0, a.1, 2.5, "#fff"); } @@ -66,12 +132,28 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option dot(a.0, a.1, 4.0, "#d22"); } for p in &s.people { - dot(p.1, p.2, 4.0, "#ff0"); + let color = if p.12.as_deref() == Some("merchant") { + "#ffa600" + } else { + "#ff0" + }; + dot(p.1, p.2, 4.0, color); } ctx.set_fill_style_str(&format!("rgba(5,10,50,{})", (1.0 - s.daylight) * 0.65)); ctx.fill_rect(0.0, 0.0, tile_offset(t.w), tile_offset(t.h)); + ctx.set_stroke_style_str("rgba(255,255,255,0.35)"); + ctx.set_line_width(1.0); + let _ = ctx.set_line_dash(&js_sys::Array::of2(&6.0.into(), &4.0.into())); + for shard in s.shards.iter().skip(1) { + ctx.begin_path(); + ctx.move_to(tile_offset(shard.0), 0.0); + ctx.line_to(tile_offset(shard.0), tile_offset(t.h)); + ctx.stroke(); + } + let _ = ctx.set_line_dash(&js_sys::Array::new()); + if let Some(sel) = sel { let (x0, y0, x1, y1) = sel.rect(); ctx.set_stroke_style_str("#fff"); diff --git a/ui/src/lib.rs b/ui/src/lib.rs index e2fb9aa..60be41d 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -157,6 +157,19 @@ fn App() -> impl IntoView {
{format!("Rabbits: {}", s.rabbits.len())}
{format!("Wolves: {}", s.wolves.len())}
{format!("Houses: {}", s.houses.len())}
+
{format!( + "Economy: {} markets, {} workplaces, {} carts, {} coins in markets", + s.markets.len(), + s.workplaces.len(), + s.carts.len(), + s.markets.iter().map(|m| u64::from(m.3)).sum::() + )}
+
{format!( + "Employed: {} of {}, shards: {}", + s.people.iter().filter(|p| p.12.is_some()).count(), + s.people.len(), + s.shards.len().max(1) + )}
{format!( "Stockpile: food {}, wood {}, stone {}, tools {}", s.stock.food, s.stock.wood, s.stock.stone, s.stock.tools @@ -319,7 +332,10 @@ fn App() -> impl IntoView {
"yellow: person, white: rabbit, red: wolf"
"green dot: tree, pink: berries, grey: stone"
- "brown square: house" + "brown square: house, orange: merchant"
+ "field: green, gold when ripe"
+ "gold square: market, coloured square: workplace"
+ "flat bar: cart, dashed line: shard border"
diff --git a/ui/src/model.rs b/ui/src/model.rs index 9b70b10..5e3c4fe 100644 --- a/ui/src/model.rs +++ b/ui/src/model.rs @@ -29,7 +29,7 @@ pub const TERRAIN: [&str; 7] = [ "Hills", "Mountain", ]; -pub const RES_NAMES: [&str; 4] = ["", "Tree", "Berry bush", "Stone deposit"]; +pub const RES_NAMES: [&str; 5] = ["", "Tree", "Berry bush", "Stone deposit", "Crop field"]; #[derive(Deserialize, Clone, Default)] pub struct Stock { @@ -141,7 +141,7 @@ impl Calendar { } } -/// id, x, y, action, hunger, food, wood, stone, life stage, age in days, partner id +/// id, x, y, action, hunger, food, wood, stone, life stage, age in days, partner id, coins, job #[derive(Deserialize, Clone)] pub struct Person( pub u32, @@ -155,8 +155,40 @@ pub struct Person( pub String, pub u64, pub Option, + #[serde(default)] pub u32, + #[serde(default)] pub Option, ); +/// id, x, y, coins, food, wood, stone, tools, carts +#[derive(Deserialize, Clone)] +pub struct Market( + pub u64, + pub usize, + pub usize, + pub u32, + pub u32, + pub u32, + pub u32, + pub u32, + pub usize, +); + +/// id, x, y, trade, coins, employees, positions +#[derive(Deserialize, Clone)] +pub struct Workplace( + pub u64, + pub usize, + pub usize, + pub String, + pub u32, + pub usize, + pub usize, +); + +/// id, x, y, owner +#[derive(Deserialize, Clone)] +pub struct Cart(pub u64, pub usize, pub usize, pub Option); + /// x, y, energy #[derive(Deserialize, Clone)] pub struct Animal(pub usize, pub usize, pub f32); @@ -187,6 +219,21 @@ pub struct State { pub rabbits: Vec, pub wolves: Vec, pub res: Vec, + #[serde(default)] + pub markets: Vec, + #[serde(default)] + pub workplaces: Vec, + #[serde(default)] + pub carts: Vec, + /// Growth at which crop fields can be harvested. + #[serde(default)] + pub crop_ripe: u16, + /// Village center of every shard. + #[serde(default)] + pub homes: Vec<(usize, usize)>, + /// Column range `[start, end)` of every shard. + #[serde(default)] + pub shards: Vec<(usize, usize)>, pub log: Vec, pub calendar: Calendar, } @@ -303,9 +350,12 @@ pub fn describe(sel: Sel, s: &State, t: &Terrain) -> Vec { if (x0, y0) == (x1, y1) { out.push(format!("Tile ({x0}, {y0})")); out.push(format!("Terrain: {}", TERRAIN[t.kind(x0, y0)])); - if s.home == (x0, y0) { + if s.home == (x0, y0) || s.homes.contains(&(x0, y0)) { out.push("Village center (stockpile)".into()); } + if let Some(shard) = s.shards.iter().position(|r| (r.0..r.1).contains(&x0)) { + out.push(format!("Shard {shard}")); + } if houses > 0 { out.push("House".into()); } @@ -321,7 +371,7 @@ pub fn describe(sel: Sel, s: &State, t: &Terrain) -> Vec { .map(|(i, n)| format!("{} {n}", TERRAIN[i])) .collect(); out.push(format!("Terrain: {}", terrain.join(", "))); - let kinds: Vec = (1..=3u8) + let kinds: Vec = (1..=4u8) .map(|k| { format!( "{} {}", @@ -339,10 +389,42 @@ pub fn describe(sel: Sel, s: &State, t: &Terrain) -> Vec { rabbits.len(), wolves.len() )); + for m in s.markets.iter().filter(|m| inside(m.1, m.2)) { + out.push(format!( + "- Market #{}: {} coins, food {} wood {} stone {} tools {}, carts {}", + m.0, m.3, m.4, m.5, m.6, m.7, m.8 + )); + } + for w in s.workplaces.iter().filter(|w| inside(w.1, w.2)) { + out.push(format!( + "- {} #{}: {} coins, {}/{} workers", + capitalized(&w.3), + w.0, + w.4, + w.5, + w.6 + )); + } + for c in s.carts.iter().filter(|c| inside(c.1, c.2)) { + out.push(match c.3 { + Some(owner) => format!("- Cart #{} of person #{owner}", c.0), + None => format!("- Cart #{} for sale", c.0), + }); + } for p in &people { out.push(format!( - "- Person #{}: {}, stage {}, age {} days, hunger {}, partner {:?}, carrying food {} wood {} stone {}", - p.0, p.3, p.8, p.9, p.4, p.10, p.5, p.6, p.7 + "- Person #{}: {}, stage {}, age {} days, hunger {}, partner {:?}, job {}, {} coins, carrying food {} wood {} stone {}", + p.0, + p.3, + p.8, + p.9, + p.4, + p.10, + p.12.as_deref().unwrap_or("none"), + p.11, + p.5, + p.6, + p.7 )); } if rabbits.len() + wolves.len() <= 6 { @@ -351,3 +433,10 @@ pub fn describe(sel: Sel, s: &State, t: &Terrain) -> Vec { } out } + +fn capitalized(word: &str) -> String { + let mut chars = word.chars(); + chars.next().map_or_else(String::new, |first| { + first.to_uppercase().chain(chars).collect() + }) +}