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>
This commit is contained in:
parent
eac68db433
commit
00ea5899c1
|
|
@ -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.
|
- 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`).
|
- 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 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
|
### Changed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3455,7 +3455,7 @@ impl World {
|
||||||
let actor_stats = self.actor_system.stats();
|
let actor_stats = self.actor_system.stats();
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
s,
|
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.paused,
|
||||||
self.speed,
|
self.speed,
|
||||||
self.seed,
|
self.seed,
|
||||||
|
|
@ -3480,7 +3480,8 @@ impl World {
|
||||||
self.stone,
|
self.stone,
|
||||||
self.tools,
|
self.tools,
|
||||||
self.home.0,
|
self.home.0,
|
||||||
self.home.1
|
self.home.1,
|
||||||
|
self.config.crop_ripe
|
||||||
);
|
);
|
||||||
self.write_calendar_json(&mut s);
|
self.write_calendar_json(&mut s);
|
||||||
self.write_entities_json(&mut s);
|
self.write_entities_json(&mut s);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,18 @@ const COLORS: [&str; 7] = [
|
||||||
"#10305e", "#2a62a8", "#e0d08a", "#6aaa4a", "#3f7d3a", "#8a8a6a", "#aaaaaa",
|
"#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<Sel>) {
|
pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>) {
|
||||||
canvas.set_width(canvas_len(t.w));
|
canvas.set_width(canvas_len(t.w));
|
||||||
canvas.set_height(canvas_len(t.h));
|
canvas.set_height(canvas_len(t.h));
|
||||||
|
|
@ -42,6 +54,20 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>
|
||||||
match r.2 {
|
match r.2 {
|
||||||
1 => dot(r.0, r.1, 3.5, "#1d4a1d"),
|
1 => dot(r.0, r.1, 3.5, "#1d4a1d"),
|
||||||
2 => dot(r.0, r.1, 2.0, "#e85a8a"),
|
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.set_fill_style_str("#666");
|
||||||
ctx.fill_rect(tile_offset(r.0) + 2.0, tile_offset(r.1) + 2.0, 6.0, 6.0);
|
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<Sel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
ctx.set_fill_style_str("#b05a2a");
|
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 {
|
for a in &s.rabbits {
|
||||||
dot(a.0, a.1, 2.5, "#fff");
|
dot(a.0, a.1, 2.5, "#fff");
|
||||||
}
|
}
|
||||||
|
|
@ -66,12 +132,28 @@ pub fn draw(canvas: &HtmlCanvasElement, t: &Terrain, s: &State, sel: Option<Sel>
|
||||||
dot(a.0, a.1, 4.0, "#d22");
|
dot(a.0, a.1, 4.0, "#d22");
|
||||||
}
|
}
|
||||||
for p in &s.people {
|
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.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.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 {
|
if let Some(sel) = sel {
|
||||||
let (x0, y0, x1, y1) = sel.rect();
|
let (x0, y0, x1, y1) = sel.rect();
|
||||||
ctx.set_stroke_style_str("#fff");
|
ctx.set_stroke_style_str("#fff");
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,19 @@ fn App() -> impl IntoView {
|
||||||
<div>{format!("Rabbits: {}", s.rabbits.len())}</div>
|
<div>{format!("Rabbits: {}", s.rabbits.len())}</div>
|
||||||
<div>{format!("Wolves: {}", s.wolves.len())}</div>
|
<div>{format!("Wolves: {}", s.wolves.len())}</div>
|
||||||
<div>{format!("Houses: {}", s.houses.len())}</div>
|
<div>{format!("Houses: {}", s.houses.len())}</div>
|
||||||
|
<div>{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::<u64>()
|
||||||
|
)}</div>
|
||||||
|
<div>{format!(
|
||||||
|
"Employed: {} of {}, shards: {}",
|
||||||
|
s.people.iter().filter(|p| p.12.is_some()).count(),
|
||||||
|
s.people.len(),
|
||||||
|
s.shards.len().max(1)
|
||||||
|
)}</div>
|
||||||
<div>{format!(
|
<div>{format!(
|
||||||
"Stockpile: food {}, wood {}, stone {}, tools {}",
|
"Stockpile: food {}, wood {}, stone {}, tools {}",
|
||||||
s.stock.food, s.stock.wood, s.stock.stone, s.stock.tools
|
s.stock.food, s.stock.wood, s.stock.stone, s.stock.tools
|
||||||
|
|
@ -319,7 +332,10 @@ fn App() -> impl IntoView {
|
||||||
<div>
|
<div>
|
||||||
"yellow: person, white: rabbit, red: wolf" <br />
|
"yellow: person, white: rabbit, red: wolf" <br />
|
||||||
"green dot: tree, pink: berries, grey: stone" <br />
|
"green dot: tree, pink: berries, grey: stone" <br />
|
||||||
"brown square: house"
|
"brown square: house, orange: merchant" <br />
|
||||||
|
"field: green, gold when ripe" <br />
|
||||||
|
"gold square: market, coloured square: workplace" <br />
|
||||||
|
"flat bar: cart, dashed line: shard border"
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
101
ui/src/model.rs
101
ui/src/model.rs
|
|
@ -29,7 +29,7 @@ pub const TERRAIN: [&str; 7] = [
|
||||||
"Hills",
|
"Hills",
|
||||||
"Mountain",
|
"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)]
|
#[derive(Deserialize, Clone, Default)]
|
||||||
pub struct Stock {
|
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)]
|
#[derive(Deserialize, Clone)]
|
||||||
pub struct Person(
|
pub struct Person(
|
||||||
pub u32,
|
pub u32,
|
||||||
|
|
@ -155,8 +155,40 @@ pub struct Person(
|
||||||
pub String,
|
pub String,
|
||||||
pub u64,
|
pub u64,
|
||||||
pub Option<u32>,
|
pub Option<u32>,
|
||||||
|
#[serde(default)] pub u32,
|
||||||
|
#[serde(default)] pub Option<String>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// 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<u32>);
|
||||||
|
|
||||||
/// x, y, energy
|
/// x, y, energy
|
||||||
#[derive(Deserialize, Clone)]
|
#[derive(Deserialize, Clone)]
|
||||||
pub struct Animal(pub usize, pub usize, pub f32);
|
pub struct Animal(pub usize, pub usize, pub f32);
|
||||||
|
|
@ -187,6 +219,21 @@ pub struct State {
|
||||||
pub rabbits: Vec<Animal>,
|
pub rabbits: Vec<Animal>,
|
||||||
pub wolves: Vec<Animal>,
|
pub wolves: Vec<Animal>,
|
||||||
pub res: Vec<Res>,
|
pub res: Vec<Res>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub markets: Vec<Market>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub workplaces: Vec<Workplace>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub carts: Vec<Cart>,
|
||||||
|
/// 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<String>,
|
pub log: Vec<String>,
|
||||||
pub calendar: Calendar,
|
pub calendar: Calendar,
|
||||||
}
|
}
|
||||||
|
|
@ -303,9 +350,12 @@ pub fn describe(sel: Sel, s: &State, t: &Terrain) -> Vec<String> {
|
||||||
if (x0, y0) == (x1, y1) {
|
if (x0, y0) == (x1, y1) {
|
||||||
out.push(format!("Tile ({x0}, {y0})"));
|
out.push(format!("Tile ({x0}, {y0})"));
|
||||||
out.push(format!("Terrain: {}", TERRAIN[t.kind(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());
|
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 {
|
if houses > 0 {
|
||||||
out.push("House".into());
|
out.push("House".into());
|
||||||
}
|
}
|
||||||
|
|
@ -321,7 +371,7 @@ pub fn describe(sel: Sel, s: &State, t: &Terrain) -> Vec<String> {
|
||||||
.map(|(i, n)| format!("{} {n}", TERRAIN[i]))
|
.map(|(i, n)| format!("{} {n}", TERRAIN[i]))
|
||||||
.collect();
|
.collect();
|
||||||
out.push(format!("Terrain: {}", terrain.join(", ")));
|
out.push(format!("Terrain: {}", terrain.join(", ")));
|
||||||
let kinds: Vec<String> = (1..=3u8)
|
let kinds: Vec<String> = (1..=4u8)
|
||||||
.map(|k| {
|
.map(|k| {
|
||||||
format!(
|
format!(
|
||||||
"{} {}",
|
"{} {}",
|
||||||
|
|
@ -339,10 +389,42 @@ pub fn describe(sel: Sel, s: &State, t: &Terrain) -> Vec<String> {
|
||||||
rabbits.len(),
|
rabbits.len(),
|
||||||
wolves.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 {
|
for p in &people {
|
||||||
out.push(format!(
|
out.push(format!(
|
||||||
"- Person #{}: {}, stage {}, age {} days, hunger {}, partner {:?}, carrying food {} wood {} stone {}",
|
"- 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.5, p.6, p.7
|
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 {
|
if rabbits.len() + wolves.len() <= 6 {
|
||||||
|
|
@ -351,3 +433,10 @@ pub fn describe(sel: Sel, s: &State, t: &Terrain) -> Vec<String> {
|
||||||
}
|
}
|
||||||
out
|
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()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue