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