words.rsannotatedwords.rssource549 lines · 23.0 KB · raw
1//! The goals the bot could pursue, said as sentences a person would say, from
2//! nothing but the bot's own records of them (`zbanks::GoalOption`), and
3//! collapsed so that no two options in one question read alike.
4//!
5//! Every sentence carries what the bot knows that tells a goal apart from its
6//! neighbours: what it is, which screen it is on and where on that screen,
7//! which way it lies from Link, the exit the bot's own path leaves by and how
8//! many screens that path crosses, whether it is on the way to another option,
9//! what the bot expects there, failures so far, and the path length. Two goals
10//! that differ in none of those things except distance are the same choice as
11//! far as anyone could tell from the words, so they are one [`Offer`] - Jev
12//! is never asked to split what it cannot tell apart (user, on "use door use
13//! door use door"), and never asked to break a tie the planner's own scoring
14//! already settles: distance is not a difference in KIND, so it is never a
15//! reason to keep two same-kind, same-place goals as separate offers, however
16//! far apart the two distances are (frame 14674, two pots 27 and 45 steps
17//! apart, asked anyway - the fix this note describes).
18
19use zbanks::{GoalOption, Situation};
20
21/// The bot's screen names carry their bounds and level after the name
22/// ("Well Uncle ^ 0 6a00,a00 x 6bff,aff", "Castle Yard 600,600 x 9ff,9ff");
23/// unnamed screens are their id ("0x0c50 v 0 (20480 ...)"). The name alone,
24/// with the bot's abbreviations spelled out, or `None` for an unnamed one.
25pub fn screen_name(screen: &str) -> Option<String> {
26    let mut words = Vec::new();
27    for word in screen.split_whitespace() {
28        let marker = word == "^" || word == "v" || word.starts_with('(') || word.contains(',');
29        if marker {
30            break;
31        }
32        words.push(word);
33    }
34    if words.is_empty() || words[0].starts_with("0x") {
35        return None;
36    }
37    let name = words
38        .iter()
39        .map(|w| match *w {
40            "HC" => "Hyrule Castle",
41            "EP" => "Eastern Palace",
42            "DP" => "Desert Palace",
43            "Kak" => "Kakariko",
44            "DM" => "Death Mountain",
45            "ToH" => "Tower of Hera",
46            other => other,
47        })
48        .collect::<Vec<_>>()
49        .join(" ");
50    Some(name.trim_end_matches(';').to_owned())
51}
52
53/// A direction index as the bot keeps it (ap_map.h:9: 1 U, 2 D, 3 L, 4 R).
54fn compass_of(direction: u8) -> Option<&'static str> {
55    match direction {
56        1 => Some("north"),
57        2 => Some("south"),
58        3 => Some("west"),
59        4 => Some("east"),
60        _ => None,
61    }
62}
63
64/// An exit's direction: the bot's own, else the letter in its node name
65/// ("door U 0x84 ...").
66fn direction(option: &GoalOption) -> Option<&'static str> {
67    compass_of(option.direction).or_else(|| {
68        option.node.split_whitespace().nth(1).and_then(|w| match w {
69            "U" => Some("north"),
70            "D" => Some("south"),
71            "L" => Some("west"),
72            "R" => Some("east"),
73            _ => None,
74        })
75    })
76}
77
78/// Which way `to` lies from `from`, in eight compass points (y grows south),
79/// or `None` when they are within a couple of tiles of each other.
80pub fn bearing(from: (u16, u16), to: (u16, u16)) -> Option<&'static str> {
81    let dx = i32::from(to.0) - i32::from(from.0);
82    let dy = i32::from(to.1) - i32::from(from.1);
83    if dx.abs().max(dy.abs()) < 24 {
84        return None;
85    }
86    let ns = if dy < 0 { "north" } else { "south" };
87    let ew = if dx < 0 { "west" } else { "east" };
88    Some(if dx.abs() > 2 * dy.abs() {
89        ew
90    } else if dy.abs() > 2 * dx.abs() {
91        ns
92    } else {
93        match (dy < 0, dx < 0) {
94            (true, true) => "north-west",
95            (true, false) => "north-east",
96            (false, true) => "south-west",
97            (false, false) => "south-east",
98        }
99    })
100}
101
102/// Where on its screen a point is, by thirds of the screen's width and height.
103pub fn zone(at: (u16, u16), bounds: ((u16, u16), (u16, u16))) -> Option<&'static str> {
104    let ((x0, y0), (x1, y1)) = bounds;
105    if x1 <= x0 || y1 <= y0 {
106        return None;
107    }
108    let third = |v: u16, lo: u16, hi: u16| {
109        let f = f64::from(v.saturating_sub(lo)) / f64::from(hi - lo);
110        if f < 1.0 / 3.0 { 0 } else if f < 2.0 / 3.0 { 1 } else { 2 }
111    };
112    Some(match (third(at.1, y0, y1), third(at.0, x0, x1)) {
113        (0, 0) => "north-west corner",
114        (0, 1) => "north side",
115        (0, _) => "north-east corner",
116        (1, 0) => "west side",
117        (1, 1) => "middle",
118        (1, _) => "east side",
119        (_, 0) => "south-west corner",
120        (_, 1) => "south side",
121        (_, _) => "south-east corner",
122    })
123}
124
125fn person(option: &GoalOption) -> &'static str {
126    match (option.sprite_type, option.sprite_subtype) {
127        (0x73, 0x100) => "Link's wounded uncle",
128        (0x73, _) => "the sage",
129        (0x16, _) => "Sahasrahla, the village elder",
130        (0x2C, _) => "the lumberjacks",
131        (0x31, _) => "the fortune teller",
132        _ => "the person",
133    }
134}
135
136fn on_links_screen(option: &GoalOption, here: &Situation) -> bool {
137    !here.link_screen.is_empty() && option.screen == here.link_screen
138}
139
140/// The screen a goal is on, as a noun phrase.
141fn screen_phrase(option: &GoalOption, here: &Situation) -> String {
142    if on_links_screen(option, here) {
143        return "this screen".to_owned();
144    }
145    match screen_name(&option.screen) {
146        Some(name) => name,
147        None if option.indoors => "an unnamed room".to_owned(),
148        None => "an unnamed stretch of overworld".to_owned(),
149    }
150}
151
152/// An exit by the bot's own node name (ap_map.c:2737-3308) and direction:
153/// "north door" (a dungeon door, "door U 0x84 ..."), "entrance" (an
154/// overworld door into a building or cave, "door 0x3f"), "stairs up", "hole",
155/// "ledge", "north edge" (a screen edge, "0x148 U 1").
156fn exit_phrase(node: &str, direction: Option<&str>) -> String {
157    let mut words = node.split_whitespace();
158    let first = words.next().unwrap_or("");
159    let second = words.next().unwrap_or("");
160    let lettered = matches!(second, "U" | "D" | "L" | "R");
161    match (first, direction) {
162        ("door", Some(dir)) if lettered => format!("{dir} door"),
163        ("door", _) => "entrance".to_owned(),
164        ("stairs", _) if second == "U" => "stairs up".to_owned(),
165        ("stairs", _) => "stairs down".to_owned(),
166        ("hole", _) => "hole in the ground".to_owned(),
167        ("ledge", Some(dir)) => format!("{dir} ledge"),
168        ("ledge", None) => "ledge".to_owned(),
169        ("keyblock", _) => "locked block".to_owned(),
170        (_, Some(dir)) => format!("{dir} edge"),
171        (_, None) => "passage".to_owned(),
172    }
173}
174
175/// The thing itself: "the pot", "the chest", "the north door", "Link's
176/// wounded uncle". Short enough to name one option inside another's sentence.
177fn thing(option: &GoalOption) -> String {
178    let node = option.node.as_str();
179    let first = node.split_whitespace().next().unwrap_or("");
180    match option.kind.as_str() {
181        "CHEST" if first == "big" => "the unopened big chest".to_owned(),
182        "CHEST" => "the unopened chest".to_owned(),
183        "PICKUP" => match (first, option.sprite_type) {
184            ("pot", _) => "the pot".to_owned(),
185            (_, 0xE4) => "the small key lying".to_owned(),
186            (_, 0xE5) => "the big key lying".to_owned(),
187            (_, 0xEB) => "the piece of heart lying".to_owned(),
188            _ => "the item lying".to_owned(),
189        },
190        "NPC" => person(option).to_owned(),
191        "ITEM" if option.item.is_empty() => "the item".to_owned(),
192        "ITEM" => format!("the {}", option.item.to_lowercase()),
193        "SCRIPT" => format!("the known routine \"{}\"", node.strip_prefix("Script: ").unwrap_or(node)),
194        "EXPLORE" => format!("the {}", exit_phrase(node, direction(option))),
195        other => format!("the {other} goal \"{node}\""),
196    }
197}
198
199/// The verb for doing it, and what doing it is for.
200fn verb(option: &GoalOption) -> (&'static str, &'static str) {
201    let first = option.node.split_whitespace().next().unwrap_or("");
202    match option.kind.as_str() {
203        "CHEST" => ("Open", ""),
204        "PICKUP" if first == "pot" => ("Lift", " to see what is under it"),
205        "PICKUP" => ("Pick up", ""),
206        "NPC" => ("Talk to", ""),
207        "ITEM" => ("Collect", ""),
208        "SCRIPT" => ("Carry out", ""),
209        "EXPLORE" => ("Go through", ""),
210        _ => ("Pursue", ""),
211    }
212}
213
214/// "the pot in the north-west corner of this screen": enough to name another
215/// option from inside a sentence.
216fn short(option: &GoalOption, here: &Situation) -> String {
217    let screen = screen_phrase(option, here);
218    match zone(option.at, option.screen_bounds) {
219        Some(z) => format!("{} in the {z} of {screen}", thing(option)),
220        None => format!("{} in {screen}", thing(option)),
221    }
222}
223
224/// Where a point on Link's screen is from him, in steps (16 pixels) along
225/// each axis: "6 steps west and 2 steps north of Link".
226pub fn offset(from: (u16, u16), to: (u16, u16)) -> Option<String> {
227    let dx = (i32::from(to.0) - i32::from(from.0)) / 16;
228    let dy = (i32::from(to.1) - i32::from(from.1)) / 16;
229    let axis = |d: i32, neg: &str, pos: &str| match d {
230        0 => None,
231        1 | -1 => Some(format!("1 step {}", if d < 0 { neg } else { pos })),
232        d => Some(format!("{} steps {}", d.abs(), if d < 0 { neg } else { pos })),
233    };
234    match (axis(dx, "west", "east"), axis(dy, "north", "south")) {
235        (None, None) => None,
236        (Some(a), None) | (None, Some(a)) => Some(format!("{a} of Link")),
237        (Some(a), Some(b)) => Some(format!("{a} and {b} of Link")),
238    }
239}
240
241/// Everything said about a goal except its distance. `fine` places a goal
242/// on Link's screen by its offset from him in steps; otherwise by compass
243/// point. Look-alikes are judged on the coarse form (a pot one tile from
244/// another is the same choice), and the question carries the fine one.
245fn describe(options: &[GoalOption], i: usize, here: &Situation, fine: bool) -> String {
246    let option = &options[i];
247    let (verb, purpose) = verb(option);
248    let mut s = format!("{verb} {}{purpose}", short(option, here));
249    // Which way it lies from Link: only when both are in the same space (the
250    // bot keeps indoors and outdoors in different coordinate ranges).
251    if option.indoors == here.link_indoors {
252        let near = if fine && on_links_screen(option, here) { offset(here.link, option.at) } else { bearing(here.link, option.at).map(|b| format!("{b} of Link")) };
253        match near {
254            Some(b) => s.push_str(&format!(", {b}")),
255            None if on_links_screen(option, here) => s.push_str(", right beside Link"),
256            None => {}
257        }
258    }
259    if !on_links_screen(option, here) && !option.via.is_empty() {
260        let dir = compass_of(option.via_direction);
261        s.push_str(&format!(", reached by leaving this screen through its {}", exit_phrase(&option.via, dir)));
262        match option.screens_on_path {
263            0 | 1 => {}
264            n => s.push_str(&format!(" and crossing {} screens", n)),
265        }
266    }
267    if option.kind == "EXPLORE" {
268        s.push_str(if option.adjacent_known { ", to somewhere already mapped" } else { ", to somewhere never visited" });
269    }
270    if !option.item.is_empty() && option.kind != "ITEM" {
271        s.push_str(&format!("; the bot expects it holds the {}", option.item.to_lowercase()));
272    }
273    let ways: Vec<String> = (0..options.len())
274        .filter(|&j| j != i && option.on_way_to & (1 << j) != 0)
275        .take(2)
276        .map(|j| short(&options[j], here))
277        .collect();
278    if !ways.is_empty() {
279        s.push_str(&format!("; it is on the way to {}", ways.join(" and to ")));
280    }
281    match option.attempts {
282        0 => {}
283        1 => s.push_str("; already tried once and failed"),
284        n => s.push_str(&format!("; already tried {n} times and failed")),
285    }
286    if !option.needs.is_empty() && option.needs != "[Any]" {
287        let needs = option.needs.trim_matches(|c| c == '[' || c == ']').to_lowercase().replace('_', " ");
288        s.push_str(&format!("; it needs the {needs}, which Link has"));
289    }
290    s
291}
292
293/// The bot's path cost in Link-sized steps (16 pixels).
294pub fn steps(option: &GoalOption) -> i32 {
295    (option.distance.max(0) + 8) / 16
296}
297
298fn distance_clause(steps: i32) -> String {
299    match steps {
300        0 => ". It is right beside Link.".to_owned(),
301        1 => ". It is about 1 step away.".to_owned(),
302        n => format!(". It is about {n} steps away."),
303    }
304}
305
306/// One option as Jev sees it: its sentence, and the bot's goals it stands
307/// for (indices into the offered goals; the first is the one pursued if it is
308/// chosen, and is always the lowest index, so upstream's pick stays 0).
309#[derive(Clone, Debug, PartialEq, Eq)]
310pub struct Offer {
311    pub sentence: String,
312    pub goals: Vec<usize>,
313}
314
315/// The offered goals as distinct options. Goals that are the same kind of
316/// thing on the same screen, said identically but for distance, collapse
317/// into one offer REGARDLESS of how far apart their distances are: the
318/// planner's own scoring already weighs distance, so asking Jev to break a
319/// tie between two same-kind, same-place goals spends a question on
320/// something the scorer already knows (frame 14674, two pots 27 and 45
321/// steps apart, asked anyway - the same kind of goal, so nothing but
322/// distance separated them, and distance is not Jev's to weigh). The
323/// group's own representative (`goals[0]`, the one pursued if this offer is
324/// picked) is always the NEAREST of the merged goals, kept up to date as
325/// each candidate streams in - "not asked: same kind, nearest taken". The
326/// result's sentences are still pairwise different.
327pub fn offers(options: &[GoalOption], here: &Situation) -> Vec<Offer> {
328    struct Group {
329        description: String,
330        screen_id: u16,
331        steps: i32,
332        goals: Vec<usize>,
333    }
334    let mut groups: Vec<Group> = Vec::new();
335    for i in 0..options.len() {
336        let description = describe(options, i, here, false);
337        let steps = steps(&options[i]);
338        let screen_id = options[i].screen_id;
339        match groups.iter_mut().find(|g| g.description == description && g.screen_id == screen_id) {
340            Some(g) => {
341                g.goals.push(i);
342                if steps < g.steps {
343                    g.steps = steps;
344                    let pos = g.goals.len() - 1;
345                    g.goals.swap(0, pos);
346                }
347            }
348            None => groups.push(Group { description, screen_id, steps, goals: vec![i] }),
349        }
350    }
351    let mut out: Vec<Offer> = groups
352        .iter()
353        .map(|g| Offer {
354            sentence: format!("{}{}", describe(options, g.goals[0], here, true), distance_clause(g.steps)),
355            goals: g.goals.clone(),
356        })
357        .collect();
358    // Same coarse words and same screen were merged above regardless of
359    // distance, and the fine words only add to the coarse ones, so equal
360    // sentences can only be the same description on two different screens
361    // the words cannot name (two unnamed rooms): name them by the bot's own
362    // screen number.
363    let clashing: Vec<usize> = (0..out.len())
364        .filter(|&i| out.iter().enumerate().any(|(j, o)| j != i && o.sentence == out[i].sentence))
365        .collect();
366    for i in clashing {
367        let id = options[out[i].goals[0]].screen_id;
368        out[i].sentence = format!("{} (It is on the bot's screen {id:#06x}.)", out[i].sentence.trim_end_matches('.'));
369    }
370    out
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    const YARD: &str = "Castle Yard 600,600 x 9ff,9ff";
378
379    fn here() -> Situation {
380        Situation {
381            link_screen: YARD.into(),
382            link_indoors: false,
383            has_sword: true,
384            link: (0x7f8, 0x832),
385            screen: ((0x600, 0x600), (0x9ff, 0x9ff)),
386        }
387    }
388
389    fn option(kind: &str, node: &str, screen: &str, at: (u16, u16), distance: i32) -> GoalOption {
390        GoalOption {
391            kind: kind.into(),
392            node: node.into(),
393            screen: screen.into(),
394            screen_id: 0x0606,
395            indoors: false,
396            direction: 0,
397            sprite_type: 0,
398            sprite_subtype: 0,
399            item: String::new(),
400            needs: "[Any]".into(),
401            score: distance,
402            distance,
403            attempts: 0,
404            adjacent_known: false,
405            at,
406            screen_bounds: ((0x600, 0x600), (0x9ff, 0x9ff)),
407            via: String::new(),
408            via_direction: 0,
409            screens_on_path: 0,
410            on_way_to: 0,
411        }
412    }
413
414    fn distinct(offers: &[Offer]) {
415        let set: std::collections::BTreeSet<_> = offers.iter().map(|o| &o.sentence).collect();
416        assert_eq!(set.len(), offers.len(), "{offers:#?}");
417    }
418
419    #[test]
420    fn screen_names_lose_their_bounds() {
421        assert_eq!(screen_name("Well Uncle ^ 0 6a00,a00 x 6bff,aff").as_deref(), Some("Well Uncle"));
422        assert_eq!(screen_name("Castle Yard 600,600 x 9ff,9ff").as_deref(), Some("Castle Yard"));
423        assert_eq!(screen_name("HC First Key ^ 5200,e00 x 53ff,eff").as_deref(), Some("Hyrule Castle First Key"));
424        assert_eq!(screen_name("0x0c50 v 0 (20480 0x5000, 3072 0x0c00) x (20991"), None);
425    }
426
427    /// The question in `run/window/jev-5.png`: two pots side by side, 62 steps
428    /// away, which read identically. They are one choice, and with nothing
429    /// else on offer there is no question at all.
430    #[test]
431    fn two_pots_side_by_side_are_one_choice() {
432        let a = option("PICKUP", "pot 0x7e0,0x6f0", YARD, (0x7e0, 0x6f0), 990);
433        let b = option("PICKUP", "pot 0x7f0,0x6f0", YARD, (0x7f0, 0x6f0), 1000);
434        let offers = offers(&[a, b], &here());
435        assert_eq!(offers.len(), 1, "{offers:#?}");
436        assert_eq!(offers[0].goals, vec![0, 1]);
437        assert!(offers[0].sentence.starts_with("Lift the pot in the north side of this screen"), "{}", offers[0].sentence);
438        assert!(offers[0].sentence.contains("north of Link"), "{}", offers[0].sentence);
439    }
440
441    #[test]
442    fn pots_in_different_places_are_told_apart() {
443        let a = option("PICKUP", "pot a", YARD, (0x640, 0x640), 1000);
444        let b = option("PICKUP", "pot b", YARD, (0x9c0, 0x640), 1000);
445        let offers = offers(&[a, b], &here());
446        assert_eq!(offers.len(), 2);
447        assert!(offers[0].sentence.contains("north-west corner") && offers[1].sentence.contains("north-east corner"), "{offers:#?}");
448        distinct(&offers);
449    }
450
451    #[test]
452    fn pots_close_together_are_placed_by_steps() {
453        let a = option("PICKUP", "pot a", YARD, (0x7c0, 0x800), 300);
454        let b = option("PICKUP", "pot b", YARD, (0x790, 0x820), 500);
455        let offers = offers(&[a, b], &here());
456        assert_eq!(offers.len(), 2);
457        assert!(offers[0].sentence.contains("3 steps west and 3 steps north of Link"), "{}", offers[0].sentence);
458        assert!(offers[1].sentence.contains("6 steps west and 1 step north of Link"), "{}", offers[1].sentence);
459    }
460
461    #[test]
462    fn exits_are_named_by_kind() {
463        assert_eq!(exit_phrase("door U 0x84 DOOR|NODE NORM", Some("north")), "north door");
464        assert_eq!(exit_phrase("door 0x3f", None), "entrance");
465        assert_eq!(exit_phrase("0x148 U 1", Some("north")), "north edge");
466        assert_eq!(exit_phrase("stairs U 0x1f", Some("north")), "stairs up");
467        assert_eq!(exit_phrase("hole 0x05", None), "hole in the ground");
468    }
469
470    /// The scorer already weighs distance, so two same-kind, same-place
471    /// goals collapse to one offer no matter HOW far apart their distances
472    /// are - only a different kind or place is worth a question. Order
473    /// doesn't matter: the nearer one is always the representative.
474    #[test]
475    fn same_place_far_apart_in_distance_is_still_collapsed_and_the_nearer_is_taken() {
476        let a = option("PICKUP", "pot a", YARD, (0x640, 0x640), 900);
477        let b = option("PICKUP", "pot b", YARD, (0x650, 0x650), 200);
478        let offers = offers(&[a, b], &here());
479        assert_eq!(offers.len(), 1, "{offers:#?}");
480        assert_eq!(offers[0].goals, vec![1, 0], "the nearer goal (b, index 1) must be the representative");
481    }
482
483    #[test]
484    fn two_unnamed_rooms_are_named_by_number() {
485        let mut a = option("CHEST", "chest 0", "0x0c50 v 0 (1", (0x5100, 0xc80), 3000);
486        let mut b = option("CHEST", "chest 0", "0x0c52 v 0 (1", (0x5100, 0xc80), 3000);
487        for (o, id) in [(&mut a, 0x0c50), (&mut b, 0x0c52)] {
488            o.indoors = true;
489            o.screen_id = id;
490            o.screen_bounds = ((0x5000, 0xc00), (0x51ff, 0xcff));
491        }
492        let offers = offers(&[a, b], &here());
493        assert_eq!(offers.len(), 2, "{offers:#?}");
494        distinct(&offers);
495        assert!(offers[1].sentence.contains("0x0c52"), "{}", offers[1].sentence);
496    }
497
498    #[test]
499    fn sentences_say_what_where_and_the_way() {
500        let mut uncle = option("NPC", "sprite 0x73.0x100 TALK|NODE", "Well Uncle ^ 0 6a00,a00 x 6bff,aff", (0x6b00, 0xa80), 3000);
501        uncle.indoors = true;
502        uncle.sprite_type = 0x73;
503        uncle.sprite_subtype = 0x100;
504        uncle.item = "SWORD".into();
505        uncle.screen_id = 0x0a6a;
506        uncle.screen_bounds = ((0x6a00, 0xa00), (0x6bff, 0xaff));
507        uncle.via = "door U 0x84 DOOR|NODE NORM".into();
508        uncle.via_direction = 1;
509        uncle.screens_on_path = 3;
510        let mut door = option("EXPLORE", "door U 0x84 DOOR|NODE NORM", YARD, (0x800, 0x610), 700);
511        door.direction = 1;
512        door.attempts = 2;
513        door.on_way_to = 0b1;
514        let said = offers(&[uncle, door], &here());
515        let s0 = &said[0].sentence;
516        assert!(s0.starts_with("Talk to Link's wounded uncle in the"), "{s0}");
517        assert!(s0.contains("of Well Uncle") && s0.contains("sword"), "{s0}");
518        assert!(s0.contains("leaving this screen through its north door and crossing 3 screens"), "{s0}");
519        let s1 = &said[1].sentence;
520        assert!(s1.starts_with("Go through the north door in the north side of this screen, 34 steps north of Link"), "{s1}");
521        assert!(s1.contains("on the way to Link's wounded uncle") && s1.contains("tried 2 times"), "{s1}");
522        distinct(&said);
523    }
524
525    /// Every question the window and the headless runs have logged: the
526    /// options reconstructed from the log cannot be, but the rule can - no two
527    /// offers built from any set of goals share a sentence. Exercised over a
528    /// grid of look-alike goals.
529    #[test]
530    fn no_two_offers_ever_read_alike() {
531        let kinds = [("PICKUP", "pot x"), ("PICKUP", "bush x"), ("CHEST", "chest 0"), ("EXPLORE", "door U 0x84")];
532        let mut all = Vec::new();
533        for (k, (kind, node)) in kinds.iter().enumerate() {
534            for d in [100, 104, 500, 520, 2000] {
535                for (x, y) in [(0x640u16, 0x640u16), (0x648, 0x640), (0x800, 0x800)] {
536                    let mut o = option(kind, node, YARD, (x, y), d);
537                    o.screen_id = 0x0606 + (k as u16 % 2);
538                    all.push(o);
539                }
540            }
541        }
542        for window in all.windows(6) {
543            distinct(&offers(window, &here()));
544        }
545        for chunk in all.chunks(6) {
546            distinct(&offers(chunk, &here()));
547        }
548    }
549}