1use crate::color::RgbColor;
2use flatland_client_lib::GameState;
3use flatland_protocol::{BuildingView, InteriorMapView, TerrainKindView};
4
5use crate::map_presentation::{self, MapPresentation};
6
7pub struct WorldView {
12 pub width: usize,
13 pub height: usize,
14 pub cells: Vec<String>,
15 pub cell_fg: Vec<Option<RgbColor>>,
17 pub target_t1_cells: Vec<bool>,
19 pub target_t2_cells: Vec<bool>,
21 pub origin_x: f32,
22 pub origin_y: f32,
23 pub inside_building: Option<String>,
24}
25
26#[derive(Debug, Clone, Copy)]
28pub struct WorldViewOptions {
29 pub paint_local_player: bool,
31 pub paint_overlays: bool,
34}
35
36impl Default for WorldViewOptions {
37 fn default() -> Self {
38 Self {
39 paint_local_player: true,
40 paint_overlays: true,
41 }
42 }
43}
44
45impl WorldViewOptions {
46 pub fn terrain_only() -> Self {
48 Self {
49 paint_local_player: false,
50 paint_overlays: false,
51 }
52 }
53}
54
55impl WorldView {
56 pub fn build_with_target(
57 state: &GameState,
58 view_w: usize,
59 view_h: usize,
60 map_target: Option<(f32, f32)>,
61 ) -> Self {
62 Self::build_with_options(
63 state,
64 view_w,
65 view_h,
66 map_target,
67 WorldViewOptions::default(),
68 )
69 }
70
71 pub fn build_with_options(
73 state: &GameState,
74 view_w: usize,
75 view_h: usize,
76 map_target: Option<(f32, f32)>,
77 options: WorldViewOptions,
78 ) -> Self {
79 let (px, py) = state.player_position();
80 Self::build_with_anchor(state, view_w, view_h, px, py, map_target, options)
81 }
82
83 pub fn build_with_anchor(
85 state: &GameState,
86 view_w: usize,
87 view_h: usize,
88 anchor_x: f32,
89 anchor_y: f32,
90 map_target: Option<(f32, f32)>,
91 options: WorldViewOptions,
92 ) -> Self {
93 map_presentation::maybe_reload_for_content_rev(state.content_rev);
94 let px = anchor_x;
95 let py = anchor_y;
96 let inside_building = state.effective_inside_building();
97
98 let width = view_w.max(3);
99 let height = view_h.max(3);
100 let grass = map_presentation::terrain_for(TerrainKindView::Grass);
101 let mut cells = vec![grass.glyph.clone(); width * height];
102 let mut cell_fg = vec![Some(grass.color); width * height];
103
104 let half_w = (width / 2) as i32;
105 let half_h = (height / 2) as i32;
106
107 if let (Some(_bid), Some(interior)) =
109 (inside_building.as_deref(), state.interior_map.as_ref())
110 {
111 paint_interior_background(&mut cells, &mut cell_fg, width, height, interior);
112 paint_interior_rooms(
113 &mut cells,
114 &mut cell_fg,
115 width,
116 height,
117 interior,
118 px,
119 py,
120 half_w,
121 half_h,
122 );
123 let door_gaps = interior_door_gaps(interior, &state.doors);
124 for room in &interior.rooms {
125 paint_building_walls(
126 &mut cells,
127 &mut cell_fg,
128 width,
129 height,
130 room.x0,
131 room.y0,
132 room.x1 - room.x0,
133 room.y1 - room.y0,
134 px,
135 py,
136 half_w,
137 half_h,
138 &door_gaps,
139 );
140 }
141 } else {
142 paint_terrain(
143 &mut cells,
144 &mut cell_fg,
145 width,
146 height,
147 state,
148 px,
149 py,
150 half_w,
151 half_h,
152 );
153
154 for building in &state.buildings {
155 if building.tags.iter().any(|t| t == "well") {
156 paint_well(
157 &mut cells,
158 &mut cell_fg,
159 width,
160 height,
161 building,
162 px,
163 py,
164 half_w,
165 half_h,
166 );
167 } else {
168 paint_building_walls_centered(
169 &mut cells,
170 &mut cell_fg,
171 width,
172 height,
173 building,
174 px,
175 py,
176 half_w,
177 half_h,
178 );
179 }
180 }
181 }
182
183 if options.paint_overlays {
184 for door in &state.doors {
185 if let Some((gx, gy)) =
186 world_to_grid(door.x, door.y, px, py, half_w, half_h, width, height)
187 {
188 let idx = gy * width + gx;
189 paint_presentation(
190 &mut cells,
191 &mut cell_fg,
192 idx,
193 &map_presentation::door_presentation(door.open),
194 );
195 }
196 }
197
198 for npc in &state.npcs {
199 if let Some((gx, gy)) =
200 world_to_grid(npc.x, npc.y, px, py, half_w, half_h, width, height)
201 {
202 let idx = gy * width + gx;
203 paint_presentation(
204 &mut cells,
205 &mut cell_fg,
206 idx,
207 &map_presentation::npc_for(npc),
208 );
209 }
210 }
211
212 let ground = empty_ground_glyph();
213 let player_glyph = map_presentation::player_presentation().glyph;
214 for node in &state.resource_nodes {
215 if let Some((gx, gy)) =
216 world_to_grid(node.x, node.y, px, py, half_w, half_h, width, height)
217 {
218 let idx = gy * width + gx;
219 let pres = map_presentation::resource_for(node);
220 if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
221 paint_presentation(&mut cells, &mut cell_fg, idx, &pres);
222 }
223 }
224 }
225
226 for drop in &state.ground_drops {
227 if let Some((gx, gy)) =
228 world_to_grid(drop.x, drop.y, px, py, half_w, half_h, width, height)
229 {
230 let idx = gy * width + gx;
231 if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
232 paint_presentation(
233 &mut cells,
234 &mut cell_fg,
235 idx,
236 &map_presentation::loot_presentation(),
237 );
238 }
239 }
240 }
241
242 for chest in &state.placed_containers {
243 if let Some((gx, gy)) =
244 world_to_grid(chest.x, chest.y, px, py, half_w, half_h, width, height)
245 {
246 let idx = gy * width + gx;
247 if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
248 paint_presentation(
249 &mut cells,
250 &mut cell_fg,
251 idx,
252 &map_presentation::chest_presentation(chest.locked),
253 );
254 }
255 }
256 }
257
258 if state.effective_inside_building().is_none() {
259 for inter in &state.interactables {
260 if inter.kind != "quest_board" {
261 continue;
262 }
263 if let Some((gx, gy)) =
264 world_to_grid(inter.x, inter.y, px, py, half_w, half_h, width, height)
265 {
266 let idx = gy * width + gx;
267 if can_paint_world_object(&cells[idx], &ground, &player_glyph) {
268 paint_presentation(
269 &mut cells,
270 &mut cell_fg,
271 idx,
272 &map_presentation::quest_board_presentation(),
273 );
274 }
275 }
276 }
277 }
278
279 for entity in &state.entities {
280 if !options.paint_local_player && entity.id == state.entity_id {
282 continue;
283 }
284 if entity.id != state.entity_id
285 && entity.inside_building.as_deref() != inside_building.as_deref()
286 {
287 continue;
288 }
289 if let Some(pres) = entity_presentation(entity, state.entity_id) {
290 if let Some((gx, gy)) = world_to_grid(
291 entity.transform.position.x,
292 entity.transform.position.y,
293 px,
294 py,
295 half_w,
296 half_h,
297 width,
298 height,
299 ) {
300 let idx = gy * width + gx;
301 paint_presentation(&mut cells, &mut cell_fg, idx, &pres);
302 }
303 }
304 }
305 }
306
307 if options.paint_local_player {
308 if state.player_entity().is_some() {
309 let cx = half_w as usize;
310 let cy = half_h as usize;
311 let idx = cy * width + cx;
312 if idx < cells.len() {
313 paint_presentation(
314 &mut cells,
315 &mut cell_fg,
316 idx,
317 &map_presentation::player_presentation(),
318 );
319 }
320 }
321 }
322
323 let mut target_t1_cells = vec![false; cells.len()];
324 let mut target_t2_cells = vec![false; cells.len()];
325 if options.paint_overlays {
326 for (slot, cells_out) in [(1, &mut target_t1_cells), (2, &mut target_t2_cells)] {
327 let target_id = state
328 .combat_slots
329 .iter()
330 .find(|s| s.slot_index == slot)
331 .and_then(|s| s.target_entity_id)
332 .or_else(|| if slot == 1 { state.combat_target } else { None });
333 let Some(target_id) = target_id else {
334 continue;
335 };
336 let (tx, ty) = if let Some(npc) = state
337 .npcs
338 .iter()
339 .find(|n| n.entity_id == Some(target_id))
340 {
341 (npc.x, npc.y)
342 } else if let Some(entity) = state.entities.iter().find(|e| e.id == target_id) {
343 (
344 entity.transform.position.x,
345 entity.transform.position.y,
346 )
347 } else {
348 continue;
349 };
350 if let Some((gx, gy)) =
351 world_to_grid(tx, ty, px, py, half_w, half_h, width, height)
352 {
353 let idx = gy * width + gx;
354 if idx < cells_out.len() {
355 cells_out[idx] = true;
356 }
357 }
358 }
359 }
360
361 if options.paint_overlays {
362 if let Some((tx, ty)) = map_target {
363 if let Some((gx, gy)) = world_to_grid(tx, ty, px, py, half_w, half_h, width, height)
364 {
365 let idx = gy * width + gx;
366 if idx < cells.len() {
367 cells[idx] = "X".into();
368 cell_fg[idx] = Some(RgbColor::YELLOW);
369 }
370 }
371 }
372 }
373
374 Self {
375 width,
376 height,
377 cells,
378 cell_fg,
379 target_t1_cells,
380 target_t2_cells,
381 origin_x: px,
382 origin_y: py,
383 inside_building,
384 }
385 }
386}
387
388fn paint_presentation(
389 cells: &mut [String],
390 cell_fg: &mut [Option<RgbColor>],
391 idx: usize,
392 pres: &MapPresentation,
393) {
394 cells[idx] = pres.glyph.clone();
395 cell_fg[idx] = Some(pres.color);
396}
397
398fn empty_ground_glyph() -> String {
399 map_presentation::terrain_for(TerrainKindView::Grass).glyph
400}
401
402fn entity_presentation(
403 entity: &flatland_protocol::EntityState,
404 player_id: u64,
405) -> Option<MapPresentation> {
406 if entity.id == player_id {
407 return Some(map_presentation::player_presentation());
408 }
409 if entity
410 .vitals
411 .as_ref()
412 .is_some_and(|v| v.life_state == flatland_protocol::LifeState::Dead)
413 {
414 return Some(map_presentation::corpse_presentation());
415 }
416 Some(map_presentation::entity_fallback(&entity.label))
417}
418
419fn paint_interior_background(
420 cells: &mut [String],
421 cell_fg: &mut [Option<RgbColor>],
422 _width: usize,
423 _height: usize,
424 interior: &InteriorMapView,
425) {
426 let bg = crate::color::parse_color(&interior.background_color).unwrap_or(RgbColor::BLACK);
427 for idx in 0..cells.len() {
428 cells[idx] = " ".into();
429 cell_fg[idx] = Some(bg);
430 }
431}
432
433fn paint_interior_rooms(
434 cells: &mut [String],
435 cell_fg: &mut [Option<RgbColor>],
436 width: usize,
437 height: usize,
438 interior: &InteriorMapView,
439 px: f32,
440 py: f32,
441 half_w: i32,
442 half_h: i32,
443) {
444 let default_color = interior
445 .default_floor_color
446 .as_deref()
447 .and_then(crate::color::parse_color)
448 .unwrap_or(RgbColor::rgb(0x2a, 0x2a, 0x2a));
449 for room in &interior.rooms {
450 let floor_color = room
451 .floor_color
452 .as_deref()
453 .and_then(crate::color::parse_color)
454 .unwrap_or(default_color);
455 let glyph = room.floor_glyph.as_deref().unwrap_or(".").to_string();
456 let x0 = room.x0.floor() as i32;
457 let y0 = room.y0.floor() as i32;
458 let x1 = room.x1.ceil() as i32 - 1;
459 let y1 = room.y1.ceil() as i32 - 1;
460 for wy in y0..=y1 {
461 for wx in x0..=x1 {
462 if let Some((gx, gy)) =
463 world_to_grid(wx as f32, wy as f32, px, py, half_w, half_h, width, height)
464 {
465 let idx = gy * width + gx;
466 cells[idx] = glyph.clone();
467 cell_fg[idx] = Some(floor_color);
468 }
469 }
470 }
471 }
472}
473
474fn interior_door_gaps(
475 interior: &InteriorMapView,
476 doors: &[flatland_protocol::DoorView],
477) -> Vec<(f32, f32)> {
478 let mut gaps: Vec<(f32, f32)> = interior
479 .room_doors
480 .iter()
481 .filter_map(|d| {
482 doors
483 .iter()
484 .find(|door| door.id == d.id)
485 .filter(|door| door.open)
486 .map(|door| (door.x, door.y))
487 })
488 .collect();
489 for door in doors {
490 if door.portal.is_some() && door.open {
491 gaps.push((door.x, door.y));
492 }
493 }
494 gaps
495}
496
497fn near_door_gap(wx: i32, wy: i32, door_gaps: &[(f32, f32)]) -> bool {
498 door_gaps
499 .iter()
500 .any(|(dx, dy)| (wx as f32 - dx).abs() < 0.75 && (wy as f32 - dy).abs() < 0.75)
501}
502
503fn paint_terrain(
504 cells: &mut [String],
505 cell_fg: &mut [Option<RgbColor>],
506 width: usize,
507 height: usize,
508 state: &GameState,
509 px: f32,
510 py: f32,
511 _half_w: i32,
512 _half_h: i32,
513) {
514 for gy in 0..height {
515 for gx in 0..width {
516 let Some((wx, wy)) = grid_to_world(gx, gy, px, py, width, height) else {
517 continue;
518 };
519 let zone = state.terrain_zone_at(wx, wy);
520 let kind = zone.map(|z| z.kind).unwrap_or(TerrainKindView::Grass);
521 let elev = zone.map(|z| z.elevation).unwrap_or(0.0);
522 let style = map_presentation::terrain_for_zone(
523 kind,
524 elev,
525 zone.and_then(|z| z.glyph.as_deref()),
526 zone.and_then(|z| z.color.as_deref()),
527 );
528 let idx = gy * width + gx;
529 paint_presentation(cells, cell_fg, idx, &style);
530 }
531 }
532}
533
534fn paint_well(
535 cells: &mut [String],
536 cell_fg: &mut [Option<RgbColor>],
537 width: usize,
538 height: usize,
539 building: &BuildingView,
540 px: f32,
541 py: f32,
542 half_w: i32,
543 half_h: i32,
544) {
545 let hw = building.width_m / 2.0;
546 let hd = building.depth_m / 2.0;
547 let x0 = (building.x - hw).floor() as i32;
548 let y0 = (building.y - hd).floor() as i32;
549 let x1 = (building.x + hw).ceil() as i32 - 1;
550 let y1 = (building.y + hd).ceil() as i32 - 1;
551 let water = map_presentation::shallow_water_presentation();
552 let center = map_presentation::well_center_presentation();
553
554 for wy in y0..=y1 {
555 for wx in x0..=x1 {
556 let pres = if wx == building.x.round() as i32 && wy == building.y.round() as i32 {
557 center.clone()
558 } else {
559 water.clone()
560 };
561 if let Some((gx, gy)) =
562 world_to_grid(wx as f32, wy as f32, px, py, half_w, half_h, width, height)
563 {
564 let idx = gy * width + gx;
565 if !is_wall(&cells[idx]) {
566 paint_presentation(cells, cell_fg, idx, &pres);
567 }
568 }
569 }
570 }
571}
572
573fn paint_building_walls_centered(
574 cells: &mut [String],
575 cell_fg: &mut [Option<RgbColor>],
576 width: usize,
577 height: usize,
578 building: &BuildingView,
579 px: f32,
580 py: f32,
581 half_w: i32,
582 half_h: i32,
583) {
584 let hw = building.width_m / 2.0;
585 let hd = building.depth_m / 2.0;
586 paint_building_walls(
587 cells,
588 cell_fg,
589 width,
590 height,
591 building.x - hw,
592 building.y - hd,
593 building.width_m,
594 building.depth_m,
595 px,
596 py,
597 half_w,
598 half_h,
599 &[],
600 );
601}
602
603fn paint_building_walls(
605 cells: &mut [String],
606 cell_fg: &mut [Option<RgbColor>],
607 width: usize,
608 height: usize,
609 origin_x: f32,
610 origin_y: f32,
611 width_m: f32,
612 depth_m: f32,
613 px: f32,
614 py: f32,
615 half_w: i32,
616 half_h: i32,
617 door_gaps: &[(f32, f32)],
618) {
619 let x0 = origin_x.floor() as i32;
620 let y0 = origin_y.floor() as i32;
621 let x1 = (origin_x + width_m).ceil() as i32 - 1;
622 let y1 = (origin_y + depth_m).ceil() as i32 - 1;
623
624 if x1 < x0 || y1 < y0 {
625 return;
626 }
627
628 for wx in x0..=x1 {
629 if !near_door_gap(wx, y0, door_gaps) {
630 paint_wall_cell(
631 cells, cell_fg, width, height, wx, y0, px, py, half_w, half_h, "-",
632 );
633 }
634 if !near_door_gap(wx, y1, door_gaps) {
635 paint_wall_cell(
636 cells, cell_fg, width, height, wx, y1, px, py, half_w, half_h, "-",
637 );
638 }
639 }
640 for wy in y0 + 1..y1 {
641 if !near_door_gap(x0, wy, door_gaps) {
642 paint_wall_cell(
643 cells, cell_fg, width, height, x0, wy, px, py, half_w, half_h, "|",
644 );
645 }
646 if !near_door_gap(x1, wy, door_gaps) {
647 paint_wall_cell(
648 cells, cell_fg, width, height, x1, wy, px, py, half_w, half_h, "|",
649 );
650 }
651 }
652 if !near_door_gap(x0, y0, door_gaps) {
653 paint_wall_cell(
654 cells, cell_fg, width, height, x0, y0, px, py, half_w, half_h, "+",
655 );
656 }
657 if !near_door_gap(x1, y0, door_gaps) {
658 paint_wall_cell(
659 cells, cell_fg, width, height, x1, y0, px, py, half_w, half_h, "+",
660 );
661 }
662 if !near_door_gap(x0, y1, door_gaps) {
663 paint_wall_cell(
664 cells, cell_fg, width, height, x0, y1, px, py, half_w, half_h, "+",
665 );
666 }
667 if !near_door_gap(x1, y1, door_gaps) {
668 paint_wall_cell(
669 cells, cell_fg, width, height, x1, y1, px, py, half_w, half_h, "+",
670 );
671 }
672}
673
674fn paint_wall_cell(
675 cells: &mut [String],
676 cell_fg: &mut [Option<RgbColor>],
677 width: usize,
678 height: usize,
679 wx: i32,
680 wy: i32,
681 px: f32,
682 py: f32,
683 half_w: i32,
684 half_h: i32,
685 ch: &str,
686) {
687 let Some((gx, gy)) = world_to_grid(wx as f32, wy as f32, px, py, half_w, half_h, width, height)
688 else {
689 return;
690 };
691 let idx = gy * width + gx;
692 let ground = empty_ground_glyph();
693 if cells[idx] == ground || is_wall(&cells[idx]) {
694 cells[idx] = merge_wall_corner(&cells[idx], ch, &ground);
695 cell_fg[idx] = Some(map_presentation::wall_presentation().color);
696 }
697}
698
699fn is_wall(glyph: &str) -> bool {
700 matches!(glyph.chars().next(), Some('+' | '-' | '|'))
701}
702
703fn can_paint_world_object(glyph: &str, _ground: &str, _player_glyph: &str) -> bool {
705 !is_wall(glyph)
706}
707
708fn merge_wall_corner(existing: &str, incoming: &str, ground: &str) -> String {
709 if existing == ground {
710 return incoming.to_string();
711 }
712 if existing == incoming {
713 return existing.to_string();
714 }
715 "+".to_string()
716}
717
718fn world_to_grid(
719 x: f32,
720 y: f32,
721 px: f32,
722 py: f32,
723 half_w: i32,
724 half_h: i32,
725 width: usize,
726 height: usize,
727) -> Option<(usize, usize)> {
728 let dx = (x - px).round() as i32;
729 let dy = (y - py).round() as i32;
730
731 if dx.abs() > half_w || dy.abs() > half_h {
732 return None;
733 }
734
735 let gx = half_w + dx;
736 let gy = half_h - dy;
737
738 if gx < 0 || gy < 0 {
739 return None;
740 }
741 let gx = gx as usize;
742 let gy = gy as usize;
743 if gx >= width || gy >= height {
744 return None;
745 }
746 Some((gx, gy))
747}
748
749pub fn grid_to_world(
751 gx: usize,
752 gy: usize,
753 px: f32,
754 py: f32,
755 view_w: usize,
756 view_h: usize,
757) -> Option<(f32, f32)> {
758 if gx >= view_w || gy >= view_h {
759 return None;
760 }
761 let half_w = (view_w / 2) as i32;
762 let half_h = (view_h / 2) as i32;
763 let dx = gx as i32 - half_w;
764 let dy = half_h - gy as i32;
765 Some((px + dx as f32, py + dy as f32))
766}
767
768#[cfg(test)]
769mod tests {
770 use super::*;
771 use flatland_protocol::{
772 BuildingView, EntityState, PlayerVitals, PrimaryAttributes, Transform, WorldCoord,
773 };
774
775 fn state_with_building(building: BuildingView) -> GameState {
776 GameState {
777 session_id: 1,
778 entity_id: 1,
779 character_id: None,
780 tick: 0,
781 chunk_rev: 0,
782 content_rev: 0,
783 publish_rev: 0,
784 entities: vec![EntityState {
785 id: 1,
786 label: "You".into(),
787 transform: Transform {
788 position: WorldCoord::surface(148.0, 118.0),
789 yaw: 0.0,
790 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
791 },
792 vitals: Some(PlayerVitals::default()),
793 attributes: Some(PrimaryAttributes::default()),
794 skills: Some(flatland_protocol::PlayerSkills::default()),
795 inside_building: None,
796 tile_id: None,
797 paperdoll_ref: None,
798 presentation_state: None,
799 sprite_mode: None,
800 progression_xp: None,
801 }],
802 player: None,
803 resource_nodes: vec![],
804 ground_drops: vec![],
805 placed_containers: vec![],
806 buildings: vec![building],
807 doors: vec![],
808 interior_map: None,
809 npcs: vec![],
810 blueprints: vec![],
811 world_width_m: 256.0,
812 world_height_m: 256.0,
813 terrain_zones: vec![],
814 z_platforms: vec![],
815 z_transitions: vec![],
816 world_clock: flatland_protocol::WorldClock::default(),
817 inventory: Default::default(),
818 inventory_hints: Default::default(),
819 logs: Default::default(),
820 intents_sent: 0,
821 ticks_received: 0,
822 connected: true,
823 disconnect_reason: None,
824 show_stats: false,
825 hud_log_hidden: false,
826 show_equip_menu: false,
827 equip_menu_index: 0,
828 ledger: None,
829 career: None,
830 character_sheet_tab: flatland_client_lib::CharacterSheetTab::Character,
831 ledger_period: flatland_client_lib::LedgerPeriod::Day,
832 show_craft_menu: false,
833 craft_menu_index: 0,
834 craft_batch_quantity: 1,
835 show_shop_menu: false,
836 shop_catalog: None,
837 bank_panel: None,
838 bank_menu_index: 0,
839 bank_ui_mode: flatland_client_lib::BankUiMode::Menu,
840 storage_panel: None,
841 storage_menu_index: 0,
842 storage_ui_mode: flatland_client_lib::StorageUiMode::Menu,
843 shop_tab: flatland_client_lib::ShopTab::default(),
844 shop_menu_index: 0,
845 shop_quantity: 1,
846 shop_trade_log: std::collections::VecDeque::new(),
847 show_npc_verb_menu: false,
848 npc_verb_target: None,
849 npc_verb_index: 0,
850 player_verbs: Default::default(),
851 social_chat: Default::default(),
852 trade_ui: Default::default(),
853 whisper_pouch_ui: Default::default(),
854 show_npc_chat: false,
855 npc_chat: None,
856 show_inventory_menu: false,
857 inventory_menu_index: 0,
858 inventory_tab: flatland_client_lib::InventoryTab::OnPerson,
859 inventory_filter: String::new(),
860 inventory_filter_focused: false,
861 show_move_picker: false,
862 show_rename_prompt: false,
863 show_worker_rename: false,
864 rename_buffer: String::new(),
865 move_picker_index: 0,
866 move_picker: None,
867 show_grant_picker: false,
868 grant_picker_index: 0,
869 grant_picker: None,
870 show_destroy_picker: false,
871 destroy_confirm_pending: false,
872 destroy_picker: None,
873 combat_target: None,
874 combat_target_label: None,
875 combat_fx: Vec::new(),
876 in_combat: false,
877 auto_attack: true,
878 combat_has_los: false,
879 attack_cd_ticks: 0,
880 gcd_ticks: 0,
881 weapon_ability_id: "unarmed".into(),
882 mainhand_template_id: None,
883 mainhand_label: None,
884 offhand_template_id: None,
885 offhand_label: None,
886 mainhand_hand_slots: 1,
887 defense: None,
888 worn: std::collections::BTreeMap::new(),
889 carry_mass: 0.0,
890 carry_mass_max: 0.0,
891 encumbrance: flatland_protocol::EncumbranceState::Light,
892 inventory_stacks: Vec::new(),
893 keychain_stacks: Vec::new(),
894 whisper_pouch_stacks: Vec::new(),
895 combat_target_detail: None,
896 statuses: Vec::new(),
897 cast_progress: None,
898 ability_cooldowns: Vec::new(),
899 blocking_active: false,
900 max_target_slots: 1,
901 combat_slots: Vec::new(),
902 rotation_presets: Vec::new(),
903 known_abilities: Vec::new(),
904 hotbar: vec![None; 9],
905 max_abilities_per_rotation: 0,
906 show_loadout_menu: false,
907 show_keychain_menu: false,
908 keychain_menu_index: 0,
909 show_rotation_editor: false,
910 loadout_menu_index: 0,
911 loadout_hotbar_slot: 1,
912 loadout_ability_index: 0,
913 loadout_focus_presets: false,
914 rotation_editor: Default::default(),
915 harvest_in_progress: false,
916 harvest_started_at: None,
917 pending_craft_ack: None,
918 pending_worker_job_ack: None,
919 quest_log: Vec::new(),
920 interactables: Vec::new(),
921 show_quest_offer: false,
922 pending_quest_offer: None,
923 show_quest_menu: false,
924 quest_menu_index: 0,
925 quest_withdraw_confirm: false,
926 hired_workers: Vec::new(),
927 show_workers_menu: false,
928 workers_menu_index: 0,
929 workers_menu_compact: false,
930 worker_step_display: std::collections::BTreeMap::new(),
931 worker_error_display: std::collections::BTreeMap::new(),
932 show_worker_give_picker: false,
933 worker_give_picker_index: 0,
934 worker_give_picker: None,
935 show_worker_give_target_picker: false,
936 worker_give_target_picker_index: 0,
937 worker_give_target_picker: None,
938 show_worker_take_picker: false,
939 worker_take_picker_index: 0,
940 worker_take_picker: None,
941 show_worker_teach_picker: false,
942 worker_teach_picker_index: 0,
943 worker_teach_picker: None,
944 worker_route_editor: None,
945 progression_curve: None,
946 }
947 }
948
949 #[test]
950 fn shallow_water_terrain_paints_tilde() {
951 use flatland_protocol::TerrainZoneView;
952 let mut state = state_with_building(BuildingView {
953 id: "x".into(),
954 label: "X".into(),
955 x: 128.0,
956 y: 128.0,
957 width_m: 1.0,
958 depth_m: 1.0,
959 interior_blueprint: None,
960 tags: vec![],
961 });
962 state.terrain_zones.push(TerrainZoneView {
963 id: "pond".into(),
964 x0: 126.0,
965 y0: 126.0,
966 x1: 130.0,
967 y1: 130.0,
968 kind: TerrainKindView::ShallowWater,
969 elevation: -0.5,
970 glyph: None,
971 color: None,
972 tile_id: None,
973 z_order: 0,
974 });
975 state.entities = vec![EntityState {
976 id: 1,
977 label: "You".into(),
978 transform: Transform {
979 position: WorldCoord::surface(128.0, 128.0),
980 yaw: 0.0,
981 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
982 },
983 vitals: Some(PlayerVitals::default()),
984 attributes: Some(PrimaryAttributes::default()),
985 skills: Some(flatland_protocol::PlayerSkills::default()),
986 inside_building: None,
987 tile_id: None,
988 paperdoll_ref: None,
989 presentation_state: None,
990 sprite_mode: None,
991 progression_xp: None,
992 }];
993 state.player = state.entities.first().cloned();
994 let view = WorldView::build_with_target(&state, 9, 9, None);
995 let flat = view.cells.join("");
996 let water = map_presentation::shallow_water_presentation();
997 assert!(
998 flat.contains(&water.glyph),
999 "expected water tiles ({:?}): {flat}",
1000 water.glyph
1001 );
1002 assert!(
1003 view.cell_fg.iter().any(|c| *c == Some(water.color)),
1004 "expected water color on terrain cells"
1005 );
1006 }
1007
1008 #[test]
1009 fn zone_glyph_and_color_overrides_paint_on_map() {
1010 use flatland_protocol::TerrainZoneView;
1011 let mut state = state_with_building(BuildingView {
1012 id: "x".into(),
1013 label: "X".into(),
1014 x: 128.0,
1015 y: 128.0,
1016 width_m: 1.0,
1017 depth_m: 1.0,
1018 interior_blueprint: None,
1019 tags: vec![],
1020 });
1021 state.terrain_zones.push(TerrainZoneView {
1022 id: "marked".into(),
1023 x0: 126.0,
1024 y0: 126.0,
1025 x1: 130.0,
1026 y1: 130.0,
1027 kind: TerrainKindView::Grass,
1028 elevation: 0.0,
1029 glyph: Some("%".into()),
1030 color: Some("magenta".into()),
1031 tile_id: None,
1032 z_order: 0,
1033 });
1034 state.entities = vec![EntityState {
1035 id: 1,
1036 label: "You".into(),
1037 transform: Transform {
1038 position: WorldCoord::surface(128.0, 128.0),
1039 yaw: 0.0,
1040 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1041 },
1042 vitals: Some(PlayerVitals::default()),
1043 attributes: Some(PrimaryAttributes::default()),
1044 skills: Some(flatland_protocol::PlayerSkills::default()),
1045 inside_building: None,
1046 tile_id: None,
1047 paperdoll_ref: None,
1048 presentation_state: None,
1049 sprite_mode: None,
1050 progression_xp: None,
1051 }];
1052 state.player = state.entities.first().cloned();
1053 let view = WorldView::build_with_target(&state, 9, 9, None);
1054 let flat = view.cells.join("");
1055 assert!(
1056 flat.contains('%'),
1057 "expected custom zone glyph on map: {flat}"
1058 );
1059 assert!(
1060 view.cell_fg.iter().any(|c| *c == Some(RgbColor::MAGENTA)),
1061 "expected custom zone color on terrain cells"
1062 );
1063 }
1064
1065 #[test]
1066 fn well_paints_water_ring_and_center() {
1067 let building = BuildingView {
1068 id: "town_well".into(),
1069 label: "Well".into(),
1070 x: 122.0,
1071 y: 106.0,
1072 width_m: 3.0,
1073 depth_m: 3.0,
1074 interior_blueprint: None,
1075 tags: vec!["well".into()],
1076 };
1077 let mut state = state_with_building(building);
1078 state.entities = vec![EntityState {
1079 id: 1,
1080 label: "You".into(),
1081 transform: Transform {
1082 position: WorldCoord::surface(120.0, 106.0),
1083 yaw: 0.0,
1084 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1085 },
1086 vitals: Some(PlayerVitals::default()),
1087 attributes: Some(PrimaryAttributes::default()),
1088 skills: Some(flatland_protocol::PlayerSkills::default()),
1089 inside_building: None,
1090 tile_id: None,
1091 paperdoll_ref: None,
1092 presentation_state: None,
1093 sprite_mode: None,
1094 progression_xp: None,
1095 }];
1096 state.player = state.entities.first().cloned();
1097 let view = WorldView::build_with_target(&state, 9, 9, None);
1098 let flat = view.cells.join("");
1099 assert!(flat.contains('~'), "expected well water: {flat}");
1100 assert!(flat.contains('O'), "expected well center: {flat}");
1101 }
1102
1103 #[test]
1104 fn broker_hut_draws_wall_outline() {
1105 let building = BuildingView {
1106 id: "broker_hut".into(),
1107 label: "Broker's Hut".into(),
1108 x: 148.0,
1109 y: 118.0,
1110 width_m: 8.0,
1111 depth_m: 6.0,
1112 interior_blueprint: None,
1113 tags: vec![],
1114 };
1115 let mut state = state_with_building(building);
1116 state.player = state.entities.first().cloned();
1117 let view = WorldView::build_with_target(&state, 25, 15, None);
1118 let flat = view.cells.join("");
1119 assert!(flat.contains('+'), "expected corners: {flat}");
1120 assert!(flat.contains('-'), "expected horiz walls: {flat}");
1121 assert!(flat.contains('|'), "expected vert walls: {flat}");
1122 }
1123
1124 #[test]
1125 fn interior_map_renders_rooms_and_walls() {
1126 use flatland_protocol::{InteriorMapView, InteriorRoomView};
1127 let building = BuildingView {
1128 id: "broker_hut".into(),
1129 label: "Broker's Hut".into(),
1130 x: 148.0,
1131 y: 118.0,
1132 width_m: 8.0,
1133 depth_m: 6.0,
1134 interior_blueprint: Some("broker_hut".into()),
1135 tags: vec![],
1136 };
1137 let mut state = state_with_building(building);
1138 state.interior_map = Some(InteriorMapView {
1139 building_id: "broker_hut".into(),
1140 blueprint_id: "broker_hut".into(),
1141 background_color: "#000000".into(),
1142 default_floor_color: Some("#2a2a2a".into()),
1143 floor_height_m: 3.0,
1144 rooms: vec![InteriorRoomView {
1145 id: "main".into(),
1146 label: "Main".into(),
1147 floor: 0,
1148 x0: 0.0,
1149 y0: 0.0,
1150 x1: 8.5,
1151 y1: 7.0,
1152 floor_color: None,
1153 floor_glyph: None,
1154 }],
1155 room_doors: vec![],
1156 });
1157 state.entities = vec![EntityState {
1158 id: 1,
1159 label: "You".into(),
1160 transform: Transform {
1161 position: WorldCoord::surface(4.0, 3.0),
1162 yaw: 0.0,
1163 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1164 },
1165 vitals: Some(PlayerVitals::default()),
1166 attributes: Some(PrimaryAttributes::default()),
1167 skills: Some(flatland_protocol::PlayerSkills::default()),
1168 inside_building: Some("broker_hut".into()),
1169 tile_id: None,
1170 paperdoll_ref: None,
1171 presentation_state: None,
1172 sprite_mode: None,
1173 progression_xp: None,
1174 }];
1175 state.player = state.entities.first().cloned();
1176 let view = WorldView::build_with_target(&state, 25, 15, None);
1177 assert_eq!(view.inside_building.as_deref(), Some("broker_hut"));
1178 let flat = view.cells.join("");
1179 assert!(flat.contains('+'), "expected interior walls: {flat}");
1180 assert!(flat.contains('@'), "expected player marker: {flat}");
1181 }
1182
1183 #[test]
1184 fn stale_interior_map_renders_outdoor_when_outside() {
1185 use flatland_protocol::{InteriorMapView, InteriorRoomView};
1186 let building = BuildingView {
1187 id: "town_hall".into(),
1188 label: "Town Hall".into(),
1189 x: 163.0,
1190 y: 137.0,
1191 width_m: 20.0,
1192 depth_m: 10.0,
1193 interior_blueprint: Some("town_hall".into()),
1194 tags: vec![],
1195 };
1196 let mut state = state_with_building(building);
1197 state.interior_map = Some(InteriorMapView {
1198 building_id: "town_hall".into(),
1199 blueprint_id: "town_hall".into(),
1200 background_color: "#000000".into(),
1201 default_floor_color: Some("#2a2a2a".into()),
1202 floor_height_m: 3.0,
1203 rooms: vec![InteriorRoomView {
1204 id: "main_hall".into(),
1205 label: "Main".into(),
1206 floor: 0,
1207 x0: -3.5,
1208 y0: -8.0,
1209 x1: 18.5,
1210 y1: 6.0,
1211 floor_color: None,
1212 floor_glyph: None,
1213 }],
1214 room_doors: vec![],
1215 });
1216 state.player = state.entities.first().cloned();
1217 let view = WorldView::build_with_target(&state, 25, 15, None);
1218 assert!(view.inside_building.is_none());
1219 let flat = view.cells.join("");
1220 let grass = map_presentation::terrain_for(TerrainKindView::Grass).glyph;
1221 assert!(
1222 flat.contains(&grass),
1223 "expected outdoor terrain, not stale interior background: {flat}"
1224 );
1225 assert!(
1226 !flat.chars().all(|c| c == ' ' || c == '@'),
1227 "stale interior_map must not paint black interior when outside"
1228 );
1229 }
1230
1231 #[test]
1232 fn stale_inside_flag_still_renders_outdoor_world() {
1233 use flatland_protocol::ResourceNodeState;
1234 let building = BuildingView {
1235 id: "broker_hut".into(),
1236 label: "Broker's Hut".into(),
1237 x: 148.0,
1238 y: 118.0,
1239 width_m: 8.0,
1240 depth_m: 6.0,
1241 interior_blueprint: None,
1242 tags: vec![],
1243 };
1244 let mut state = state_with_building(building);
1245 state.entities = vec![EntityState {
1246 id: 1,
1247 label: "You".into(),
1248 transform: Transform {
1249 position: WorldCoord::surface(128.0, 128.0),
1250 yaw: 0.0,
1251 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1252 },
1253 vitals: Some(PlayerVitals::default()),
1254 attributes: Some(PrimaryAttributes::default()),
1255 skills: Some(flatland_protocol::PlayerSkills::default()),
1256 inside_building: Some("broker_hut".into()),
1257 tile_id: None,
1258 paperdoll_ref: None,
1259 presentation_state: None,
1260 sprite_mode: None,
1261 progression_xp: None,
1262 }];
1263 state.player = state.entities.first().cloned();
1264 state
1265 .resource_nodes
1266 .push(flatland_protocol::ResourceNodeView {
1267 id: "oak".into(),
1268 label: "Oak".into(),
1269 x: 126.0,
1270 y: 134.0,
1271 z: 0.0,
1272 item_template: "oak_log".into(),
1273 state: ResourceNodeState::Available,
1274 blocking: true,
1275 blocking_radius_m: 0.8,
1276 tile_id: None,
1277 paperdoll_ref: None,
1278 yaw: 0.0,
1279 pitch: 0.0,
1280 roll: 0.0,
1281 draw_scale: 1.0,
1282 sprite_mode: None,
1283 presentation_state: None,
1284 });
1285 let view = WorldView::build_with_target(&state, 25, 15, None);
1286 assert_eq!(
1287 view.inside_building.as_deref(),
1288 Some("broker_hut"),
1289 "server inside flag is authoritative"
1290 );
1291 let flat = view.cells.join("");
1292 let oak = map_presentation::resource_for(&flatland_protocol::ResourceNodeView {
1293 id: "oak".into(),
1294 label: "Oak".into(),
1295 x: 0.0,
1296 y: 0.0,
1297 z: 0.0,
1298 item_template: "oak_log".into(),
1299 state: flatland_protocol::ResourceNodeState::Available,
1300 blocking: true,
1301 blocking_radius_m: 0.8,
1302 tile_id: None,
1303 paperdoll_ref: None,
1304 yaw: 0.0,
1305 pitch: 0.0,
1306 roll: 0.0,
1307 draw_scale: 1.0,
1308 sprite_mode: None,
1309 presentation_state: None,
1310 });
1311 assert!(
1312 flat.contains(&oak.glyph),
1313 "expected nearby tree ({:?}): {flat}",
1314 oak.glyph
1315 );
1316 assert!(flat.contains('@'), "expected player: {flat}");
1317 }
1318
1319 #[test]
1320 fn inside_building_flag_selects_active_instance() {
1321 let town = BuildingView {
1322 id: "town_hall".into(),
1323 label: "Town Hall".into(),
1324 x: 160.0,
1325 y: 136.0,
1326 width_m: 20.0,
1327 depth_m: 10.0,
1328 interior_blueprint: Some("town_hall".into()),
1329 tags: vec![],
1330 };
1331 let guild = BuildingView {
1332 id: "guild_hall".into(),
1333 label: "Guild Hall".into(),
1334 x: 164.0,
1335 y: 152.0,
1336 width_m: 20.0,
1337 depth_m: 14.0,
1338 interior_blueprint: Some("guild_hall".into()),
1339 tags: vec![],
1340 };
1341 let mut state = state_with_building(town);
1342 state.buildings.push(guild);
1343 state.entities = vec![EntityState {
1344 id: 1,
1345 label: "You".into(),
1346 transform: Transform {
1347 position: WorldCoord::surface(4.0, 3.0),
1348 yaw: 0.0,
1349 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1350 },
1351 vitals: Some(PlayerVitals::default()),
1352 attributes: Some(PrimaryAttributes::default()),
1353 skills: Some(flatland_protocol::PlayerSkills::default()),
1354 inside_building: Some("guild_hall".into()),
1355 tile_id: None,
1356 paperdoll_ref: None,
1357 presentation_state: None,
1358 sprite_mode: None,
1359 progression_xp: None,
1360 }];
1361 state.player = state.entities.first().cloned();
1362 state.world_width_m = 256.0;
1363 state.world_height_m = 256.0;
1364
1365 let view = WorldView::build_with_target(&state, 25, 15, None);
1366 assert_eq!(view.inside_building.as_deref(), Some("guild_hall"));
1367 }
1368
1369 #[test]
1370 fn combat_target_marks_creature_cell() {
1371 let building = BuildingView {
1372 id: "x".into(),
1373 label: "X".into(),
1374 x: 128.0,
1375 y: 128.0,
1376 width_m: 1.0,
1377 depth_m: 1.0,
1378 interior_blueprint: None,
1379 tags: vec![],
1380 };
1381 let mut state = state_with_building(building);
1382 state.entities = vec![
1383 EntityState {
1384 id: 1,
1385 label: "You".into(),
1386 transform: Transform {
1387 position: WorldCoord::surface(100.0, 100.0),
1388 yaw: 0.0,
1389 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1390 },
1391 vitals: Some(PlayerVitals::default()),
1392 attributes: Some(PrimaryAttributes::default()),
1393 skills: Some(flatland_protocol::PlayerSkills::default()),
1394 inside_building: None,
1395 tile_id: None,
1396 paperdoll_ref: None,
1397 presentation_state: None,
1398 sprite_mode: None,
1399 progression_xp: None,
1400 },
1401 EntityState {
1402 id: 42,
1403 label: "Rabbit".into(),
1404 transform: Transform {
1405 position: WorldCoord::surface(103.0, 100.0),
1406 yaw: 0.0,
1407 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1408 },
1409 vitals: None,
1410 attributes: None,
1411 skills: None,
1412 inside_building: None,
1413 tile_id: None,
1414 paperdoll_ref: None,
1415 presentation_state: None,
1416 sprite_mode: None,
1417 progression_xp: None,
1418 },
1419 ];
1420 state.player = state.entities.first().cloned();
1421 state.combat_target = Some(42);
1422 state.combat_target_label = Some("Rabbit".into());
1423
1424 let view = WorldView::build_with_target(&state, 25, 15, None);
1425 let marked: usize = view
1426 .target_t1_cells
1427 .iter()
1428 .chain(view.target_t2_cells.iter())
1429 .filter(|b| **b)
1430 .count();
1431 assert_eq!(marked, 1, "exactly one targeted cell");
1432 let idx = view
1433 .target_t1_cells
1434 .iter()
1435 .chain(view.target_t2_cells.iter())
1436 .position(|b| *b)
1437 .expect("target cell");
1438 assert_eq!(view.cells[idx], "R");
1439 }
1440
1441 #[test]
1442 fn combat_target_ring_prefers_live_npc_coords() {
1443 let building = BuildingView {
1444 id: "x".into(),
1445 label: "X".into(),
1446 x: 128.0,
1447 y: 128.0,
1448 width_m: 1.0,
1449 depth_m: 1.0,
1450 interior_blueprint: None,
1451 tags: vec![],
1452 };
1453 let mut state = state_with_building(building);
1454 state.entities = vec![
1455 EntityState {
1456 id: 1,
1457 label: "You".into(),
1458 transform: Transform {
1459 position: WorldCoord::surface(100.0, 100.0),
1460 yaw: 0.0,
1461 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1462 },
1463 vitals: Some(PlayerVitals::default()),
1464 attributes: Some(PrimaryAttributes::default()),
1465 skills: Some(flatland_protocol::PlayerSkills::default()),
1466 inside_building: None,
1467 tile_id: None,
1468 paperdoll_ref: None,
1469 presentation_state: None,
1470 sprite_mode: None,
1471 progression_xp: None,
1472 },
1473 EntityState {
1474 id: 42,
1475 label: "Rabbit".into(),
1476 transform: Transform {
1478 position: WorldCoord::surface(90.0, 100.0),
1479 yaw: 0.0,
1480 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
1481 },
1482 vitals: None,
1483 attributes: None,
1484 skills: None,
1485 inside_building: None,
1486 tile_id: None,
1487 paperdoll_ref: None,
1488 presentation_state: None,
1489 sprite_mode: None,
1490 progression_xp: None,
1491 },
1492 ];
1493 state.npcs = vec![flatland_protocol::NpcView {
1494 id: "rabbit-1".into(),
1495 label: "Rabbit".into(),
1496 role: "wildlife".into(),
1497 x: 103.0,
1498 y: 100.0,
1499 building_id: None,
1500 entity_id: Some(42),
1501 life_state: Some(flatland_protocol::LifeState::Alive),
1502 hp_pct: Some(1.0),
1503 can_trade: false,
1504 tile_id: None,
1505 behavior_state: None,
1506 presentation_state: None,
1507 sprite_mode: None,
1508 paperdoll_ref: None,
1509 }];
1510 state.player = state.entities.first().cloned();
1511 state.combat_target = Some(42);
1512
1513 let view = WorldView::build_with_target(&state, 25, 15, None);
1514 let marked = view
1515 .target_t1_cells
1516 .iter()
1517 .position(|b| *b)
1518 .expect("target cell");
1519 let half_w = (view.width / 2) as i32;
1520 let half_h = (view.height / 2) as i32;
1521 let (live_gx, live_gy) =
1522 world_to_grid(103.0, 100.0, 100.0, 100.0, half_w, half_h, view.width, view.height)
1523 .expect("live npc in view");
1524 let (stale_gx, stale_gy) =
1525 world_to_grid(90.0, 100.0, 100.0, 100.0, half_w, half_h, view.width, view.height)
1526 .expect("stale entity in view");
1527 let live_idx = live_gy * view.width + live_gx;
1528 let stale_idx = stale_gy * view.width + stale_gx;
1529 assert_eq!(marked, live_idx, "ring must follow live NPC coords");
1530 assert_ne!(marked, stale_idx, "ring must not stay on stale entity coords");
1531 }
1532
1533 #[test]
1534 fn grid_to_world_roundtrips_center() {
1535 let px = 10.0;
1536 let py = 20.0;
1537 let view_w = 11;
1538 let view_h = 11;
1539 let half_w = (view_w / 2) as i32;
1540 let half_h = (view_h / 2) as i32;
1541 let (gx, gy) =
1542 world_to_grid(12.0, 18.0, px, py, half_w, half_h, view_w, view_h).expect("in view");
1543 let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
1544 assert!((wx - 12.0).abs() < 0.01);
1545 assert!((wy - 18.0).abs() < 0.01);
1546 }
1547
1548 #[test]
1549 fn vertical_axis_quantizes_to_one_meter() {
1550 let px = 0.0;
1551 let py = 0.0;
1552 let view_w = 21;
1553 let view_h = 21;
1554 let half_w = (view_w / 2) as i32;
1555 let half_h = (view_h / 2) as i32;
1556 let (gx, gy) =
1557 world_to_grid(3.0, 3.0, px, py, half_w, half_h, view_w, view_h).expect("in view");
1558 let (wx, wy) = grid_to_world(gx, gy, px, py, view_w, view_h).expect("inverse");
1559 assert!(
1560 (wx - 3.0).abs() < 0.01,
1561 "x should stay exact to 1m: got {wx}"
1562 );
1563 assert!(
1564 (wy - 3.0).abs() < 0.01,
1565 "y should stay exact to 1m: got {wy}"
1566 );
1567 }
1568
1569 #[test]
1570 fn square_extent_spans_equal_rows_and_columns() {
1571 let px = 0.0;
1572 let py = 0.0;
1573 let half_w = 50;
1574 let half_h = 50;
1575 let width = 101;
1576 let height = 101;
1577 let (gx0, gy0) =
1578 world_to_grid(-4.0, -4.0, px, py, half_w, half_h, width, height).expect("in view");
1579 let (gx1, gy1) =
1580 world_to_grid(4.0, 4.0, px, py, half_w, half_h, width, height).expect("in view");
1581 let cols_spanned = (gx1 as i32 - gx0 as i32).unsigned_abs();
1582 let rows_spanned = (gy1 as i32 - gy0 as i32).unsigned_abs();
1583 assert_eq!(cols_spanned, 8, "8m wide should span 8 columns");
1584 assert_eq!(rows_spanned, 8, "8m tall should span 8 rows");
1585 }
1586
1587 #[test]
1588 fn resource_paints_on_terrain_cell_for_same_world_coords() {
1589 use flatland_protocol::{ResourceNodeState, ResourceNodeView, TerrainZoneView};
1590
1591 let px = 128.0;
1592 let py = 128.0;
1593 let rx = 131.0;
1594 let ry = 132.0;
1595 let mut state = state_with_building(BuildingView {
1596 id: "x".into(),
1597 label: "X".into(),
1598 x: 128.0,
1599 y: 128.0,
1600 width_m: 1.0,
1601 depth_m: 1.0,
1602 interior_blueprint: None,
1603 tags: vec![],
1604 });
1605 state.terrain_zones.push(TerrainZoneView {
1606 id: "pond".into(),
1607 x0: rx,
1608 y0: ry,
1609 x1: rx + 1.0,
1610 y1: ry + 1.0,
1611 kind: TerrainKindView::ShallowWater,
1612 elevation: -0.5,
1613 glyph: None,
1614 color: None,
1615 tile_id: None,
1616 z_order: 0,
1617 });
1618 state.resource_nodes.push(ResourceNodeView {
1619 id: "oak".into(),
1620 label: "Oak".into(),
1621 x: rx,
1622 y: ry,
1623 z: 0.0,
1624 item_template: "oak_log".into(),
1625 state: ResourceNodeState::Available,
1626 blocking: true,
1627 blocking_radius_m: 0.8,
1628 tile_id: None,
1629 paperdoll_ref: None,
1630 yaw: 0.0,
1631 pitch: 0.0,
1632 roll: 0.0,
1633 draw_scale: 1.0,
1634 sprite_mode: None,
1635 presentation_state: None,
1636 });
1637 state.entities[0].transform.position = WorldCoord::surface(px, py);
1638 state.player = state.entities.first().cloned();
1639
1640 let view = WorldView::build_with_target(&state, 25, 15, None);
1641 let half_w = (view.width / 2) as i32;
1642 let half_h = (view.height / 2) as i32;
1643 let (gx, gy) = world_to_grid(rx, ry, px, py, half_w, half_h, view.width, view.height)
1644 .expect("resource in view");
1645 let idx = gy * view.width + gx;
1646 let oak = map_presentation::resource_for(&state.resource_nodes[0]);
1647 assert_eq!(
1648 view.cells[idx], oak.glyph,
1649 "resource should paint on the grid cell for its world coords"
1650 );
1651 let (wx, wy) = grid_to_world(gx, gy, px, py, view.width, view.height).expect("inverse");
1652 assert!(
1653 (wx - rx).abs() < 0.01 && (wy - ry).abs() < 0.01,
1654 "resource grid cell should sample terrain at ({wx}, {wy}), expected ({rx}, {ry})"
1655 );
1656 }
1657
1658 #[test]
1659 fn chest_and_loot_paint_on_non_grass_terrain() {
1660 use flatland_protocol::{GroundDropView, PlacedContainerView, TerrainZoneView};
1661
1662 let px = 50.0;
1663 let py = 50.0;
1664 let cx = 53.0;
1665 let cy = 52.0;
1666 let lx = 54.0;
1667 let ly = 52.0;
1668 let mut state = state_with_building(BuildingView {
1669 id: "x".into(),
1670 label: "X".into(),
1671 x: 50.0,
1672 y: 50.0,
1673 width_m: 1.0,
1674 depth_m: 1.0,
1675 interior_blueprint: None,
1676 tags: vec![],
1677 });
1678 state.terrain_zones.push(TerrainZoneView {
1679 id: "trail".into(),
1680 x0: 52.0,
1681 y0: 51.0,
1682 x1: 56.0,
1683 y1: 54.0,
1684 kind: TerrainKindView::Trail,
1685 elevation: 0.0,
1686 glyph: None,
1687 color: None,
1688 tile_id: None,
1689 z_order: 0,
1690 });
1691 state.placed_containers.push(PlacedContainerView {
1692 id: "chest_1".into(),
1693 template_id: "wood_chest".into(),
1694 display_name: "Storage".into(),
1695 x: cx,
1696 y: cy,
1697 z: 0.0,
1698 locked: false,
1699 accessible: true,
1700 owner_character_id: None,
1701 contents: vec![],
1702 lock_id: None,
1703 capacity_volume: Some(40.0),
1704 item_instance_id: None,
1705 tile_id: None,
1706 paperdoll_ref: None,
1707 worker_lodging_capacity: None,
1708 });
1709 state.ground_drops.push(GroundDropView {
1710 id: "drop_1".into(),
1711 template_id: "lumber".into(),
1712 quantity: 2,
1713 x: lx,
1714 y: ly,
1715 z: 0.0,
1716 tile_id: None,
1717 paperdoll_ref: None,
1718 yaw: 0.0,
1719 pitch: 0.0,
1720 roll: 0.0,
1721 draw_scale: 1.0,
1722 });
1723 state.entities[0].transform.position = WorldCoord::surface(px, py);
1724 state.player = state.entities.first().cloned();
1725
1726 let view = WorldView::build_with_target(&state, 25, 15, None);
1727 let half_w = (view.width / 2) as i32;
1728 let half_h = (view.height / 2) as i32;
1729 let (gx, gy) =
1730 world_to_grid(cx, cy, px, py, half_w, half_h, view.width, view.height).expect("chest");
1731 let chest = map_presentation::chest_presentation(false);
1732 assert_eq!(
1733 view.cells[gy * view.width + gx],
1734 chest.glyph,
1735 "chest must paint on trail/non-grass cells"
1736 );
1737 let (gx2, gy2) =
1738 world_to_grid(lx, ly, px, py, half_w, half_h, view.width, view.height).expect("loot");
1739 let loot = map_presentation::loot_presentation();
1740 assert_eq!(
1741 view.cells[gy2 * view.width + gx2],
1742 loot.glyph,
1743 "ground loot must paint on trail/non-grass cells"
1744 );
1745 }
1746
1747 #[test]
1748 fn build_with_anchor_sets_view_origin() {
1749 let mut state = state_with_building(BuildingView {
1750 id: "x".into(),
1751 label: "X".into(),
1752 x: 128.0,
1753 y: 128.0,
1754 width_m: 1.0,
1755 depth_m: 1.0,
1756 interior_blueprint: None,
1757 tags: vec![],
1758 });
1759 state.entities[0].transform.position = WorldCoord::surface(100.0, 200.0);
1760 state.player = state.entities.first().cloned();
1761 let view = WorldView::build_with_anchor(
1762 &state,
1763 21,
1764 15,
1765 12.3,
1766 40.7,
1767 None,
1768 WorldViewOptions::terrain_only(),
1769 );
1770 assert!((view.origin_x - 12.3).abs() < 0.01);
1771 assert!((view.origin_y - 40.7).abs() < 0.01);
1772 }
1773
1774 #[test]
1775 fn skip_local_player_glyph_when_paint_local_player_false() {
1776 let mut state = state_with_building(BuildingView {
1777 id: "x".into(),
1778 label: "X".into(),
1779 x: 10.0,
1780 y: 10.0,
1781 width_m: 1.0,
1782 depth_m: 1.0,
1783 interior_blueprint: None,
1784 tags: vec![],
1785 });
1786 state.entities[0].transform.position = WorldCoord::surface(128.0, 128.0);
1787 state.player = state.entities.first().cloned();
1788 let player_glyph = map_presentation::player_presentation().glyph;
1789
1790 let with_player =
1791 WorldView::build_with_options(&state, 21, 15, None, WorldViewOptions::default());
1792 let cx = with_player.width / 2;
1793 let cy = with_player.height / 2;
1794 assert_eq!(
1795 with_player.cells[cy * with_player.width + cx],
1796 player_glyph,
1797 "default build paints @"
1798 );
1799
1800 let without =
1801 WorldView::build_with_options(&state, 21, 15, None, WorldViewOptions::terrain_only());
1802 assert_ne!(
1803 without.cells[cy * without.width + cx],
1804 player_glyph,
1805 "gfx sprite mode must not paint local player @"
1806 );
1807 }
1808}