src/selection.rs 21.1 K · 667 lines · raw · history

1 use bevy::{prelude::*, transform::TransformSystems, window::PrimaryWindow};
2
3 use crate::{
4 actions::Action,
5 building::Blueprints,
6 camera::CameraController,
7 colonist::{Colonist, Path},
8 items::{Carrying, Item, ItemKind, stock},
9 jobs::{ColonyGoals, CurrentJob, EXPAND_WORK, Job, Order, Task, WorkKind},
10 light::daylight,
11 needs::Rest,
12 sim::{SimSpeed, SimTick, TICKS_PER_HOUR, format_clock, hour_of_day},
13 structures::{Fuel, Structure, StructureKind},
14 ui::{HideableUi, PointerOverUi},
15 world::{TILE_SIZE, TilePos, WorldMap},
16 worldgen::{WorldGenerated, WorldSeed},
17 zones::{ZoneTool, Zones},
18 };
19
20 pub struct SelectionPlugin;
21
22 impl Plugin for SelectionPlugin {
23 fn build(&self, app: &mut App) {
24 app.init_resource::<Cursor>()
25 .init_resource::<Selection>()
26 .add_systems(Startup, spawn_tile_info)
27 .add_systems(Update, clear_selection.run_if(on_message::<WorldGenerated>))
28 .add_systems(
29 PostUpdate,
30 (
31 update_cursor,
32 update_selection.run_if(|tool: Res<ZoneTool>| !tool.active),
33 issue_move_orders,
34 draw_highlights,
35 update_tile_info,
36 )
37 .chain()
38 .after(TransformSystems::Propagate),
39 );
40 }
41 }
42
43 #[derive(Resource, Default, Debug, PartialEq)]
44 pub struct Cursor {
45 pub screen: Option<Vec2>,
46 pub world: Option<Vec2>,
47 pub tile: Option<UVec2>,
48 }
49
50 #[derive(Resource, Default, Debug, PartialEq, Clone, Copy)]
51 pub enum Selection {
52 #[default]
53 None,
54 Tile(UVec2),
55 Colonist(Entity),
56 }
57
58 #[derive(Component)]
59 struct TileInfoText;
60
61 const HOVER_COLOR: Color = Color::srgba(1.0, 1.0, 1.0, 0.6);
62 const SELECT_COLOR: Color = Color::srgb(1.0, 0.85, 0.2);
63 const PROGRESS_COLOR: Color = Color::srgb(0.4, 0.9, 0.4);
64 const PROGRESS_BACK_COLOR: Color = Color::srgba(0.0, 0.0, 0.0, 0.6);
65 const PATH_COLOR: Color = Color::srgba(1.0, 0.85, 0.2, 0.7);
66 const PICK_RADIUS: f32 = TILE_SIZE * 0.6;
67 const CLICK_SLOP_PX: f32 = 4.0;
68
69 pub fn update_cursor(
70 map: Res<WorldMap>,
71 window: Single<&Window, With<PrimaryWindow>>,
72 camera: Single<(&Camera, &GlobalTransform), With<CameraController>>,
73 over_ui: Res<PointerOverUi>,
74 mut cursor: ResMut<Cursor>,
75 ) {
76 let (camera, global) = *camera;
77 let screen = window.cursor_position();
78 let world = screen
79 .filter(|_| !over_ui.0)
80 .and_then(|s| camera.viewport_to_world_2d(global, s).ok());
81 let tile = world.and_then(|w| map.tile_at(w));
82 cursor.set_if_neq(Cursor {
83 screen,
84 world,
85 tile,
86 });
87 }
88
89 fn clear_selection(mut selection: ResMut<Selection>) {
90 *selection = Selection::None;
91 }
92
93 fn update_selection(
94 mut actions: MessageReader<Action>,
95 buttons: Res<ButtonInput<MouseButton>>,
96 over_ui: Res<PointerOverUi>,
97 cursor: Res<Cursor>,
98 colonists: Query<(Entity, &Transform), With<Colonist>>,
99 mut selection: ResMut<Selection>,
100 ) {
101 if actions.read().any(|a| *a == Action::Cancel) {
102 selection.set_if_neq(Selection::None);
103 } else if buttons.just_pressed(MouseButton::Left) && !over_ui.0 {
104 let picked = cursor.world.and_then(|world| {
105 pick_nearest(
106 world,
107 colonists.iter().map(|(e, t)| (e, t.translation.truncate())),
108 )
109 });
110 let new = match (picked, cursor.tile) {
111 (Some(entity), _) => Selection::Colonist(entity),
112 (None, Some(tile)) => Selection::Tile(tile),
113 (None, None) => Selection::None,
114 };
115 selection.set_if_neq(new);
116 }
117 }
118
119 pub fn pick_nearest(
120 point: Vec2,
121 candidates: impl Iterator<Item = (Entity, Vec2)>,
122 ) -> Option<Entity> {
123 candidates
124 .map(|(e, pos)| (e, pos.distance(point)))
125 .filter(|&(_, d)| d <= PICK_RADIUS)
126 .min_by(|a, b| a.1.total_cmp(&b.1))
127 .map(|(e, _)| e)
128 }
129
130 fn issue_move_orders(
131 buttons: Res<ButtonInput<MouseButton>>,
132 cursor: Res<Cursor>,
133 selection: Res<Selection>,
134 over_ui: Res<PointerOverUi>,
135 mut orders: MessageWriter<Order>,
136 mut pressed_at: Local<Option<Vec2>>,
137 ) {
138 if buttons.just_pressed(MouseButton::Right) {
139 *pressed_at = cursor.screen.filter(|_| !over_ui.0);
140 }
141 if !buttons.just_released(MouseButton::Right) {
142 return;
143 }
144 let (Some(press), Some(release)) = (pressed_at.take(), cursor.screen) else {
145 return;
146 };
147 if let (true, Selection::Colonist(colonist), Some(tile)) =
148 (is_click(press, release), *selection, cursor.tile)
149 {
150 orders.write(Order { colonist, tile });
151 }
152 }
153
154 pub fn is_click(press: Vec2, release: Vec2) -> bool {
155 press.distance(release) <= CLICK_SLOP_PX
156 }
157
158 fn draw_highlights(
159 mut gizmos: Gizmos,
160 cursor: Res<Cursor>,
161 selection: Res<Selection>,
162 colonists: Query<(Entity, &Transform, &Path, &CurrentJob), With<Colonist>>,
163 ) {
164 for (_, transform, _, job) in &colonists {
165 if let Some(fraction) = job.0.and_then(|j| progress_fraction(j.task))
166 && fraction > 0.0
167 {
168 let left =
169 transform.translation.truncate() + Vec2::new(-TILE_SIZE * 0.4, TILE_SIZE * 0.6);
170 let full = TILE_SIZE * 0.8;
171 let done = full * fraction;
172 gizmos.line_2d(left, left + Vec2::X * full, PROGRESS_BACK_COLOR);
173 gizmos.line_2d(left, left + Vec2::X * done, PROGRESS_COLOR);
174 }
175 }
176 if let Some(tile) = cursor.tile {
177 gizmos.rect_2d(
178 WorldMap::tile_center(tile),
179 Vec2::splat(TILE_SIZE),
180 HOVER_COLOR,
181 );
182 }
183 match *selection {
184 Selection::None => {}
185 Selection::Tile(tile) => {
186 gizmos.rect_2d(
187 WorldMap::tile_center(tile),
188 Vec2::splat(TILE_SIZE),
189 SELECT_COLOR,
190 );
191 }
192 Selection::Colonist(entity) => {
193 let Ok((_, transform, path, job)) = colonists.get(entity) else {
194 return;
195 };
196 if let Some(Job {
197 task:
198 Task::Work { site, .. }
199 | Task::ExpandStockpile { site, .. }
200 | Task::Build { site, .. },
201 ..
202 }) = job.0
203 {
204 gizmos.rect_2d(
205 WorldMap::tile_center(site),
206 Vec2::splat(TILE_SIZE),
207 PATH_COLOR,
208 );
209 }
210 let pos = transform.translation.truncate();
211 gizmos.circle_2d(pos, TILE_SIZE * 0.5, SELECT_COLOR);
212 let points = path.0.iter().map(|&t| WorldMap::tile_center(t));
213 gizmos.linestrip_2d(std::iter::once(pos).chain(points), PATH_COLOR);
214 }
215 }
216 }
217
218 fn spawn_tile_info(mut commands: Commands) {
219 commands.spawn((
220 TileInfoText,
221 HideableUi,
222 Text::default(),
223 TextFont::from_font_size(14.0),
224 Node {
225 position_type: PositionType::Absolute,
226 top: px(8),
227 left: px(8),
228 padding: UiRect::all(px(6)),
229 ..default()
230 },
231 BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.5)),
232 ));
233 }
234
235 fn update_tile_info(
236 map: Res<WorldMap>,
237 cursor: Res<Cursor>,
238 selection: Res<Selection>,
239 colonists: Query<(&Colonist, &TilePos, &Path, &CurrentJob, &Carrying, &Rest)>,
240 items: Query<(&Item, &TilePos)>,
241 structures: Query<(&Structure, &TilePos, Option<&Fuel>)>,
242 blueprints: Res<Blueprints>,
243 goals: Res<ColonyGoals>,
244 zones: Res<Zones>,
245 tool: Res<ZoneTool>,
246 time: Res<Time<Virtual>>,
247 tick: Res<SimTick>,
248 speed: Res<SimSpeed>,
249 seed: Res<WorldSeed>,
250 mut text: Single<&mut Text, With<TileInfoText>>,
251 ) {
252 let selected = match *selection {
253 Selection::None => "-".to_string(),
254 Selection::Tile(tile) => describe_tile(&map, Some(tile)),
255 Selection::Colonist(entity) => colonists
256 .get(entity)
257 .map(|(colonist, tile, path, job, carrying, rest)| {
258 describe_colonist(colonist, tile.0, job.0, path.0.len(), carrying.0, rest.0)
259 })
260 .unwrap_or_else(|_| "-".into()),
261 };
262 let clock = format!(
263 "{} ({})",
264 format_clock(*tick),
265 time_of_day(hour_of_day(*tick))
266 );
267 let speed = describe_speed(*speed, time.is_paused());
268 let carried: Vec<Item> = colonists.iter().filter_map(|c| c.4.0).collect();
269 let materials = ItemKind::ALL
270 .iter()
271 .map(|&kind| {
272 let have = stock(
273 items.iter().map(|(item, _)| item).chain(carried.iter()),
274 kind,
275 );
276 format!("{kind:?} {have}/{}", goals.goal(kind))
277 })
278 .collect::<Vec<_>>()
279 .join(" ");
280 let hovered_zone = match cursor.tile {
281 Some(tile) if zones.is_stockpile(tile) => ", Stockpile",
282 _ => "",
283 };
284 let hovered_items: String = items
285 .iter()
286 .filter(|(_, pos)| Some(pos.0) == cursor.tile)
287 .map(|(item, _)| format!(", {:?} x{}", item.kind, item.count))
288 .collect();
289 let hovered_structure: String = structures
290 .iter()
291 .filter(|(_, pos, _)| Some(pos.0) == cursor.tile)
292 .map(|(structure, _, fuel)| describe_structure(structure.kind, fuel.map(|f| f.0)))
293 .chain(
294 cursor
295 .tile
296 .and_then(|t| blueprints.get(t))
297 .map(|kind| format!(", planned {}", kind.name())),
298 )
299 .collect();
300 let tool_hint = if tool.active {
301 "\nSTOCKPILE TOOL: drag to add, shift-drag to remove"
302 } else {
303 ""
304 };
305 let info = format!(
306 "{clock} {speed}\nSeed: {}\n{materials}{tool_hint}\nHover: {}{hovered_zone}{hovered_items}{hovered_structure}\nSelected: {selected}",
307 seed.0,
308 describe_tile(&map, cursor.tile)
309 );
310 if text.0 != info {
311 text.0 = info;
312 }
313 }
314
315 pub fn describe_tile(map: &WorldMap, tile: Option<UVec2>) -> String {
316 let Some((t, terrain)) = tile.and_then(|t| map.get(t.as_ivec2()).map(|terrain| (t, terrain)))
317 else {
318 return "-".into();
319 };
320 let p = t.as_ivec2();
321 let roof = if map.is_roofed(p) { ", Roofed" } else { "" };
322 let plant = map
323 .plant(p)
324 .map(|plant| format!(", {plant:?}"))
325 .unwrap_or_default();
326 let height = map.height(p).unwrap_or(0.0);
327 format!("({}, {}) {terrain:?}{plant}{roof}, {height:.0} m", t.x, t.y)
328 }
329
330 pub fn describe_speed(speed: SimSpeed, paused: bool) -> String {
331 if paused {
332 "PAUSED".into()
333 } else {
334 format!("{}x", speed.multiplier())
335 }
336 }
337
338 pub fn time_of_day(hour: f32) -> &'static str {
339 match daylight(hour) {
340 1.0 => "day",
341 0.0 => "night",
342 _ if hour < 12.0 => "dawn",
343 _ => "dusk",
344 }
345 }
346
347 pub fn describe_structure(kind: StructureKind, fuel: Option<u32>) -> String {
348 match fuel {
349 Some(fuel) => {
350 let hours = fuel as f32 / TICKS_PER_HOUR as f32;
351 format!(", {:?} ({hours:.1}h of fuel)", kind)
352 }
353 None => format!(", {kind:?}"),
354 }
355 }
356
357 pub fn progress_fraction(task: Task) -> Option<f32> {
358 match task {
359 Task::Work { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
360 Task::ExpandStockpile { progress, .. } => Some(progress as f32 / EXPAND_WORK as f32),
361 Task::Build { kind, progress, .. } => Some(progress as f32 / kind.work() as f32),
362 _ => None,
363 }
364 }
365
366 fn percent(task: Task) -> u32 {
367 (progress_fraction(task).unwrap_or(0.0) * 100.0).round() as u32
368 }
369
370 pub fn describe_colonist(
371 colonist: &Colonist,
372 tile: UVec2,
373 job: Option<Job>,
374 steps_left: usize,
375 carrying: Option<Item>,
376 rest: f32,
377 ) -> String {
378 let activity = match (job.map(|j| j.task), steps_left) {
379 (None, 0) => "idle".to_string(),
380 (None, _) => "wandering".to_string(),
381 (Some(Task::MoveTo(_)), n) => format!("moving, {n} steps left"),
382 (
383 Some(Task::Work {
384 kind: WorkKind::Chop,
385 ..
386 }),
387 n,
388 ) if n > 0 => "walking to a tree".to_string(),
389 (
390 Some(Task::Work {
391 kind: WorkKind::Mine,
392 ..
393 }),
394 n,
395 ) if n > 0 => "walking to rock".to_string(),
396 (Some(task @ Task::Work { kind, .. }), _) => {
397 let verb = match kind {
398 WorkKind::Chop => "chopping a tree",
399 WorkKind::Mine => "mining",
400 };
401 format!("{verb}, {}%", percent(task))
402 }
403 (Some(Task::ExpandStockpile { .. }), n) if n > 0 => {
404 "going to expand the stockpile".to_string()
405 }
406 (Some(task @ Task::ExpandStockpile { .. }), _) => {
407 format!("expanding the stockpile, {}%", percent(task))
408 }
409 (
410 Some(Task::Haul {
411 pickup: Some(_), ..
412 }),
413 _,
414 ) => "fetching items to haul".to_string(),
415 (Some(Task::Haul { pickup: None, .. }), _) => "hauling to the stockpile".to_string(),
416 (
417 Some(Task::Build {
418 kind,
419 pickup: Some(_),
420 ..
421 }),
422 _,
423 ) => format!("fetching {:?} for a {}", kind.cost().0, kind.name()).to_lowercase(),
424 (Some(Task::Build { kind, .. }), n) if n > 0 => {
425 format!("carrying {:?} to build a {}", kind.cost().0, kind.name()).to_lowercase()
426 }
427 (Some(Task::Sleep { .. }), n) if n > 0 => "going to sleep".to_string(),
428 (Some(Task::Sleep { .. }), _) => "sleeping".to_string(),
429 (Some(task @ Task::Build { kind, .. }), _) => {
430 format!("building a {}, {}%", kind.name(), percent(task))
431 }
432 };
433 let load = carrying
434 .map(|item| format!(", carrying {} {:?}", item.count, item.kind))
435 .unwrap_or_default();
436 let ordered = if job.is_some_and(|j| j.ordered) {
437 " (ordered)"
438 } else {
439 ""
440 };
441 format!(
442 "{} at ({}, {}), {activity}{ordered}{load}, rest {:.0}%",
443 colonist.name,
444 tile.x,
445 tile.y,
446 rest * 100.0
447 )
448 }
449
450 #[cfg(test)]
451 mod tests {
452 use super::*;
453 use crate::world::Terrain;
454
455 fn selection_app(cursor: Cursor, selection: Selection) -> App {
456 let mut app = App::new();
457 app.init_resource::<ButtonInput<MouseButton>>()
458 .init_resource::<PointerOverUi>()
459 .insert_resource(cursor)
460 .insert_resource(selection)
461 .add_message::<Order>()
462 .add_message::<Action>()
463 .add_systems(Update, (update_selection, issue_move_orders));
464 app
465 }
466
467 fn cursor_on_tile(tile: UVec2) -> Cursor {
468 Cursor {
469 screen: Some(Vec2::new(50.0, 50.0)),
470 world: Some(WorldMap::tile_center(tile)),
471 tile: Some(tile),
472 }
473 }
474
475 fn press(app: &mut App, button: MouseButton) {
476 app.world_mut()
477 .resource_mut::<ButtonInput<MouseButton>>()
478 .press(button);
479 app.update();
480 }
481
482 fn release(app: &mut App, button: MouseButton) {
483 let mut input = app.world_mut().resource_mut::<ButtonInput<MouseButton>>();
484 input.clear();
485 input.release(button);
486 app.update();
487 }
488
489 fn selection(app: &App) -> Selection {
490 *app.world().resource::<Selection>()
491 }
492
493 fn sent_orders(app: &mut App) -> Vec<(Entity, UVec2)> {
494 app.world_mut()
495 .resource_mut::<Messages<Order>>()
496 .drain()
497 .map(|o| (o.colonist, o.tile))
498 .collect()
499 }
500
501 #[test]
502 fn clicking_selects_the_hovered_tile() {
503 let mut app = selection_app(cursor_on_tile(UVec2::new(3, 4)), Selection::None);
504 press(&mut app, MouseButton::Left);
505 assert_eq!(selection(&app), Selection::Tile(UVec2::new(3, 4)));
506 }
507
508 #[test]
509 fn clicking_a_colonist_selects_it_instead_of_its_tile() {
510 let tile = UVec2::new(3, 4);
511 let mut app = selection_app(cursor_on_tile(tile), Selection::None);
512 let colonist = app
513 .world_mut()
514 .spawn((
515 Colonist { name: "Ada".into() },
516 Transform::from_translation(WorldMap::tile_center(tile).extend(1.0)),
517 ))
518 .id();
519 press(&mut app, MouseButton::Left);
520 assert_eq!(selection(&app), Selection::Colonist(colonist));
521 }
522
523 #[test]
524 fn clicking_outside_the_world_clears_the_selection() {
525 let mut app = selection_app(Cursor::default(), Selection::Tile(UVec2::new(3, 4)));
526 press(&mut app, MouseButton::Left);
527 assert_eq!(selection(&app), Selection::None);
528 }
529
530 #[test]
531 fn cancel_clears_the_selection() {
532 let mut app = selection_app(
533 cursor_on_tile(UVec2::ONE),
534 Selection::Tile(UVec2::new(3, 4)),
535 );
536 app.world_mut().write_message(Action::Cancel);
537 app.update();
538 assert_eq!(selection(&app), Selection::None);
539 }
540
541 #[test]
542 fn clicks_on_the_ui_do_not_reach_the_map() {
543 let colonist = Entity::from_raw_u32(7).unwrap();
544 let mut app = selection_app(
545 cursor_on_tile(UVec2::new(5, 6)),
546 Selection::Colonist(colonist),
547 );
548 app.insert_resource(PointerOverUi(true));
549 press(&mut app, MouseButton::Left);
550 assert_eq!(selection(&app), Selection::Colonist(colonist));
551 press(&mut app, MouseButton::Right);
552 release(&mut app, MouseButton::Right);
553 assert!(sent_orders(&mut app).is_empty());
554 }
555
556 #[test]
557 fn right_click_orders_selected_colonist_to_hovered_tile() {
558 let colonist = Entity::from_raw_u32(7).unwrap();
559 let mut app = selection_app(
560 cursor_on_tile(UVec2::new(5, 6)),
561 Selection::Colonist(colonist),
562 );
563 press(&mut app, MouseButton::Right);
564 release(&mut app, MouseButton::Right);
565 assert_eq!(sent_orders(&mut app), vec![(colonist, UVec2::new(5, 6))]);
566 }
567
568 #[test]
569 fn right_drag_does_not_order_because_it_pans_the_camera() {
570 let colonist = Entity::from_raw_u32(7).unwrap();
571 let mut app = selection_app(
572 cursor_on_tile(UVec2::new(5, 6)),
573 Selection::Colonist(colonist),
574 );
575 press(&mut app, MouseButton::Right);
576 app.world_mut().resource_mut::<Cursor>().screen = Some(Vec2::new(200.0, 50.0));
577 release(&mut app, MouseButton::Right);
578 assert!(sent_orders(&mut app).is_empty());
579 }
580
581 #[test]
582 fn pick_nearest_ignores_far_candidates() {
583 let a = Entity::from_raw_u32(1).unwrap();
584 let b = Entity::from_raw_u32(2).unwrap();
585 let candidates = [(a, Vec2::new(5.0, 0.0)), (b, Vec2::new(2.0, 0.0))];
586 assert_eq!(pick_nearest(Vec2::ZERO, candidates.into_iter()), Some(b));
587 assert_eq!(
588 pick_nearest(Vec2::new(100.0, 0.0), candidates.into_iter()),
589 None
590 );
591 }
592
593 #[test]
594 fn describe_tile_shows_coordinates_terrain_plant_and_height() {
595 let mut map = WorldMap::filled(UVec2::new(4, 4), Terrain::Sand);
596 map.set_height(UVec2::new(1, 2), 12.4);
597 assert_eq!(
598 describe_tile(&map, Some(UVec2::new(1, 2))),
599 "(1, 2) Sand, 12 m"
600 );
601 map.set_plant(UVec2::new(1, 2), Some(crate::world::Plant::Tree));
602 assert_eq!(
603 describe_tile(&map, Some(UVec2::new(1, 2))),
604 "(1, 2) Sand, Tree, 12 m"
605 );
606 assert_eq!(describe_tile(&map, None), "-");
607 }
608
609 #[test]
610 fn time_of_day_labels() {
611 assert_eq!(time_of_day(12.0), "day");
612 assert_eq!(time_of_day(6.0), "dawn");
613 assert_eq!(time_of_day(19.0), "dusk");
614 assert_eq!(time_of_day(1.0), "night");
615 }
616
617 #[test]
618 fn describe_speed_shows_multiplier_or_paused() {
619 assert_eq!(describe_speed(SimSpeed::Fastest, false), "6x");
620 assert_eq!(describe_speed(SimSpeed::Fast, true), "PAUSED");
621 }
622
623 #[test]
624 fn describe_colonist_shows_activity() {
625 let ada = Colonist { name: "Ada".into() };
626 let tile = UVec2::new(1, 2);
627 assert_eq!(
628 describe_colonist(&ada, tile, None, 0, None, 1.0),
629 "Ada at (1, 2), idle, rest 100%"
630 );
631 let ordered_move = Job {
632 task: Task::MoveTo(UVec2::ZERO),
633 ordered: true,
634 };
635 assert_eq!(
636 describe_colonist(&ada, tile, Some(ordered_move), 3, None, 1.0),
637 "Ada at (1, 2), moving, 3 steps left (ordered), rest 100%"
638 );
639 let chop = Job {
640 task: Task::Work {
641 site: UVec2::ZERO,
642 kind: WorkKind::Chop,
643 progress: WorkKind::Chop.work() / 2,
644 },
645 ordered: false,
646 };
647 assert_eq!(
648 describe_colonist(&ada, tile, Some(chop), 0, None, 0.5),
649 "Ada at (1, 2), chopping a tree, 50%, rest 50%"
650 );
651 let haul = Job {
652 task: Task::Haul {
653 pickup: None,
654 to: UVec2::ZERO,
655 },
656 ordered: false,
657 };
658 let wood = Item {
659 kind: ItemKind::Wood,
660 count: 10,
661 };
662 assert_eq!(
663 describe_colonist(&ada, tile, Some(haul), 4, Some(wood), 0.64),
664 "Ada at (1, 2), hauling to the stockpile, carrying 10 Wood, rest 64%"
665 );
666 }
667 }