From 00ea5899c15e2e369da6429b95357c9534419bd4 Mon Sep 17 00:00:00 2001 From: Steve Biedermann Date: Mon, 12 Oct 2026 20:42:11 +0200 Subject: [PATCH] feat(ui): show markets, workplaces, carts, crops and shard borders The map draws markets, workplaces coloured by trade, carts, merchants, crop fields that turn gold when ripe, every home and dashed shard borders. The side panel lists each person's job and coins and shows economy and employment statistics. The server includes `crop_ripe` in the state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + src/world.rs | 5 ++- ui/src/canvas.rs | 86 +++++++++++++++++++++++++++++++++++++++- ui/src/lib.rs | 18 ++++++++- ui/src/model.rs | 101 ++++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 200 insertions(+), 11 deletions(-) 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() + }) +}