├── .gitignore ├── MANIFEST.in ├── SConstruct ├── native └── random.c ├── noobhack ├── __init__.py ├── game │ ├── __init__.py │ ├── dungeon.py │ ├── events.py │ ├── graphics.py │ ├── intrinsics.py │ ├── manager.py │ ├── mapping.py │ ├── player.py │ ├── plugins │ │ └── hp_color │ │ │ └── __init__.py │ ├── save.py │ ├── shops.py │ ├── sounds.py │ └── status.py ├── plugins.py ├── process.py ├── proxy.py ├── telnet.py └── ui │ ├── __init__.py │ ├── common.py │ ├── game.py │ ├── helper.py │ └── minimap.py ├── readme.md ├── requirements.txt ├── scripts └── noobhack ├── setup.py ├── tests ├── __init__.py ├── game │ ├── test_branch.py │ ├── test_buy_id.py │ ├── test_dungeon.py │ ├── test_level.py │ ├── test_manager.py │ ├── test_mapping.py │ ├── test_player.py │ └── test_sell_id.py ├── plugins │ └── test_hp_color.py ├── ui │ ├── test_curses_interface.py │ ├── test_drawing_individual_branches.py │ ├── test_drawing_individual_levels.py │ └── test_drawing_multiple_branches.py └── utils.py └── todo.md /.gitignore: -------------------------------------------------------------------------------- 1 | MANIFEST 2 | dist 3 | build 4 | *.pyc 5 | *.swp 6 | *.dblite 7 | *.os 8 | *.dylib 9 | site/ 10 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | -------------------------------------------------------------------------------- /SConstruct: -------------------------------------------------------------------------------- 1 | env = Environment() 2 | env.SharedLibrary(target="setrandom", source=["native/random.c"]) 3 | 4 | # This builds the shared library. To load it, do something like... 5 | # $ export DYLD_INSERT_LIBRARIES=`pwd`/libsetrandom.dylib 6 | # $ export DYLD_FORCE_FLAT_NAMESPACE=1 7 | # $ nethack 8 | -------------------------------------------------------------------------------- /native/random.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | static long (*real_random)(void) = NULL; 6 | 7 | static void __random_init(void) 8 | { 9 | real_random = dlsym(RTLD_NEXT, "random"); 10 | 11 | if (NULL == real_random) 12 | { 13 | fprintf(stderr, "Error in `dlsym`: %s\n", dlerror()); 14 | exit(0); 15 | } 16 | 17 | srandom(1); 18 | } 19 | 20 | long random(void) 21 | { 22 | if (NULL == real_random) 23 | { 24 | __random_init(); 25 | } 26 | 27 | return real_random(); 28 | } 29 | -------------------------------------------------------------------------------- /noobhack/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samfoo/noobhack/ffb4901202ba1c6493edb411856b70c3ec78a53a/noobhack/__init__.py -------------------------------------------------------------------------------- /noobhack/game/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samfoo/noobhack/ffb4901202ba1c6493edb411856b70c3ec78a53a/noobhack/game/__init__.py -------------------------------------------------------------------------------- /noobhack/game/dungeon.py: -------------------------------------------------------------------------------- 1 | """ 2 | I'm chock full of classes and methods and constants and whatnot that keep track 3 | of the state of the dungeon while a game is being played. That is to say: which 4 | levels are where, what features the levels have, where it's entrances and exits 5 | are, etc. 6 | """ 7 | 8 | import re 9 | 10 | from noobhack.game import shops 11 | 12 | from noobhack.game.mapping import Map 13 | from noobhack.game.mapping import Level 14 | 15 | messages = { 16 | "trap-door": set(( 17 | "A trap door opens up under you!", 18 | "Air currents pull you down into a hole!", 19 | "There's a gaping hole under you!", 20 | )), 21 | "level-teleport": set(( 22 | "You rise up, through the ceiling!", 23 | "You dig a hole through the floor. You fall through...", 24 | "You activated a magic portal!", 25 | )), 26 | } 27 | 28 | def looks_like_sokoban(display): 29 | """ 30 | Sokoban is a lot easier than the mines. There's no teleporting and we know 31 | exactly what the first level looks like (though there are two variations 32 | and it's all revealed at once. 33 | 34 | Easy peasy. 35 | """ 36 | 37 | first = [ 38 | "--\\^\\| \\|.0...\\|", 39 | "\\|\\^-----.0...\\|", 40 | "\\|..\\^\\^\\^\\^0.0..\\|", 41 | "\\|..----------", 42 | "----", 43 | ] 44 | 45 | second = [ 46 | "\\|\\^\\| \\|......\\|", 47 | "\\|\\^------......\\|", 48 | "\\|..\\^\\^\\^\\^0000...\\|", 49 | "\\|\\.\\.-----......\\|", 50 | "---- --------", 51 | ] 52 | 53 | def identify(pattern): 54 | i = 0 55 | for j in xrange(len(display)): 56 | line = display[j].strip() 57 | if re.match(pattern[i], line) is not None: 58 | if i == len(pattern) - 1: 59 | # Found the last one, that means we're home free. 60 | return True 61 | else: 62 | # Found this one, but it's not the last one. 63 | i += 1 64 | return False 65 | 66 | sokoban = identify(first) 67 | if not sokoban: 68 | sokoban = identify(second) 69 | 70 | return sokoban 71 | 72 | def looks_like_mines(display): 73 | """ 74 | Gnomish Mines: 75 | 76 | Since we don't get a message about being in the mines, we have to 77 | guess whether we're in the mines or not. There are some features 78 | unique to the mines that we can use to make a pretty educated 79 | guess that we're there. Easiest is that the walls are typically irregular 80 | in the mines: 81 | 82 | e.g. 83 | 84 | -- or -- or -- or -- 85 | -- -- -- -- 86 | 87 | Would indicate that we're in the mines. The other thing that could indicate 88 | that it's the mines is passageways that are only one square wide. 89 | 90 | e.g. 91 | or - 92 | |.| . 93 | - 94 | """ 95 | 96 | def indices(row): 97 | """ 98 | Find the indices of all double dashes in the string. 99 | """ 100 | found = [] 101 | i = 0 102 | try: 103 | while True: 104 | occurance = row.index("--", i) 105 | found.append(occurance) 106 | i = occurance + 1 107 | except ValueError: 108 | pass 109 | 110 | return found 111 | 112 | def mines(first, second): 113 | """ 114 | Look at adjacent rows and see if the have the right pattern shapes to 115 | be what might be the mines. 116 | """ 117 | for index in first: 118 | for other_index in second: 119 | if index == other_index + 1 or \ 120 | other_index == index + 1 or \ 121 | index == other_index + 2 or \ 122 | other_index == index + 2: 123 | return True 124 | return False 125 | 126 | scanned = [indices(row) for row in display] 127 | for i in xrange(len(scanned)): 128 | if i + 1 == len(scanned): 129 | break 130 | above, below = scanned[i], scanned[i+1] 131 | if mines(above, below): 132 | return True 133 | 134 | for column in ["".join(c).strip() for c in zip(*display)]: 135 | if column.find("-.-") > -1: 136 | return True 137 | 138 | return False 139 | 140 | class Dungeon: 141 | """ 142 | The dungeon keeps track of various dungeon states that are helpful to know. 143 | It remembers where shops are, remembers where it heard sounds and what they 144 | mean, and probably some other stuff that I'll think of in the future. 145 | """ 146 | 147 | def __init__(self, events): 148 | self.graph = Map(Level(1), 0, 0) 149 | self.level = 1 150 | self.went_through_lvl_tel = False 151 | self.events = events 152 | 153 | def __getstate__(self): 154 | d = self.__dict__.copy() 155 | del d['events'] 156 | return d 157 | 158 | def listen(self): 159 | self.events.listen("level-changed", self._level_changed) 160 | self.events.listen("branch-changed", self._branch_changed) 161 | self.events.listen("feature-found", self._feature_found) 162 | self.events.listen("shop-entered", self._shop_entered) 163 | self.events.listen("level-teleported", self._level_teleported) 164 | self.events.listen("trap-door-fell", self._level_teleported) 165 | self.events.listen("moved", self._moved) 166 | 167 | def _moved(self, _, cursor): 168 | self.graph.move(*cursor) 169 | 170 | def _shop_entered(self, _, shop_type): 171 | if "shop" not in self.current_level().features: 172 | self.current_level().features.add("shop") 173 | self.current_level().shops.add(shops.types[shop_type]) 174 | 175 | def _branch_changed(self, _, branch): 176 | # I'm really reluctant to put logic in here, beyond just a basic event 177 | # handler. However, until Brain is refactored into some more meaningful 178 | # structure, there's a couple edge cases where a level can be detected 179 | # as "mines" even though it's clearly not the mines. 180 | # 181 | # Specifically: When in the upper levels of sokoban and traveling 182 | # downward. Mines obviously only exists off of "main" or "not sure", it 183 | # can never come out of "sokoban". Enforcing that here is the easiest 184 | # way to fix weird branching craziness. 185 | if branch == "mines" and \ 186 | self.current_level().branch not in ["main", "not sure"]: 187 | pass 188 | else: 189 | self.graph.change_branch_to(branch) 190 | 191 | def _feature_found(self, _, feature): 192 | self.current_level().features.add(feature) 193 | 194 | def _level_teleported(self, _): 195 | self.went_through_lvl_tel = True 196 | 197 | def _level_changed(self, _, level, from_pos, to_pos): 198 | if self.level == level: 199 | # This seems like it's probably an error. The brain, or whoever is 200 | # doing the even dispatching should know not to dispatch a level 201 | # change event when, in fact, we clearly have not changed levels. 202 | return 203 | 204 | if abs(self.level - level) > 1 or self.went_through_lvl_tel: 205 | self.graph.travel_by_teleport(level, to_pos) 206 | self.went_through_lvl_tel = False 207 | else: 208 | self.graph.travel_by_stairs(level, to_pos) 209 | 210 | # Update our current position 211 | self.level = level 212 | 213 | def current_level(self): 214 | """ Return the level that the player is currently on """ 215 | return self.graph.current 216 | -------------------------------------------------------------------------------- /noobhack/game/events.py: -------------------------------------------------------------------------------- 1 | class Dispatcher: 2 | def __init__(self): 3 | self.listeners = {} 4 | 5 | def listen(self, event, function): 6 | if not self.listeners.has_key(event): 7 | self.listeners[event] = set() 8 | 9 | self.listeners[event].add(function) 10 | 11 | def dispatch(self, event, *args): 12 | for listener in self.listeners.get(event, []): 13 | listener(event, *args) 14 | -------------------------------------------------------------------------------- /noobhack/game/graphics.py: -------------------------------------------------------------------------------- 1 | ibm = dict( 2 | zip( 3 | (ord(code_point) for code_point in 4 | # Normal IBMgraphics... 5 | u'\u2502\u2500\u250c\u2510\u2514\u2518\u253c\u2534\u252c\u2524\u251c' + 6 | u'\u2591\u2592\u2261\xb1\u2320\u2248\xb7\u25a0' + 7 | 8 | # Rogue level IBMgraphics... 9 | u'\u2551\u2550\u2554\u2557\u255a\u255d\u256c\u2569\u2566\u2563\u2560' + 10 | u'\u263a\u2666\u2663\u2640\u266b\u263c\u2191[\xa1\u2592\u2593\u03c4' + 11 | u'\u2261\xb7' 12 | ), 13 | # And... the normal way we expect them... 14 | u"|--------||####{}.||-----+--||@^:,?*)]!##/%.") 15 | ) 16 | -------------------------------------------------------------------------------- /noobhack/game/intrinsics.py: -------------------------------------------------------------------------------- 1 | messages = { 2 | "Warning": { 3 | "You feel sensitive!":True, 4 | "You feel less sensitive!":False, 5 | }, 6 | "Shock resistance": { 7 | "Your health currently feels amplified!":True, 8 | "You feel insulated!":True, 9 | "You are shock resistant":True, 10 | "You feel grounded in reality.":True, 11 | "You feel conductive":False 12 | }, 13 | "Fire resistance": { 14 | "You be chillin'.":True, 15 | "You feel cool!":True, 16 | "You are fire resistant":True, 17 | "You feel a momentary chill.":True, 18 | "You feel warmer!":False 19 | }, 20 | "Cold resistance": { 21 | "You are cold resistant":True, 22 | "You feel warm!":True, 23 | "You feel full of hot air.":True, 24 | "You feel cooler!":False 25 | }, 26 | "Disintegration resist.": { 27 | "You are disintegration-resistant":True, 28 | "You feel very firm.":True, 29 | "You feel totally together, man.":True 30 | }, 31 | "Poison resistance": { 32 | "You are poison resistant":True, 33 | "You feel( especially)? (healthy)|(hardy)":True, 34 | "You feel a little sick!":False 35 | }, 36 | "Sleep resistance": { 37 | "You are sleep resistant":True, 38 | "You feel( wide)? awake":True, 39 | "You feel tired!":False 40 | }, 41 | "Aggravate monster": { 42 | "You feel that monsters are aware of your presence":True, 43 | "You feel less attractive":False 44 | }, 45 | "Protection": { 46 | "You feel vulnerable":False 47 | }, 48 | "Invisible": { 49 | "You feel hidden!":True 50 | }, 51 | "See invisible": { 52 | "You see an image of someone stalking you.":True, 53 | "You feel transparent":True, 54 | "You feel very self-conscious":True, 55 | "Your vision becomes clear":True 56 | }, 57 | "Searching": { 58 | "You feel perceptive!":True 59 | }, 60 | "Speed": { 61 | "You feel quick!":True, 62 | "You feel yourself speed up.":True, # speed boots put on (want this here?)! 63 | "You feel yourself slow down.":False, # speed boots removed (want this here?)! 64 | "You feel slow!":False 65 | }, 66 | "Teleportitis": { 67 | "You feel very jumpy":True, 68 | "You feel diffuse":True, 69 | "You feel less jumpy":False 70 | }, 71 | "Teleport control": { 72 | "You feel in control of yourself":True, 73 | "You feel controlled!":True, 74 | "You feel centered in your personal space":True, 75 | "You feel less jumpy":False 76 | }, 77 | "Telepathy": { 78 | "You feel in touch with the cosmos":True, 79 | "You feel a strange mental acuity":True 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /noobhack/game/manager.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from noobhack.game.graphics import ibm 4 | from noobhack.game import shops, status, intrinsics, sounds, dungeon, save, player 5 | 6 | class Manager: 7 | def __init__(self, term, output_proxy, input_proxy, events): 8 | self.term = term 9 | output_proxy.register(self.process) 10 | 11 | self.last_move = None 12 | self.turn = 0 13 | self.dlvl = 0 14 | self.prev_cursor = (0, 0) 15 | self.events = events 16 | 17 | self.stats = {"St": None, "Dx": None, "Co": None, "In": None, 18 | "Wi": None, "Ch": None} 19 | 20 | self.player = player.Player(self.events) 21 | self.dungeon = dungeon.Dungeon(self.events) 22 | 23 | self.player.listen() 24 | self.dungeon.listen() 25 | 26 | def _level_feature_events(self, data): 27 | match = re.search("There is an altar to .* \\((\\w+)\\) here.", data) 28 | if match is not None: 29 | self.events.dispatch("feature-found", "altar (%s)" % match.groups()[0]) 30 | 31 | match = re.search("a (large box)|(chest).", data) 32 | if match is not None: 33 | self.events.dispatch("feature-found", "chest") 34 | 35 | for feature, messages in sounds.messages.iteritems(): 36 | for message in messages: 37 | match = re.search(message, data, re.I | re.M) 38 | if match is not None: 39 | self.events.dispatch("feature-found", feature) 40 | 41 | def _intrinsic_events(self, data): 42 | for name, messages in intrinsics.messages.iteritems(): 43 | for message, value in messages.iteritems(): 44 | match = re.search(message, data, re.I | re.M) 45 | if match is not None: 46 | self.events.dispatch("intrinsic-changed", name, value) 47 | 48 | def _status_events(self, data): 49 | """ 50 | Check the output stream for any messages that might indicate a status 51 | change event. If such a message is found, then dispatch a status event 52 | with the name of the status and it's value (either True or False). 53 | """ 54 | 55 | for name, messages in status.messages.iteritems(): 56 | for message, value in messages.iteritems(): 57 | match = re.search(message, data, re.I | re.M) 58 | if match is not None: 59 | self.events.dispatch("status-changed", name, value) 60 | 61 | def _content(self): 62 | return [line.translate(ibm) for line 63 | in self.term.display 64 | if len(line.strip()) > 0] 65 | 66 | def _get_last_line(self): 67 | # The last line in the display is the one that contains the turn 68 | # information. 69 | for i in xrange(len(self.term.display)-1, -1, -1): 70 | line = self.term.display[i].translate(ibm).strip() 71 | if len(line) > 0: 72 | break 73 | return line 74 | 75 | def _branch_change_event(self, data): 76 | level = [line.translate(ibm) for line in self.term.display] 77 | if 6 <= self.dlvl <= 10 and dungeon.looks_like_sokoban(level): 78 | # If the player arrived at a level that looks like sokoban, she's 79 | # definitely in sokoban. 80 | self.events.dispatch("branch-changed", "sokoban") 81 | elif self.last_move == "down" and 3 <= self.dlvl <= 6 and \ 82 | dungeon.looks_like_mines(level): 83 | # The only entrace to the mines is between levels 3 and 5 and 84 | # the player has to have been traveling down to get there. Also 85 | # count it if the dlvl didn't change, because it *might* take 86 | # a couple turns to identify the mines. Sokoban, by it's nature 87 | # however is instantly identifiable. 88 | self.events.dispatch("branch-changed", "mines") 89 | 90 | def _level_change_event(self, data): 91 | line = self._get_last_line() 92 | match = re.search("Dlvl:(\\d+)", line) 93 | if match is not None: 94 | dlvl = int(match.groups()[0]) 95 | if dlvl != self.dlvl: 96 | if dlvl < self.dlvl: 97 | self.last_move = "up" 98 | elif dlvl > self.dlvl: 99 | self.last_move = "down" 100 | 101 | self.dlvl = dlvl 102 | self.events.dispatch( 103 | "level-changed", dlvl, 104 | self.prev_cursor, self.term.cursor() 105 | ) 106 | return 107 | 108 | # Couldn't find the dlvl line... this means we're somewhere outside 109 | # of the dungeon. Either in the end game, ft. ludios or in your quest. 110 | match = re.search("Home (\\d+)", line) 111 | if match is not None: 112 | dlvl = int(match.groups()[0]) 113 | self.dlvl = dlvl + self.dlvl - 1 114 | if dlvl == 1: 115 | self.events.dispatch("branch-port", "quest") 116 | else: 117 | self.events.dispatch( 118 | "level-changed", self.dlvl, 119 | self.prev_cursor, self.term.cursor() 120 | ) 121 | return 122 | 123 | match = re.search("Fort Ludios", line) 124 | if match is not None: 125 | self.events.dispatch("branch-port", "ludios") 126 | return 127 | 128 | def _level_teleport_event(self, data): 129 | for message in dungeon.messages["level-teleport"]: 130 | match = re.search(message, data) 131 | if match is not None: 132 | self.events.dispatch("level-teleported") 133 | 134 | def _trap_door_event(self, data): 135 | for message in dungeon.messages["trap-door"]: 136 | match = re.search(message, data) 137 | if match is not None: 138 | self.events.dispatch("trap-door-fell") 139 | 140 | def _turn_change_event(self, data): 141 | """ 142 | Dispatch an even each time a turn advances. 143 | """ 144 | 145 | line = self._get_last_line() 146 | match = re.search("T:(\\d+)", line) 147 | if match is not None: 148 | turn = int(match.groups()[0]) 149 | if turn != self.turn: 150 | self.turn = turn 151 | self.events.dispatch("turn", self.turn) 152 | 153 | def _shop_entered_event(self, data): 154 | match = re.search(shops.entrance, data, re.I | re.M) 155 | if match is not None: 156 | shop_type = match.groups()[1] 157 | for t, _ in shops.types.iteritems(): 158 | match = re.search(t, shop_type, re.I) 159 | if match is not None: 160 | self.events.dispatch("shop-entered", t) 161 | 162 | def _move_event(self, data): 163 | if self.cursor_is_on_player(): 164 | self.events.dispatch("moved", self.term.cursor()) 165 | 166 | def cursor_is_on_player(self): 167 | """ Return whether or not the cursor is currently on the player. """ 168 | 169 | first = self.term.display[0].translate(ibm) 170 | return \ 171 | "To what position do you want to be teleported?" not in first and \ 172 | "Please move the cursor to an unknown object." not in first and \ 173 | self.char_at(*self.term.cursor()) != " " 174 | 175 | def char_at(self, x, y): 176 | """ Return the glyph at the specified coordinates """ 177 | row = self.term.display[y].translate(ibm) 178 | col = row[x] 179 | 180 | return col 181 | 182 | def save(self, save_file): 183 | save.save(save_file, self.player, self.dungeon) 184 | 185 | def load(self, save_file): 186 | self.player, self.dungeon = save.load(save_file) 187 | self.player.events = self.events 188 | self.dungeon.events = self.events 189 | 190 | self.player.listen() 191 | self.dungeon.listen() 192 | 193 | def _parse_stats(self, data): 194 | results = {} 195 | for st in ["Ch", "St", "Dx", "Co", "Wi", "In"]: 196 | match = re.search("%s:(\\d+)" % st, data) 197 | if match is not None: 198 | results[st] = int(match.groups()[0]) 199 | 200 | return results 201 | 202 | def _stats_changed_event(self, data): 203 | stats = self._parse_stats(data) 204 | dicts = [stats, self.stats] 205 | 206 | changes = dict(set.difference(*(set(d.iteritems()) for d in dicts))) 207 | 208 | if len(changes) > 0: 209 | self.events.dispatch( 210 | "stats-changed", 211 | changes 212 | ) 213 | 214 | def process(self, data): 215 | self._stats_changed_event(data) 216 | self._status_events(data) 217 | self._intrinsic_events(data) 218 | self._turn_change_event(data) 219 | self._trap_door_event(data) 220 | self._level_change_event(data) 221 | self._level_feature_events(data) 222 | self._branch_change_event(data) 223 | self._shop_entered_event(data) 224 | self._move_event(data) 225 | 226 | if "--More--" not in self.term.display[self.term.cursor()[1]]: 227 | self.prev_cursor = self.term.cursor() 228 | 229 | -------------------------------------------------------------------------------- /noobhack/game/mapping.py: -------------------------------------------------------------------------------- 1 | class Branch: 2 | """ 3 | Given a level, provide some methods to treat that level's dungeon 4 | branch as a distinct entity. Probably most importantly, Branch's are iters 5 | that iterate from the top (lowest dlvl) to the bottom (highest dlvl). 6 | """ 7 | 8 | def __init__(self, junction): 9 | self.junction = junction 10 | self.current = self.start = self.find_top(junction) 11 | 12 | def __iter__(self): 13 | return Branch(self.start) 14 | 15 | def __len__(self): 16 | return len([l for l in self]) 17 | 18 | def find_top(self, level): 19 | """ 20 | Given a level, find the uppermost parent that still belongs to the same 21 | branch 22 | """ 23 | 24 | ups = [l for l 25 | in level.stairs.values() 26 | if l.dlvl < level.dlvl 27 | and l.branch == level.branch] 28 | if len(ups) == 0: 29 | return level 30 | else: 31 | return self.find_top(ups[0]) 32 | 33 | def name(self): 34 | """ The name of the branch (e.g. main, mines, sokoban, etc) """ 35 | return self.start.branch 36 | 37 | def sub_branches(self): 38 | """ 39 | Return all branches that are connected to this one (whether they're 40 | parents or children) 41 | """ 42 | 43 | # TODO: Can a level have connections to more than one dungeon branch? 44 | roots = [l.branches()[0] for l 45 | in self 46 | if l.has_a_branch()] 47 | 48 | return [Branch(r) for r in roots] 49 | 50 | def next(self): 51 | # Go down the stairs (if they exist) to the next level below this one 52 | # on the same branch. 53 | if self.current == None: 54 | raise StopIteration 55 | else: 56 | current = self.current 57 | potential_nexts = [l for l 58 | in current.stairs.values() 59 | if l.branch == current.branch 60 | and l.dlvl > current.dlvl] 61 | if len(potential_nexts) > 0: 62 | self.current = potential_nexts[0] 63 | else: 64 | self.current = None 65 | 66 | return current 67 | 68 | class Level(object): 69 | """ 70 | A single dungeon level. This can be thought of as "mines, level 3", or 71 | "main, level 5". There can be multiple levels of the same dlvl, but 72 | they should generally be in different branches. Dungeons have stairs to 73 | other levels. 74 | 75 | Levels also are responsible for keeping track of various interesting things 76 | about themselves. What features they contain, breadcrumbs of what squares 77 | have been stepped on, etc. 78 | """ 79 | 80 | def __init__(self, dlvl, branch="main"): 81 | self.dlvl = dlvl 82 | self.branch = branch 83 | self.stairs = {} 84 | 85 | # Level features 86 | self.breadcrumbs = set() 87 | self.features = set() 88 | self.shops = set() 89 | 90 | def change_branch_to(self, branch): 91 | self.branch = branch 92 | if self.branch != "sokoban": 93 | links = self.stairs.values() 94 | for child in [c for c in links if c.dlvl > self.dlvl]: 95 | child.change_branch_to(branch) 96 | 97 | def add_stairs(self, level, position): 98 | self.stairs[position] = level 99 | 100 | def stairs_at(self, pos): 101 | return self.stairs.get(pos, None) 102 | 103 | def has_stairs_at(self, pos): 104 | return self.stairs_at(pos) != None 105 | 106 | def branches(self): 107 | return [l for l in self.stairs.values() if l.branch != self.branch] 108 | 109 | def has_a_branch(self): 110 | return any(l.branch != self.branch for l in self.stairs.values()) 111 | 112 | def is_a_junction(self): 113 | below = [l for l in self.stairs.values() if l.dlvl > self.dlvl] 114 | above = [l for l in self.stairs.values() if l.dlvl < self.dlvl] 115 | return len(below) > 1 or len(above) > 1 116 | 117 | class Map: 118 | def __init__(self, level, x, y): 119 | self.current = level 120 | self.levels = set([self.current]) 121 | self.location = (x, y) 122 | 123 | def move(self, x, y): 124 | self.location = (x, y) 125 | self.current.breadcrumbs.add((x, y)) 126 | 127 | def level_at(self, branch, dlvl): 128 | maybe_level = [l for l 129 | in self.levels 130 | if l.dlvl == dlvl 131 | and l.branch == branch] 132 | if len(maybe_level) == 1: 133 | return maybe_level[0] 134 | else: 135 | return None 136 | 137 | def change_branch_to(self, branch): 138 | peers = [l for l 139 | in self.levels 140 | if l.dlvl == self.current.dlvl 141 | and l != self.current 142 | and l.branch == "not sure"] 143 | 144 | assert len(peers) == 0 or len(peers) == 1 145 | for p in peers: p.change_branch_to("main") 146 | 147 | self.current.change_branch_to(branch) 148 | 149 | def is_there_a_level_at(self, branch, dlvl): 150 | return self.level_at(branch, dlvl) is not None 151 | 152 | def _link(self, new_level, pos): 153 | self.current.add_stairs(new_level, self.location) 154 | new_level.add_stairs(self.current, pos) 155 | 156 | def _add(self, new_level, pos): 157 | self.levels.add(new_level) 158 | self._link(new_level, pos) 159 | 160 | def _handle_existing_level(self, to_dlvl, to_pos): 161 | has_stairs_to_other_lower = [l for l 162 | in self.current.stairs.values() 163 | if l.dlvl == to_dlvl] 164 | 165 | if len(has_stairs_to_other_lower) > 0: 166 | # If the existing level has stairs to it, and we're at a different 167 | # location than those stairs then the stairs at our current 168 | # location *must* be a different level. 169 | new_level = Level(to_dlvl, "not sure") 170 | self._add(new_level, to_pos) 171 | self.current = new_level 172 | else: 173 | # Otherwise, if there are no stairs to the lower level, we just 174 | # assume that the stairs we're presently at lead to it. 175 | existing_level = self.level_at(self.current.branch, to_dlvl) 176 | self._link(existing_level, to_pos) 177 | self.current = existing_level 178 | 179 | def main(self): 180 | return Branch([l for l 181 | in self.levels 182 | if l.dlvl == 1 and l.branch == "main"][0]) 183 | 184 | def branches(self): 185 | def group_min(groups, l): 186 | existing_min = groups.get(l.branch, l) 187 | if l.dlvl <= existing_min.dlvl: 188 | groups[l.branch] = l 189 | 190 | branch_roots = reduce(group_min, self.levels) 191 | return [Branch(root) for root in branch_roots] 192 | 193 | def travel_by_stairs(self, to_dlvl, to_pos): 194 | if self.current.has_stairs_at(self.location): 195 | # If the current level already has stairs at our current position, 196 | # then use them. 197 | self.current = self.current.stairs_at(self.location) 198 | elif self.is_there_a_level_at(self.current.branch, to_dlvl): 199 | # If there's a level in this branch at the dlvl that we're 200 | # traveling to, but there's no current stairs link, it means that 201 | # we just haven't traveled by stairs between those levels. Adding 202 | # the link is all that's necessary. 203 | self._handle_existing_level(to_dlvl, to_pos) 204 | else: 205 | # If the current level doesn't have stairs at our current position, 206 | # create a new level and attach them. 207 | new_level = Level(to_dlvl, self.current.branch) 208 | self._add(new_level, to_pos) 209 | self.current = new_level 210 | 211 | self.location = to_pos 212 | 213 | def travel_by_teleport(self, to_dlvl, to_pos): 214 | levels_in_current_branch = \ 215 | [l for l in self.levels if l.branch == self.current.branch] 216 | maybe_level = [l for l in levels_in_current_branch if l.dlvl == to_dlvl] 217 | if len(maybe_level) == 1: 218 | # We've already been to the level and can just set it as the 219 | # current and be done with it. 220 | self.current = maybe_level[0] 221 | else: 222 | # We haven't already been to the level and need to create it, but 223 | # leave it unlinked to anything as of yet. 224 | new_level = Level(to_dlvl, self.current.branch) 225 | self.current = new_level 226 | self.levels.add(new_level) 227 | -------------------------------------------------------------------------------- /noobhack/game/player.py: -------------------------------------------------------------------------------- 1 | class Player: 2 | """ 3 | The player keeps track of various player states that are helpful to know 4 | but either not displayed in the nethack UI or displayed with less 5 | information than we might be able to infer about it. 6 | """ 7 | 8 | def __init__(self, events): 9 | self.status = set() 10 | self.intrinsics = set() 11 | self.stats = {"St": None, "Dx": None, "Co": None, "In": None, 12 | "Wi": None, "Ch": None} 13 | 14 | self.events = events 15 | 16 | def sucker(self): 17 | """ 18 | Return whether or not the player is considered a 'sucker'. A level 14 19 | or lower tourists or anyone wearing a shirt with no armor or cloak over 20 | it. Confers a 33% penalty to the price of an object. Necessary when 21 | price identifying. 22 | """ 23 | return False 24 | 25 | def intelligence(self): 26 | return self.stats["In"] 27 | 28 | def charisma(self): 29 | return self.stats["Ch"] 30 | 31 | def strength(self): 32 | return self.stats["St"] 33 | 34 | def wisdom(self): 35 | return self.stats["Wi"] 36 | 37 | def dexterity(self): 38 | return self.stats["Dx"] 39 | 40 | def constitution(self): 41 | return self.stats["Co"] 42 | 43 | def __getstate__(self): 44 | d = self.__dict__.copy() 45 | del d['events'] 46 | return d 47 | 48 | def listen(self): 49 | self.events.listen("status-changed", self._status_changed) 50 | self.events.listen("intrinsic-changed", self._intrinsic_changed) 51 | self.events.listen("stats-changed", self._stats_changed) 52 | 53 | def _stats_changed(self, event, changes): 54 | self.stats = dict(self.stats.items() + changes.items()) 55 | 56 | def _intrinsic_changed(self, event, name, value): 57 | if name in self.intrinsics and value == False: 58 | self.intrinsics.remove(name) 59 | 60 | elif name not in self.intrinsics and value == True: 61 | self.intrinsics.add(name) 62 | 63 | def _status_changed(self, event, name, value): 64 | if name in self.status and value == False: 65 | self.status.remove(name) 66 | 67 | elif name not in self.status and value == True: 68 | self.status.add(name) 69 | -------------------------------------------------------------------------------- /noobhack/game/plugins/hp_color/__init__.py: -------------------------------------------------------------------------------- 1 | import re 2 | import locale 3 | 4 | ENCODING = locale.getpreferredencoding() 5 | 6 | def color_hp(row, col, length, color, term): 7 | for i in xrange(length): 8 | term.attributes[row][col+i] = color 9 | 10 | def color_for_ratio(ratio): 11 | bg = "default" 12 | 13 | if ratio <= 0.25: 14 | fg = "white" 15 | bg = "red" 16 | elif ratio <= 0.5: 17 | fg = "yellow" 18 | else: 19 | fg = "green" 20 | 21 | return (["bold"], fg, bg) 22 | 23 | def redraw(term): 24 | """ 25 | Highlight health depending on much much is left: 26 | 27 | * Less than 25% - red 28 | * Less than 50% - yellow 29 | * More than 50% - green 30 | """ 31 | 32 | for row in xrange(len(term.display)): 33 | row_str = term.display[row].encode(ENCODING) 34 | 35 | if "HP:" in row_str: 36 | match = re.search("HP:(\\d+)\\((\\d+)\\)", row_str) 37 | if match is not None: 38 | hp, hp_max = match.groups() 39 | ratio = float(hp) / float(hp_max) 40 | 41 | color = color_for_ratio(ratio) 42 | 43 | color_hp(row, 44 | match.start() + 3, 45 | match.end() - match.start() - 3, 46 | color, 47 | term) 48 | 49 | -------------------------------------------------------------------------------- /noobhack/game/save.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import cPickle as pickle 4 | 5 | def load(save_file): 6 | if os.path.exists(save_file): 7 | save = open(save_file, "r") 8 | try: 9 | return pickle.load(save) 10 | finally: 11 | save.close() 12 | else: 13 | raise RuntimeError( 14 | "NetHack is trying to restore a game file, but noobhack " + 15 | "doesn't have any memory of this game. While noobhack will " + 16 | "still work on a game it doesn't know anything about, there " + 17 | "will probably be errors. If you'd like to use noobhack, " + 18 | "run nethack and quit your current game, then restart " + 19 | "noobhack." 20 | ) 21 | 22 | def save(save_file, player, dungeon): 23 | save = open(save_file, "w") 24 | try: 25 | pickle.dump((player, dungeon), save) 26 | finally: 27 | save.close() 28 | 29 | def delete(save_file): 30 | if os.path.exists(save_file): 31 | os.remove(save_file) 32 | 33 | -------------------------------------------------------------------------------- /noobhack/game/shops.py: -------------------------------------------------------------------------------- 1 | entrance = "Welcome( again)? to [\\w ]+'s (.*)!" 2 | price = "[^(only)] (a|\\d+) (.*) \\(unpaid, (\\d+) zorkmids\\)\\." 3 | offer = "offers (\\d+) gold pieces for your (.*)" 4 | 5 | types = { 6 | "general": "random", 7 | "used armor dealership": "90/10 arm/weap", 8 | "second-hand bookstore": "90/10 scroll/books", 9 | "liquor emporium": "potions", 10 | "antique weapons outlet" : "90/10 weap/armor", 11 | "delicatessen": "83/14/3 food/drink/icebox", 12 | "jewelers": "85/10/5 ring/gems/amu", 13 | "quality apparel and accessories": "90/10 wand/misc", 14 | "hardware store": "tools", 15 | "rare books": "90/10 books/scrolls", 16 | "lighting store": "97/3 light/m.lamp" 17 | } 18 | 19 | amulets = set([ 20 | ("change", 150, 130, None), 21 | ("ESP", 150, 175, None), 22 | ("life saving", 150, 75, None), 23 | ("magical breathing", 150, 65, None), 24 | ("reflection", 150, 75, None), 25 | ("restful sleep", 150, 135, None), 26 | ("strangulation", 150, 135, None), 27 | ("unchanging", 150, 45, None), 28 | ("versus poison", 150, 165, None), 29 | ("imitation AoY", 0, 0, None), 30 | ("Yendor", 30000, 0, None), 31 | ]) 32 | 33 | weapons = { 34 | "dagger": set([ 35 | ("orcish dagger", 4, 12, "crude dagger"), 36 | ("dagger", 4, 30, None), 37 | ("silver dagger", 40, 3, None), 38 | ("athame", 4, 0, None), 39 | ("elven dagger", 4, 10, "runed dagger"), 40 | ]), 41 | "knife": set([ 42 | ("worm tooth", 2, 0, None), 43 | ("knife", 4, 20, None), 44 | ("shito", 4, 20, None), 45 | ("stiletto", 4, 5, None), 46 | ("scalpel", 6, 0, None), 47 | ("crysknife", 100, 0, None), 48 | ]), 49 | "axe": set([ 50 | ("axe", 8, 40, None), 51 | ("battle-axe", 40, 10, "double-headed axe"), 52 | ]), 53 | "short sword": set([ 54 | ("orcish short sword", 10, 3, "crude short sword"), 55 | ("short sword", 10, 8, None), 56 | ("wakizashi", 10, 8, None), 57 | ("dwarvish short sword", 10, 2, "broad short sword"), 58 | ("elvish short sword", 10, 2, "runed short sword"), 59 | ]), 60 | "broadsword": set([ 61 | ("broadsword", 10, 8, None), 62 | ("ninja-to", 10, 8, None), 63 | ("runesword", 300, 0, "runed broadsword"), 64 | ("elven broadsword", 10, 4, "runed broadsword"), 65 | ]), 66 | "long sword": set([ 67 | ("long sword", 15, 50, None), 68 | ("katana", 80, 4, "samurai sword"), 69 | ]), 70 | "two-handed sword": set([ 71 | ("two-handed sword", 50, 22, None), 72 | ("tsurugi", 500, 0, "long samurai sword"), 73 | ]), 74 | "club": set([ 75 | ("club", 3, 12, None), 76 | ("aklys", 3, 8, "thonged club"), 77 | ]), 78 | "flail": set([ 79 | ("flail", 4, 40, None), 80 | ("nunchaku", 4, 40, None), 81 | ("grappling hook", 50, 0, "iron hook"), 82 | ]), 83 | "polearm": set([ 84 | ("partisan", 10, 5, "vulgar polearm"), 85 | ("fauchard", 5, 6, "pole sickle"), 86 | ("glaive", 6, 8, "single-edged polearm"), 87 | ("naginata", 6, 8, "single-edged polearm"), 88 | ("bec-de-corbin", 8, 4, "beaked polearm"), 89 | ("spetum", 5, 5, "forked polearm"), 90 | ("lucern hammer", 7, 5, "pronged polearm"), 91 | ("guisarme", 5, 6, "pruning hook"), 92 | ("ranseur", 6, 5, "hilted polearm"), 93 | ("voulge", 5, 4, "pole cleaver"), 94 | ("bill-guisarme", 7, 4, "hooked polearm"), 95 | ("bardiche", 7, 4, "long poleaxe"), 96 | ("halberd", 10, 8, "angled poleaxe"), 97 | ]), 98 | "spear": set([ 99 | ("orcish", 3, 13, "crude spear"), 100 | ("spear", 3, 50, None), 101 | ("silver", 40, 2, None), 102 | ("elven", 3, 10, "runed spear"), 103 | ("dwarvish", 3, 12, "stout spear"), 104 | ]), 105 | "bow": set([ 106 | ("orcish", 60, 12, "crude bow"), 107 | ("bow", 60, 24, None), 108 | ("elven", 60, 12, "runed bow"), 109 | ("yumi", 60, 0, "long bow"), 110 | ]), 111 | "whip": set([ 112 | ("bullwhip", 4, 2, None), 113 | ("rubber hose", 3, 0, None), 114 | ]), 115 | } 116 | 117 | spellbooks = set([ 118 | ("blank paper", 0, 18, None), 119 | ("Book of the Dead", 10000, 0, None), 120 | ("force bolt", 100, 35, None), 121 | ("drain life", 200, 10, None), 122 | ("magic missile", 200, 45, None), 123 | ("cone of cold", 400, 10, None), 124 | ("fireball", 400, 20, None), 125 | ("finger of death", 700, 5, None), 126 | ("healing", 100, 40, None), 127 | ("cure blindness", 200, 25, None), 128 | ("cure sickness", 300, 32, None), 129 | ("extra healing", 300, 27, None), 130 | ("stone to flesh", 300, 15, None), 131 | ("restore ability", 400, 25, None), 132 | ("detect monsters", 100, 43, None), 133 | ("light", 100, 45, None), 134 | ("detect food", 200, 30, None), 135 | ("clairvoyance", 300, 15, None), 136 | ("detect unseen", 300, 20, None), 137 | ("identify", 300, 20, None), 138 | ("detect treasure", 400, 20, None), 139 | ("magic mapping", 500, 18, None), 140 | ("sleep", 100, 50, None), 141 | ("confuse monster", 200, 30, None), 142 | ("slow monster", 200, 30, None), 143 | ("cause fear", 300, 25, None), 144 | ("charm monster", 300, 20, None), 145 | ("protection", 100, 18, None), 146 | ("create monster", 200, 35, None), 147 | ("remove curse", 300, 25, None), 148 | ("create familiar", 600, 10, None), 149 | ("turn undead", 600, 16, None), 150 | ("jumping", 100, 20, None), 151 | ("haste self", 300, 33, None), 152 | ("invisibility", 400, 25, None), 153 | ("levitation", 400, 20, None), 154 | ("teleport away", 600, 15, None), 155 | ("knock", 100, 35, None), 156 | ("wizard lock", 200, 30, None), 157 | ("dig", 500, 20, None), 158 | ("polymorph", 600, 10, None), 159 | ("cancellation", 700, 15, None), 160 | ]) 161 | 162 | scrolls = set([ 163 | ("mail", 0, 0, "stamped"), 164 | ("identify", 20, 180, None), 165 | ("light", 50, 90, None), 166 | ("blank paper", 60, 28, "unlabeled"), 167 | ("enchant weapon", 60, 80, None), 168 | ("enchant armor", 80, 63, None), 169 | ("remove curse", 80, 65, None), 170 | ("confuse monster", 100, 53, None), 171 | ("destroy armor", 100, 45, None), 172 | ("fire", 100, 30, None), 173 | ("food detection", 100, 25, None), 174 | ("gold detection", 100, 33, None), 175 | ("magic mapping", 100, 45, None), 176 | ("scare monster", 100, 35, None), 177 | ("teleportation", 100, 55, None), 178 | ("amnesia", 200, 35, None), 179 | ("create monster", 200, 45, None), 180 | ("earth", 200, 18, None), 181 | ("taming", 200, 15, None), 182 | ("charging", 300, 15, None), 183 | ("genocide", 300, 15, None), 184 | ("punishment", 300, 15, None), 185 | ("stinking cloud", 300, 15, None), 186 | ]) 187 | 188 | potions = set([ 189 | ("booze", 50, 42, None), 190 | ("fruit juice", 50, 42, None), 191 | ("see invisible", 50, 42, None), 192 | ("sickness", 50, 42, None), 193 | ("confusion", 100, 42, None), 194 | ("extra healing", 100, 47, None), 195 | ("hallucination", 100, 40, None), 196 | ("healing", 100, 57, None), 197 | ("restore ability", 100, 40, None), 198 | ("sleeping", 100, 42, None), 199 | ("water", 100, 92, "clear"), 200 | ("blindness", 150, 40, None), 201 | ("gain energy", 150, 42, None), 202 | ("invisibility", 150, 40, None), 203 | ("monster detection", 150, 40, None), 204 | ("object detection", 150, 42, None), 205 | ("enlightenment", 200, 20, None), 206 | ("full healing", 200, 10, None), 207 | ("levitation", 200, 42, None), 208 | ("polymorph", 200, 10, None), 209 | ("speed", 200, 42, None), 210 | ("acid", 250, 10, None), 211 | ("oil", 250, 30, None), 212 | ("gain ability", 300, 42, None), 213 | ("gain level", 300, 20, None), 214 | ("paralysis", 300, 42, None), 215 | ]) 216 | 217 | rings = set([ 218 | ("meat ring", 5, 0, None), 219 | ("adornment",100, 1, None), 220 | ("hunger",100, 1, None), 221 | ("protection",100, 1, None), 222 | ("protection from shape changers", 100, 1, None), 223 | ("stealth", 100, 1, None), 224 | ("sustain ability", 100, 1, None), 225 | ("warning", 100, 1, None), 226 | ("aggravate monster", 150, 1, None), 227 | ("cold resistance", 150, 1, None), 228 | ("gain constitution",150, 1, None), 229 | ("gain strength",150, 1, None), 230 | ("increase accuracy",150, 1, None), 231 | ("increase damage",150, 1, None), 232 | ("invisibility", 150, 1, None), 233 | ("poison resistance", 150, 1, None), 234 | ("see invisible", 150, 1, None), 235 | ("shock resistance", 150, 1, None), 236 | ("fire resistance", 200, 1, None), 237 | ("free action", 200, 1, None), 238 | ("levitation", 200, 1, None), 239 | ("regeneration", 200, 1, None), 240 | ("searching", 200, 1, None), 241 | ("slow digestion", 200, 1, None), 242 | ("teleportation", 200, 1, None), 243 | ("conflict", 300, 1, None), 244 | ("polymorph", 300, 1, None), 245 | ("polymorph control", 300, 1, None), 246 | ("teleport control", 300, 1, None), 247 | ]) 248 | 249 | wands = set([ 250 | ("light", 100, 95, None), 251 | ("nothing", 100, 25, None), 252 | ("digging", 150, 55, None), 253 | ("enlightenment", 150, 15, None), 254 | ("locking", 150, 25, None), 255 | ("magic missile", 150, 50, None), 256 | ("make invisible", 150, 45, None), 257 | ("opening", 150, 25, None), 258 | ("probing", 150, 30, None), 259 | ("secret door detection", 150, 50, None), 260 | ("slow monster", 150, 50, None), 261 | ("speed monster", 150, 50, None), 262 | ("striking", 150, 75, None), 263 | ("undead turning", 150, 50, None), 264 | ("cold", 175, 40, None), 265 | ("fire", 175, 40, None), 266 | ("lightning", 175, 40, None), 267 | ("sleep", 175, 50, None), 268 | ("cancellation", 200, 45, None), 269 | ("create monster", 200, 45, None), 270 | ("polymorph", 200, 45, None), 271 | ("teleportation", 200, 45, None), 272 | ("death", 500, 5, None), 273 | ("wishing", 500, 5, None), 274 | ]) 275 | 276 | armor = { 277 | "shirts": set([ 278 | ("hawaiian shirt", 3, 8, None), 279 | ("t-shirt", 2, 2, None), 280 | ]), 281 | "suits": set([ 282 | ("leather jacket", 10, 12, None), 283 | ("leather armor", 5, 82, None), 284 | ("orcish ring mail", 80, 20, "crude ring mail"), 285 | ("studded leather armor", 15, 72, None), 286 | ("ring mail", 100, 72, None), 287 | ("scale mail", 45, 72, None), 288 | ("orcish chain mail", 75, 20, "crude chain mail"), 289 | ("chain mail", 75, 72, None), 290 | ("elven mithril coat", 240, 15, None), 291 | ("splint mail", 80, 62, None), 292 | ("banded mail", 90, 72, None), 293 | ("dwarvish mithril coat", 240, 10, None), 294 | ("bronze plate mail", 400, 25, None), 295 | ("plate mail", 600, 44, None), 296 | ("tanko", 600, 44, None), 297 | ("crystal plate mail", 820, 10, None), 298 | ]), 299 | "cloaks": set([ 300 | ("orcish cloak", 40, 8, "coarse mantelet"), 301 | ("dwarvish cloak", 50, 8, "hooded cloak"), 302 | ("leather cloak", 40, 8, None), 303 | ("cloak of displacement", 50, 10, "piece of cloth"), 304 | ("oilskin cloak", 50, 10, "slippery cloak"), 305 | ("alchemy smock", 50, 9, "apron"), 306 | ("cloak of invisibility", 60, 10, "opera cloak"), 307 | ("cloak of magic resistance", 60, 2, "ornamental cape"), 308 | ("elven cloak", 60, 8, "faded pall"), 309 | ("robe", 50, 3, None), 310 | ("cloak of protection", 50, 9, "tattered cape"), 311 | ]), 312 | "helmets": set([ 313 | ("fedora", 1, 0, None), 314 | ("dunce cap", 1, 3, "conical hat"), 315 | ("cornuthaum", 80, 3, "conical hat"), 316 | ("dented pot", 8, 2, None), 317 | ("elven leather helm", 8, 6, "leather hat"), 318 | ("helmet", 10, 10, "plumed helmet"), 319 | ("kabuto", 10, 10, "plumed helmet"), 320 | ("orcish helm", 10, 6, "iron skull cap"), 321 | ("helm of billiance", 50, 6, "etched helmet"), 322 | ("helm of opposite alignment", 50, 6, "crested helmet"), 323 | ("helm of telepathy", 50, 2, "visored helmet"), 324 | ("dwarvish iron helm", 20, 6, "hard hat"), 325 | ]), 326 | "gloves": set([ 327 | ("leather gloves", 8, 16, "pair of old gloves"), 328 | ("yugake", 8, 16, "pair of old gloves"), 329 | ("gauntlets of dexterity", 50, 8, "pair of padded gloves"), 330 | ("gauntlets of fumbling", 50, 8, "pair of riding gloves"), 331 | ("gauntlets of power", 50, 8, "pair of fencing gloves"), 332 | ]), 333 | "shields": set([ 334 | ("small shield", 3, 6, None), 335 | ("orcish shield", 7, 2, "red-eyed shield"), 336 | ("Uruk-hai shield", 7, 2, "white-handed shield"), 337 | ("elven shield", 7, 2, "blue and green shield"), 338 | ("dwarvish roundshield", 10, 4, "large round shield"), 339 | ("large shield", 10, 7, None), 340 | ("shield of reflection", 50, 3, "polished silver shield"), 341 | ]), 342 | "boots": set([ 343 | ("low boots", 8, 25, "pair of walking shoes"), 344 | ("elven boots", 8, 12, "pair of mud boots"), 345 | ("kicking boots", 8, 12, "pair of buckled boots"), 346 | ("fumble boots", 30, 12, "pair of riding boots"), 347 | ("levitation boots", 30, 12, "pair of snow boots"), 348 | ("jumping boots", 50, 12, "pair of hiking boots"), 349 | ("speed boots", 50, 12, "pair of combat boots"), 350 | ("water walking boots", 50, 12, "pair of jungle boots"), 351 | ("high boots", 12, 15, "pair of jackboots"), 352 | ("iron shoes", 16, 7, "pair of hard shoes"), 353 | ]), 354 | } 355 | 356 | def buy_price_markup(charisma): 357 | if charisma < 6: 358 | return 1 359 | elif charisma == 6 or charisma == 7: 360 | return 0.50 361 | elif 8 <= charisma <= 10: 362 | return 0.333 363 | elif 11 <= charisma <= 15: 364 | return 0 365 | elif charisma == 16 or charisma == 17: 366 | return -0.25 367 | elif charisma == 18: 368 | return -0.333 369 | elif charisma > 18: 370 | return -0.50 371 | 372 | def get_item_set(name): 373 | if "amulet" in name: 374 | return amulets 375 | elif "spellbook" in name: 376 | return spellbooks 377 | elif "scroll" in name: 378 | return scrolls 379 | elif "potion" in name: 380 | return potions 381 | elif "ring" in name: 382 | return rings 383 | elif "wand" in name: 384 | return wands 385 | elif "dagger" in name: 386 | return weapons["dagger"] 387 | elif "knife" in name: 388 | return weapons["knife"] 389 | elif "axe" in name: 390 | return weapons["axe"] 391 | elif "short sword" in name: 392 | return weapons["short sword"] 393 | elif "broadsword" in name: 394 | return weapons["broadsword"] 395 | elif "two-handed sword" in name or "long samurai sword" in name: 396 | return weapons["two-handed sword"] 397 | elif "long sword" in name or "samurai sword" in name: 398 | return weapons["long sword"] 399 | elif "club" in name: 400 | return weapons["club"] 401 | elif "flail" in name or "nanchaku" in name or "iron hook" in name: 402 | return weapons["flail"] 403 | elif "polearm" in name or "poleaxe" in name or "sickle" in name or "pruning hook" in name: 404 | return weapons["polearm"] 405 | elif "spear" in name: 406 | return weapons["spear"] 407 | elif "box" in name: 408 | return weapons["bow"] 409 | elif "whip" in name: 410 | return weapons["whip"] 411 | elif "shirt" in name: 412 | return armor["shirts"] 413 | elif "jacket" in name or \ 414 | "armor" in name or \ 415 | "mail" in name or \ 416 | "coat" in name or \ 417 | "tanko" in name: 418 | return armor["suits"] 419 | elif "cloak" in name or \ 420 | "smock" in name or \ 421 | "mantelet" in name or \ 422 | "cape" in name or \ 423 | "pall" in name or \ 424 | "apron" in name or \ 425 | "cloth" in name: 426 | return armor["cloaks"] 427 | elif "hat" in name or "helm" in name: 428 | return armor["helmets"] 429 | elif "gloves" in name: 430 | return armor["gloves"] 431 | elif "shield" in name: 432 | return armor["shields"] 433 | elif "shoes" in name or "boots" in name: 434 | return armor["boots"] 435 | 436 | def buy_identify(charisma, item, cost, sucker=False): 437 | possibles = get_item_set(item) 438 | if possibles is None: 439 | return set() 440 | 441 | markup = buy_price_markup(charisma) 442 | price_adjusted = [(p[0], p[1] + round(p[1] * markup)) + p[2:] for p in possibles] 443 | random_markup = [(p[0], p[1] + round(p[1] * 0.333)) + p[2:] for p in price_adjusted] 444 | real_possibles = price_adjusted + random_markup 445 | 446 | if sucker: 447 | real_possibles = [(p[0], p[1] + round(p[1] * 0.333)) + p[2:] for p in real_possibles] 448 | 449 | appearance_ids = set([p for p in real_possibles if p[3] == item and abs(p[1] - cost) <= 1]) 450 | if len(appearance_ids) > 0: 451 | return appearance_ids 452 | 453 | price_ids = set([p for p in real_possibles if abs(p[1] - cost) <= 1]) 454 | 455 | return price_ids 456 | 457 | def sell_identify(item, cost, sucker=False): 458 | possibles = get_item_set(item) 459 | if possibles is None: 460 | return set() 461 | 462 | if sucker: 463 | real_possibles = [(p[0], round(p[1] * 0.333)) + p[2:] for p in possibles] 464 | else: 465 | real_possibles = [(p[0], round(p[1] * 0.5)) + p[2:] for p in possibles] 466 | 467 | random_markdown = [(p[0], round(p[1] * 0.25)) + p[2:] for p in possibles] 468 | real_possibles += random_markdown 469 | 470 | appearance_ids = set([p for p in real_possibles if p[3] == item and abs(p[1] - cost) <= 1]) 471 | if len(appearance_ids) > 0: 472 | return appearance_ids 473 | 474 | price_ids = set([p for p in real_possibles if abs(p[1] - cost) <= 1]) 475 | 476 | return price_ids 477 | 478 | -------------------------------------------------------------------------------- /noobhack/game/sounds.py: -------------------------------------------------------------------------------- 1 | messages = { 2 | "oracle": set(( 3 | "You hear a strange wind.", 4 | "You hear convulsive ravings.", 5 | "You hear snoring snakes.", 6 | "You hear someone say \"No more woodchucks!\"", 7 | "You hear a loud ZOT!", 8 | "welcome to Delphi!", 9 | )), 10 | "rogue": set(("You enter what seems to be an older, more primitive world.",)), 11 | "angry watch": set(("You hear the shrill sound of a guard's whistle.",)), 12 | "zoo": set(( 13 | "You hear a sound reminiscent of an elephant stepping on a peanut.", 14 | "You hear a sound reminiscent of a seal barking.", 15 | "You hear Doctor Doolittle!" 16 | )), 17 | "beehive": set(( 18 | "You hear a low buzzing.", 19 | "You hear an angry drone.", 20 | "You hear bees in your.*bonnet!" 21 | )), 22 | "barracks": set(( 23 | "You hear blades being honed.", 24 | "You hear loud snoring.", 25 | "You hear dice being thrown.", 26 | "You hear General MacArthur!" 27 | )), 28 | "shop": set(( 29 | "You hear someone cursing shoplifters.", 30 | "You hear the chime of a cash register.", 31 | "You hear Neiman and Marcus arguing!" 32 | )), 33 | "vault": set(( 34 | "You hear the footsteps of a guard on patrol.", 35 | "You hear someone counting money.", 36 | "You hear Ebenezer Scrooge!", 37 | "You hear the quarterback calling the play.", 38 | "You hear someone searching.", 39 | )) 40 | } 41 | -------------------------------------------------------------------------------- /noobhack/game/status.py: -------------------------------------------------------------------------------- 1 | bads = set(["blind", "lycanthropy", "stoning", "injured leg"]) 2 | goods = set(["fast", "very fast", "stealth"]) 3 | 4 | def type_of(status): 5 | """ 6 | Return the type of a particular status; either `"good"`, `"bad"` or 7 | `"neutral"` 8 | """ 9 | if status in bads: 10 | return "bad" 11 | elif status in goods: 12 | return "good" 13 | else: 14 | return "neutral" 15 | 16 | messages = { 17 | "blind": { 18 | "You are blinded by a blast of light!": True, 19 | "You can see again.": False 20 | }, 21 | "lycanthropy": { 22 | "You feel feverish.": True, 23 | "You feel purified.": False 24 | }, 25 | "fast": { 26 | "You feel quick!": True, 27 | "You seem faster.": True, 28 | "You speed up.": True, 29 | "Your quickness feels more natural.": True, 30 | "\"and thus I grant thee the gift of Speed!\"": True, 31 | "You are slowing down.": False, 32 | "You feel slow!": False, 33 | "You feel slower.": False, 34 | "You seem slower.": False, 35 | "You slow down.": False, 36 | "Your limbs are getting oozy.": False, 37 | "Your quickness feels less natural.": False 38 | }, 39 | "very fast": { 40 | "You are suddenly moving faster.": True, 41 | "You are suddenly moving much faster.": True, 42 | "Your knees seem more flexible now.": True, 43 | "Your .* get new energy.": True, 44 | "You feel yourself slowing down a bit.": False, 45 | "You feel yourself slowing down.": False, 46 | "You slow down.": False, 47 | "Your quickness feels less natural.": False, 48 | }, 49 | "stealth": { 50 | "You feel stealthy!": True, 51 | "\"and thus I grant thee the gift of Stealth!\"": True, 52 | "You feel less stealthy!": False 53 | }, 54 | "teleportitis": { 55 | "You feel diffuse.": True, 56 | "You feel very jumpy.": True, 57 | "You feel less jumpy.": False 58 | }, 59 | "stoning": { 60 | "You are slowing down.": True, 61 | "Your limbs are stiffening.": True, 62 | "You feel limber!": False, 63 | "You feel more limber.": False, 64 | "What a pity - you just ruined a future piece of": False 65 | }, 66 | "injured leg": { 67 | "Your right leg is in no shape for kicking.": True, 68 | "Your (legs)? (feels)? somewhat better.": False 69 | }, 70 | "levitating": { 71 | "You are floating high above the fountain.": True, 72 | "You are floating high above the stairs.": True, 73 | "You cannot reach the ground.": True, 74 | "You have nothing to brace yourself against.": True, 75 | "You start to float in the air!": True, 76 | "You float gently to the floor.": False, 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /noobhack/plugins.py: -------------------------------------------------------------------------------- 1 | import os 2 | import imp 3 | 4 | from os import path 5 | 6 | PLUGINSPATH = [ 7 | "~/.noobhack/plugins", 8 | path.join(path.dirname(path.abspath(__file__)), "game/plugins") 9 | ] 10 | 11 | def load_plugin(plugin_path): 12 | module = imp.find_module("__init__", [plugin_path]) 13 | return imp.load_module("noobhack." + path.basename(plugin_path), *module) 14 | 15 | def load_plugins_for_path(p): 16 | p = path.expanduser(p) 17 | 18 | try: 19 | plugin_dirs = [path.join(p, pl) 20 | for pl 21 | in os.listdir(p) 22 | if path.isdir(path.join(p, pl))] 23 | 24 | modules = [] 25 | for plugin_path in plugin_dirs: 26 | p = load_plugin(plugin_path) 27 | modules.append(load_plugin(plugin_path)) 28 | 29 | return modules 30 | except OSError: 31 | return [] 32 | 33 | def load_plugins(): 34 | return [plugin 35 | for plugins_for_path 36 | in PLUGINSPATH 37 | for plugin 38 | in load_plugins_for_path(plugins_for_path)] 39 | -------------------------------------------------------------------------------- /noobhack/process.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import fcntl 4 | import select 5 | import signal 6 | import termios 7 | 8 | class ProcError(EnvironmentError): 9 | def __init__(self, stdout): 10 | self.stdout = stdout 11 | 12 | class Local: 13 | """ 14 | Runs and manages the input/output of a local nethack game. The game is 15 | forked into a pty. 16 | """ 17 | 18 | def __init__(self, debug=False): 19 | self.debug = debug 20 | self.pipe = None 21 | self.stdin = None 22 | self.stdout = None 23 | self.pid = None 24 | 25 | def open(self): 26 | """ 27 | Fork a child nethack process into a pty and setup its stdin and stdout 28 | """ 29 | 30 | (self.pid, self.pipe) = os.forkpty() 31 | 32 | if self.pid == 0: 33 | # I'm the child process in a fake pty. I need to replace myself 34 | # with an instance of nethack. 35 | # 36 | # NOTE: The '--proxy' argument doesn't seem to do anything though 37 | # it's used by dgamelaunch which is a bit confusing. However, 38 | # without *some* argument execvp doesn't seem to like nethack and 39 | # launches a shell instead? It's quite confusing. 40 | if self.debug: 41 | os.execvpe("nethack", ["--proxy", "-D"], os.environ) 42 | else: 43 | os.execvpe("nethack", ["--proxy"], os.environ) 44 | else: 45 | # Before we do anything else, it's time to establish some boundries 46 | signal.siginterrupt(signal.SIGCHLD, True) 47 | signal.signal(signal.SIGCHLD, self._close) 48 | 49 | # When my tty resizes, the child's pty should resize too. 50 | signal.signal(signal.SIGWINCH, self.resize_child) 51 | 52 | # Setup out input/output proxies 53 | self.stdout = os.fdopen(self.pipe, "rb", 0) 54 | self.stdin = os.fdopen(self.pipe, "wb", 0) 55 | 56 | # Set the initial size of the child pty to my own size. 57 | self.resize_child() 58 | 59 | def _close(self, *_): 60 | """ 61 | Raise an exception signaling that the child process has finished. 62 | Whoever catches the exception is responsible for flushing the child's 63 | stdout. 64 | """ 65 | 66 | raise ProcError(self.stdout) 67 | 68 | def resize_child(self, *_): 69 | """ 70 | Try to send the right signals to the child that the terminal has 71 | changed size. 72 | """ 73 | 74 | # Get the host app's terminal size first. 75 | parent = fcntl.ioctl(sys.stdin, termios.TIOCGWINSZ, 'HHHH') 76 | # Now set the child (conduit) app's size properly 77 | fcntl.ioctl(self.stdin, termios.TIOCSWINSZ, parent) 78 | 79 | def fileno(self): 80 | """ Return the fileno of the pipe. """ 81 | 82 | return self.pipe 83 | 84 | def close(self): 85 | """ Kill our child process. Cuban hit squad. """ 86 | 87 | os.kill(self.pid, signal.SIGTERM) 88 | 89 | def write(self, buf): 90 | """ Proxy input to the nethack process' stdin. """ 91 | self.stdin.write(buf) 92 | 93 | def read(self): 94 | """ 95 | Proxy output from the nethack process' stdout. This shouldn't block 96 | """ 97 | buf = "" 98 | while self.data_is_available(): 99 | buf += self.stdout.read(1) 100 | return buf 101 | 102 | def data_is_available(self): 103 | """ 104 | Return a non-empty list when the nethack process has data to read 105 | """ 106 | return select.select([self.stdout], [], [], 0) == ([self.stdout], [], []) 107 | -------------------------------------------------------------------------------- /noobhack/proxy.py: -------------------------------------------------------------------------------- 1 | """ 2 | Proxies act as the middle-men between the game that's running in a pty and the 3 | interface that's running in the actual terminal. 4 | """ 5 | 6 | import sys 7 | 8 | class Input: 9 | """ 10 | Proxies raw input from the terminal to the game, calling a set of callbacks 11 | each input item it receives. If any of the callbacks return `False` then 12 | the input is not forwarded to the game. 13 | """ 14 | 15 | def __init__(self, game): 16 | self.game = game 17 | self.callbacks = [] 18 | 19 | def register(self, callback): 20 | """ 21 | Register a function that will be called *before* any input read off of 22 | stdin is forwarded to the game. The callback should return `False` if 23 | it's not to forward the input to the game. 24 | """ 25 | if callback not in self.callbacks: 26 | self.callbacks.append(callback) 27 | 28 | def unregister(self, callback): 29 | """ 30 | Unregister a callback. 31 | """ 32 | if callback in self.callbacks: 33 | self.callbacks.remove(callback) 34 | 35 | def proxy(self): 36 | """ 37 | Read a single input character from stdin and, if none of the callbacks 38 | return `False`, forward the keystroke to the game. It's the 39 | responsibility of the caller to make sure that reading from stdin won't 40 | block (e.g. by select or setting it to non-blocking). 41 | """ 42 | key = sys.stdin.read(1) 43 | 44 | # Make the callback set a list because callbacks should be able to 45 | # unregister themselves if they want and they can't do that while 46 | # iterating over the set, so we need a copy. 47 | for callback in self.callbacks[:]: 48 | if callback(key) is False: 49 | return 50 | 51 | self.game.write(key) 52 | 53 | class Output: 54 | """ 55 | Proxies raw output from the game and calls a set of callbacks each time 56 | with the stream that was output. Typically you'd want to attach a terminal 57 | emulator, or something that can parse the output as meaningful to this. 58 | """ 59 | 60 | def __init__(self, game): 61 | self.game = game 62 | self.callbacks = [] 63 | 64 | def register(self, callback): 65 | """ 66 | Register a function that will be called whenever any input is read. The 67 | function should accept one argument, which will be the data read from 68 | the game. 69 | """ 70 | if callback not in self.callbacks: 71 | self.callbacks.append(callback) 72 | 73 | def unregister(self, callback): 74 | """ 75 | Unregister a callback. 76 | """ 77 | if callback in self.callbacks: 78 | self.callbacks.remove(callback) 79 | 80 | def proxy(self): 81 | """ 82 | Read any available information from the game and call all of the 83 | registered callbacks. It is the responsibility of the caller to make 84 | sure that the call to `self.game.read()` will not block (e.g. by using 85 | select or setting the fd to non-blocking mode) 86 | """ 87 | output = self.game.read() 88 | 89 | # Make the callback set a list because callbacks should be able to 90 | # unregister themselves if they want and they can't do that while 91 | # iterating over the list, so we need a copy. 92 | for callback in self.callbacks[:]: 93 | callback(output) 94 | -------------------------------------------------------------------------------- /noobhack/telnet.py: -------------------------------------------------------------------------------- 1 | import os 2 | import telnetlib 3 | from struct import pack 4 | 5 | class Telnet: 6 | """ 7 | Runs and manages the input/output of a remote nethack game. The class 8 | implements a telnet client in as much as the python telnetlib makes that 9 | possible (grumble, grumble, grumble). 10 | """ 11 | 12 | def __init__(self, host="nethack.alt.org", port=23,size=(80,24)): 13 | self.host = host 14 | self.port = port 15 | self.conn = None 16 | self.size = size 17 | 18 | def write(self, buf): 19 | """ Proxy input to the telnet process' stdin. """ 20 | self.conn.get_socket().send(buf) 21 | 22 | def read(self): 23 | """ 24 | Proxy output from the telnet process' stdout. This shouldn't block 25 | """ 26 | try: 27 | return self.conn.read_very_eager() 28 | except EOFError, ex: 29 | # The telnet connection closed. 30 | raise IOError(ex) 31 | 32 | def open(self): 33 | """ Open a connection to a telnet server. """ 34 | 35 | self.conn = telnetlib.Telnet(self.host, self.port) 36 | self.conn.set_option_negotiation_callback(self.set_option) 37 | self.conn.get_socket().sendall(pack(">ccc", telnetlib.IAC, telnetlib.WILL, telnetlib.NAWS)) 38 | 39 | def close(self): 40 | """ Close the connection. """ 41 | self.conn.close() 42 | 43 | def fileno(self): 44 | """ Return the fileno of the socket. """ 45 | return self.conn.get_socket().fileno() 46 | 47 | def set_option(self, socket, command, option): 48 | """ Configure our telnet options. This is magic. Don't touch it. """ 49 | 50 | if command == telnetlib.DO and option == "\x18": 51 | # Promise we'll send a terminal type 52 | socket.send("%s%s\x18" % (telnetlib.IAC, telnetlib.WILL)) 53 | elif command == telnetlib.DO and option == "\x01": 54 | # Pinky swear we'll echo 55 | socket.send("%s%s\x01" % (telnetlib.IAC, telnetlib.WILL)) 56 | elif command == telnetlib.DO and option == "\x1f": 57 | # And we should probably tell the server we will send our window 58 | # size 59 | socket.sendall(pack(">cccHHcc", telnetlib.IAC, telnetlib.SB, telnetlib.NAWS, self.size[1], self.size[0], telnetlib.IAC, telnetlib.SE)) 60 | elif command == telnetlib.DO and option == "\x20": 61 | # Tell the server to sod off, we won't send the terminal speed 62 | socket.send("%s%s\x20" % (telnetlib.IAC, telnetlib.WONT)) 63 | elif command == telnetlib.DO and option == "\x23": 64 | # Tell the server to sod off, we won't send an x-display terminal 65 | socket.send("%s%s\x23" % (telnetlib.IAC, telnetlib.WONT)) 66 | elif command == telnetlib.DO and option == "\x27": 67 | # We will send the environment, though, since it might have nethack 68 | # specific options in it. 69 | socket.send("%s%s\x27" % (telnetlib.IAC, telnetlib.WILL)) 70 | elif self.conn.rawq.startswith("\xff\xfa\x27\x01\xff\xf0\xff\xfa"): 71 | # We're being asked for the environment settings that we promised 72 | # earlier 73 | socket.send("%s%s\x27\x00%s%s%s" % 74 | (telnetlib.IAC, 75 | telnetlib.SB, 76 | '\x00"NETHACKOPTIONS"\x01"%s"' % os.environ.get("NETHACKOPTIONS", ""), 77 | telnetlib.IAC, 78 | telnetlib.SE)) 79 | # We're being asked for the terminal type that we promised earlier 80 | socket.send("%s%s\x18\x00%s%s%s" % 81 | (telnetlib.IAC, 82 | telnetlib.SB, 83 | "xterm-color", 84 | telnetlib.IAC, 85 | telnetlib.SE)) 86 | -------------------------------------------------------------------------------- /noobhack/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samfoo/noobhack/ffb4901202ba1c6493edb411856b70c3ec78a53a/noobhack/ui/__init__.py -------------------------------------------------------------------------------- /noobhack/ui/common.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import fcntl 3 | import curses 4 | import struct 5 | import termios 6 | 7 | # Map vt102 colors to curses colors. Notably nethack likes to use `brown` 8 | # which is the only difference between curses and linux console colors. Turns 9 | # out that it just renders as yellow (at least in OSX Terminal.app) anyway. 10 | colors = { 11 | "black": curses.COLOR_BLACK, 12 | "blue": curses.COLOR_BLUE, 13 | "cyan": curses.COLOR_CYAN, 14 | "green": curses.COLOR_GREEN, 15 | "magenta": curses.COLOR_MAGENTA, 16 | "red": curses.COLOR_RED, 17 | "white": curses.COLOR_WHITE, 18 | "yellow": curses.COLOR_YELLOW, 19 | "brown": curses.COLOR_YELLOW, 20 | "default": -1, 21 | } 22 | 23 | # Map vt102 text styles to curses attributes. 24 | styles = { 25 | "bold": curses.A_BOLD, 26 | "dim": curses.A_DIM, 27 | "underline": curses.A_UNDERLINE, 28 | "blink": curses.A_BLINK, 29 | "reverse": curses.A_REVERSE, 30 | } 31 | 32 | def get_color(foreground, background=-1, registered={}): 33 | """ 34 | Given a foreground and background color pair, return the curses 35 | attribute. If there isn't a color of that type registered yet, then 36 | create it. 37 | 38 | :param registered: Don't ever pass something in for this. The default 39 | mutable param as static is overriden as a feature of sorts so that 40 | a static variable doesn't have to be declared somewhere else. 41 | """ 42 | if not registered.has_key((foreground, background)): 43 | curses.init_pair(len(registered)+1, foreground, background) 44 | registered[(foreground, background)] = len(registered) + 1 45 | return curses.color_pair(registered[(foreground, background)]) 46 | 47 | def size(): 48 | """ 49 | Get the current terminal size. 50 | 51 | :return: (rows, cols) 52 | """ 53 | 54 | raw = fcntl.ioctl(sys.stdin, termios.TIOCGWINSZ, 'SSSS') 55 | return struct.unpack('hh', raw) 56 | -------------------------------------------------------------------------------- /noobhack/ui/game.py: -------------------------------------------------------------------------------- 1 | import re 2 | import curses 3 | import locale 4 | 5 | from noobhack.ui.common import styles, colors, get_color 6 | 7 | class Game: 8 | """ 9 | Draw the game in the terminal. 10 | """ 11 | 12 | def __init__(self, term, plugins): 13 | self.term = term 14 | self.plugins = plugins 15 | self.code = locale.getpreferredencoding() 16 | 17 | def _write_text(self, window, row, row_str): 18 | max_y, max_x = window.getmaxyx() 19 | if row == max_y -1: 20 | window.addstr(row, 0, row_str[:-1]) 21 | window.insch(row_str[-1]) 22 | else: 23 | window.addstr(row, 0, row_str) 24 | 25 | def _write_attrs(self, window, row): 26 | row_a = self.term.attributes[row] 27 | for col, (char_style, foreground, background) in enumerate(row_a): 28 | char_style = set(char_style) 29 | foreground = colors.get(foreground, -1) 30 | background = colors.get(background, -1) 31 | char_style = [styles.get(s, curses.A_NORMAL) for s in char_style] 32 | attrs = char_style + [get_color(foreground, background)] 33 | window.chgat(row, col, 1, reduce(lambda a, b: a | b, attrs)) 34 | 35 | def _redraw_row(self, window, row): 36 | """ 37 | Draw a single game-row in the curses display window. This means writing 38 | the text from the in-memory terminal out and setting the color/style 39 | attributes appropriately. 40 | """ 41 | row_str = self.term.display[row].encode(self.code) 42 | 43 | self._write_text(window, row, row_str) 44 | self._write_attrs(window, row) 45 | 46 | def redraw(self, window): 47 | """ 48 | Repaint the screen with the new contents of our terminal emulator... 49 | """ 50 | 51 | window.erase() 52 | 53 | for plugin in self.plugins: 54 | plugin.redraw(self.term) 55 | 56 | for row_index in xrange(len(self.term.display)): 57 | self._redraw_row(window, row_index) 58 | 59 | # Don't forget to move the cursor to where it is in game... 60 | cur_x, cur_y = self.term.cursor() 61 | window.move(cur_y, cur_x) 62 | 63 | # Finally, redraw the whole thing. 64 | window.noutrefresh() 65 | 66 | 67 | -------------------------------------------------------------------------------- /noobhack/ui/helper.py: -------------------------------------------------------------------------------- 1 | import re 2 | import curses 3 | 4 | from noobhack.game import shops, status 5 | from noobhack.ui.common import get_color 6 | 7 | class Helper: 8 | """ 9 | Maintain the state of the helper UI and draw it to a curses screen when 10 | `redraw` is called. 11 | """ 12 | 13 | intrinsic_width = 25 14 | status_width = 12 15 | level_width = 25 16 | 17 | def __init__(self, manager): 18 | self.manager = manager 19 | 20 | self.player = manager.player 21 | self.dungeon = manager.dungeon 22 | 23 | def _get_statuses(self): 24 | """ 25 | Return the statuses to display in the status frame, sorted. 26 | """ 27 | 28 | def sort_statuses(left, right): 29 | """Sort statuses first by their effect (bad, good, neutral), and 30 | second by their name.""" 31 | diff = cmp(status.type_of(left), status.type_of(right)) 32 | if diff == 0: 33 | diff = cmp(left, right) 34 | return diff 35 | 36 | return sorted(self.player.status, sort_statuses) 37 | 38 | def _height(self): 39 | """ 40 | Return the height of the helper ui. This means finding the max number 41 | of lines that is to be displayed in all of the boxes. 42 | """ 43 | 44 | level = self.dungeon.current_level() 45 | return 1 + max(1, max( 46 | len(level.features) + len(level.shops), 47 | len(self._get_statuses()), 48 | len(self.player.intrinsics), 49 | )) 50 | 51 | def _top(self): 52 | """ 53 | Return the y coordinate of the top of where the ui overlay should be 54 | drawn. 55 | """ 56 | 57 | for i in xrange(len(self.manager.term.display)-1, -1, -1): 58 | row = i 59 | line = self.manager.term.display[i].strip() 60 | if len(line) > 0: 61 | break 62 | return row - self._height() - 1 63 | 64 | def _level_box(self): 65 | """ 66 | Create the pane with information about this dungeon level. 67 | """ 68 | 69 | level = self.dungeon.current_level() 70 | default = "(none)" 71 | 72 | dungeon_frame = curses.newwin( 73 | self._height(), 74 | self.level_width, 75 | self._top(), 76 | 0 77 | ) 78 | 79 | dungeon_frame.erase() 80 | dungeon_frame.border("|", "|", "-", " ", "+", "+", "|", "|") 81 | dungeon_frame.addstr(0, 2, " this level ") 82 | dungeon_frame.chgat(0, 2, len(" this level "), get_color(curses.COLOR_CYAN)) 83 | 84 | features = sorted(level.features) 85 | row = 1 86 | i = 0 87 | while i < len(features): 88 | feature = features[i] 89 | dungeon_frame.addnstr(row, 1, feature, self.level_width-2) 90 | row += 1 91 | i += 1 92 | 93 | if feature == "shop": 94 | for shop in sorted(level.shops): 95 | dungeon_frame.addnstr(row, 3, "* " + shop, self.level_width-5) 96 | row += 1 97 | 98 | if len(features) == 0: 99 | center = (self.level_width / 2) - (len(default) / 2) 100 | dungeon_frame.addstr(1, center, default) 101 | 102 | return dungeon_frame 103 | 104 | def _intrinsic_box(self): 105 | """ 106 | Create the pane with information with the player's current intrinsics. 107 | """ 108 | 109 | default = "(none)" 110 | 111 | def res_color(res): 112 | """Return the color of a intrinsic.""" 113 | if res == "fire": 114 | return curses.COLOR_RED 115 | elif res == "cold": 116 | return curses.COLOR_BLUE 117 | elif res == "poison": 118 | return curses.COLOR_GREEN 119 | elif res == "disintegration": 120 | return curses.COLOR_YELLOW 121 | else: 122 | return -1 123 | 124 | res_frame = curses.newwin( 125 | self._height(), 126 | self.intrinsic_width, 127 | self._top(), 128 | self.level_width + self.status_width - 2 129 | ) 130 | 131 | res_frame.erase() 132 | res_frame.border("|", "|", "-", " ", "+", "+", "|", "|") 133 | res_frame.addstr(0, 2, " intrinsic ") 134 | res_frame.chgat(0, 2, len(" intrinsic "), get_color(curses.COLOR_CYAN)) 135 | intrinsics = sorted(self.player.intrinsics) 136 | for row, res in enumerate(intrinsics, 1): 137 | color = get_color(res_color(res)) 138 | res_frame.addnstr(row, 1, res, self.intrinsic_width-2) 139 | res_frame.chgat(row, 1, min(len(res), self.intrinsic_width-2), color) 140 | 141 | if len(intrinsics) == 0: 142 | center = (self.intrinsic_width / 2) - (len(default) / 2) 143 | res_frame.addstr(1, center, default) 144 | 145 | return res_frame 146 | 147 | def _identify_box(self, items): 148 | """ 149 | Create the pain with information about price identifying something. 150 | """ 151 | 152 | items = sorted(items, lambda a, b: cmp(b[2], a[2])) 153 | total_chance = sum(float(i[2]) for i in items) 154 | items = [(i[0], i[1], (i[2] / total_chance) * 100.) for i in items] 155 | items = [("%s" % i[0], "%0.2f%%" % float(i[2])) for i in items] 156 | if len(items) == 0: 157 | items = set([("(huh... can't identify)", "100%")]) 158 | 159 | width = 2 + max([len(i[0]) + len(i[1]) + 4 for i in items]) 160 | 161 | identify_frame = curses.newwin(len(items) + 1, width, 1, 0) 162 | identify_frame.border("|", "|", " ", "-", "|", "|", "+", "+") 163 | identify_frame.addstr(len(items), 2, " identify ") 164 | identify_frame.chgat(len(items), 2, len(" identify "), get_color(curses.COLOR_CYAN)) 165 | 166 | for row, item in enumerate(items): 167 | identify_frame.addstr(row, 2, item[0]) 168 | identify_frame.addstr(row, width - len(item[1]) - 2, item[1]) 169 | 170 | return identify_frame 171 | 172 | def _things_to_sell_identify(self): 173 | line = self.manager.term.display[0] 174 | match = re.search(shops.offer, line, re.I) 175 | if match is not None: 176 | price = int(match.groups()[0]) 177 | item = match.groups()[1] 178 | 179 | return (item, price) 180 | 181 | return None 182 | 183 | def _things_to_buy_identify(self): 184 | # TODO: Make sure that unpaid prices can only ever appear on the first 185 | # line. I might have to check the second line too. 186 | line = self.manager.term.display[0] 187 | match = re.search(shops.price, line, re.I) 188 | if match is not None: 189 | count = match.groups()[0] 190 | item = match.groups()[1] 191 | price = int(match.groups()[2]) 192 | 193 | if count == "a": 194 | count = 1 195 | else: 196 | count = int(count) 197 | 198 | price = price / count 199 | 200 | return (item, price) 201 | 202 | return None 203 | 204 | def _status_box(self): 205 | """ 206 | Create the pane with information about the player's current state. 207 | """ 208 | 209 | default = "(none)" 210 | 211 | status_frame = curses.newwin( 212 | self._height(), 213 | self.status_width, 214 | self._top(), 215 | self.level_width - 1 216 | ) 217 | 218 | status_frame.erase() 219 | status_frame.border("|", "|", "-", " ", "+", "+", "|", "|") 220 | status_frame.addstr(0, 2, " status ") 221 | status_frame.chgat(0, 2, len(" status "), get_color(curses.COLOR_CYAN)) 222 | statuses = self._get_statuses() 223 | for row, stat in enumerate(statuses, 1): 224 | attrs = [] 225 | if status.type_of(stat) == "bad": 226 | attrs += [get_color(curses.COLOR_RED)] 227 | 228 | attrs = reduce(lambda a, b: a | b, attrs, 0) 229 | status_frame.addnstr(row, 1, stat, self.status_width-2, attrs) 230 | 231 | if len(statuses) == 0: 232 | center = (self.status_width / 2) - (len(default) / 2) 233 | status_frame.addstr(1, center, default) 234 | 235 | return status_frame 236 | 237 | def _breadcrumbs(self, window): 238 | breadcrumbs = self.dungeon.current_level().breadcrumbs 239 | 240 | for crumb in breadcrumbs: 241 | x, y = crumb 242 | if self.manager.char_at(x, y) not in [".", "#", " "]: 243 | # Ignore anything that's not something we can step on. 244 | continue 245 | 246 | if self.manager.char_at(x, y) == " ": 247 | window.addch(y, x, ".") 248 | 249 | window.chgat(y, x, 1, curses.A_BOLD | get_color(curses.COLOR_MAGENTA)) 250 | 251 | cur_x, cur_y = self.manager.term.cursor() 252 | window.move(cur_y, cur_x) 253 | 254 | def redraw(self, window, breadcrumbs=False): 255 | """ 256 | Repaint the screen with the helper UI. 257 | """ 258 | 259 | if self.manager.cursor_is_on_player() and breadcrumbs: 260 | self._breadcrumbs(window) 261 | 262 | if self._things_to_buy_identify() is not None: 263 | item, price = self._things_to_buy_identify() 264 | items = shops.buy_identify(self.player.charisma(), item, price, self.player.sucker()) 265 | 266 | identify_frame = self._identify_box(items) 267 | identify_frame.overwrite(window) 268 | elif self._things_to_sell_identify() is not None: 269 | item, price = self._things_to_sell_identify() 270 | items = shops.sell_identify(item, price, self.player.sucker()) 271 | 272 | identify_frame = self._identify_box(items) 273 | identify_frame.overwrite(window) 274 | 275 | status_frame = self._status_box() 276 | dungeon_frame = self._level_box() 277 | intrinsic_frame = self._intrinsic_box() 278 | 279 | status_frame.overwrite(window) 280 | dungeon_frame.overwrite(window) 281 | intrinsic_frame.overwrite(window) 282 | 283 | window.noutrefresh() 284 | 285 | -------------------------------------------------------------------------------- /noobhack/ui/minimap.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import curses 3 | 4 | from noobhack.ui.common import get_color, size 5 | 6 | class Minimap: 7 | branch_display_names = { 8 | "main": "Dungeons of Doom", 9 | } 10 | 11 | def __init__(self): 12 | self.dungeon = None 13 | 14 | def shop_text_as_buffer(self, shops): 15 | if len(shops) > 0: 16 | return [" Shops:"] + [" * %s" % s.capitalize() for s in shops] 17 | else: 18 | return [] 19 | 20 | def feature_text_as_buffer(self, features): 21 | return [" * %s" % f.capitalize() 22 | for f in sorted(features) 23 | if f != "shop"] 24 | 25 | def level_text_as_buffer(self, level): 26 | buf = self.shop_text_as_buffer(level.shops) + \ 27 | self.feature_text_as_buffer(level.features) 28 | return ["Level %s:" % level.dlvl] + (buf or [" (nothing interesting)"]) 29 | 30 | def line_to_display(self, text, width, border="|", padding=" "): 31 | if len(text) > (width + len(border) * 2 + len(padding) * 2): 32 | # If the text is too long to fit in the width, then trim it. 33 | text = text[:(width + len(border) * 2 + 2)] 34 | return "%s%s%s%s%s" % ( 35 | border, padding, 36 | text + padding * (width - len(text) - len(border) * 2 - len(padding) * 2), 37 | padding, border 38 | ) 39 | 40 | def layout_level_text_buffers(self, levels): 41 | result = [] 42 | last_level = None 43 | indices = {} 44 | for level in levels: 45 | if last_level is not None and level.dlvl > (last_level.dlvl + 1): 46 | level_display_buffer = ["...", ""] + \ 47 | self.level_text_as_buffer(level) + \ 48 | [""] 49 | indices[level.dlvl] = len(result) + 2 50 | result += level_display_buffer 51 | else: 52 | level_display_buffer = self.level_text_as_buffer(level) + [""] 53 | indices[level.dlvl] = len(result) 54 | result += level_display_buffer 55 | 56 | last_level = level 57 | 58 | return indices, result 59 | 60 | def header_as_buffer(self, text, width): 61 | return [ 62 | self.line_to_display("-" * (width - 2), width, ".", ""), 63 | self.line_to_display(text, width), 64 | self.line_to_display("=" * (width - 2), width, padding=""), 65 | ] 66 | 67 | def footer_as_buffer(self, width): 68 | return [self.line_to_display("...", width, "'")] 69 | 70 | def end_as_buffer(self, width): 71 | return [self.line_to_display("-" * (width - 2), width, "'", "")] 72 | 73 | def unconnected_branch_as_buffer_with_indices(self, display_name, branch, end=False): 74 | indices, level_texts = self.layout_level_text_buffers(branch) 75 | max_level_text_width = len(max(level_texts, key=len)) 76 | 77 | # The '4' comes from two spaces of padding and two border pipes. 78 | width = 4 + max(len(display_name), max_level_text_width) 79 | 80 | header = self.header_as_buffer(display_name, width) 81 | body = [self.line_to_display(t, width) for t in level_texts] 82 | if not end: 83 | footer = self.footer_as_buffer(width) 84 | else: 85 | footer = self.end_as_buffer(width) 86 | 87 | # Adjust the indices to account for the header 88 | indices = dict([(dlvl, index + len(header)) 89 | for dlvl, index 90 | in indices.iteritems()]) 91 | 92 | return (indices, header + body + footer) 93 | 94 | def get_plane_for_map(self, levels): 95 | # Hopefully 10 lines per dungeon level on average is large enough... 96 | max_height = size()[0] * 3 + len(levels) * 10 97 | max_width = size()[1] * 3 98 | return curses.newpad(max_height, max_width) 99 | 100 | def loop_and_listen_for_scroll_events(self, window, plane, bounds, close): 101 | left, right, top, bottom, current_lvl_x, current_lvl_y = bounds 102 | 103 | mid_screen_x = size()[1] / 2 104 | mid_screen_y = size()[0] / 2 105 | scroll_y = current_lvl_y - mid_screen_y 106 | scroll_x = current_lvl_x - mid_screen_x 107 | 108 | while True: 109 | plane.noutrefresh(scroll_y, scroll_x, 0, 0, size()[0] - 1, size()[1] - 1) 110 | 111 | # For some reason, curses *really* wants the cursor to be below to the 112 | # main window, no matter who used it last. Regardless, just move it 113 | # to the lower left so it's out of the way. 114 | window.move(window.getmaxyx()[0] - 1, 0) 115 | window.noutrefresh() 116 | 117 | curses.doupdate() 118 | 119 | # Wait around until we get some input. 120 | key = sys.stdin.read(1) 121 | if key == close or key == "\x1b": 122 | break 123 | 124 | movements = { 125 | "k": (0, -1), "j": (0, 1), "h": (-1, 0), "l": (1, 0), 126 | "y": (-1, -1), "u": (1, -1), "b": (-1, 1), "n": (1, 1) 127 | } 128 | 129 | move_x, move_y = movements.get(key.lower(), (0, 0)) 130 | 131 | if key.isupper(): 132 | move_x *= 5 133 | move_y *= 5 134 | 135 | if move_y > 0: 136 | scroll_y = min(scroll_y + move_y, bottom - 5) 137 | else: 138 | scroll_y = max(scroll_y + move_y, top - size()[0] + 5) 139 | 140 | if move_x > 0: 141 | scroll_x = min(scroll_x + move_x, right - 20) 142 | else: 143 | scroll_x = max(scroll_x + move_x, left - size()[1] + 20) 144 | 145 | def _draw_down_connecter(self, plane, x_offset, y_offset, left=False): 146 | if left: 147 | syms = ["*", "/", "/"] 148 | else: 149 | syms = ["\\", "\\", "*"] 150 | 151 | for i, sym in enumerate(syms): 152 | plane.addstr(y_offset + i, x_offset + i, sym) 153 | 154 | def _draw_up_connecter(self, plane, x_offset, y_offset, left=False): 155 | if left: 156 | syms = ["*", "\\", "\\"] 157 | else: 158 | syms = ["/", "/", "*"] 159 | 160 | for i, sym in enumerate(syms): 161 | if left: 162 | real_y_offset = y_offset + i - len(syms) 163 | else: 164 | real_y_offset = y_offset - i 165 | plane.addstr(real_y_offset, x_offset + i, sym) 166 | 167 | def _draw_sub_branches(self, parent, current, plane, 168 | indices, left_x_offset, right_x_offset, y_offset, 169 | color, drawn, left=False, alternate=True): 170 | left_x, right_x, top_y, bottom_y = plane.getmaxyx()[1], 0, plane.getmaxyx()[0], 0 171 | bounds = (left_x, right_x, top_y, bottom_y, 0, 0) 172 | 173 | for i, sub_branch in enumerate(parent.sub_branches()): 174 | if not drawn.has_key(sub_branch.name()): 175 | drawn.update({sub_branch.name(): True}) 176 | 177 | branch_junction = [l for l 178 | in sub_branch.junction.branches() 179 | if l.branch == parent.name()][0] 180 | 181 | if branch_junction.dlvl < sub_branch.start.dlvl: 182 | draw = self._draw_branch_at 183 | connect = self._draw_down_connecter 184 | else: 185 | draw = self._draw_branch_to 186 | connect = self._draw_up_connecter 187 | 188 | if alternate: 189 | left = left or (i % 2) == 1 190 | 191 | if left: 192 | connect_offset = x_offset = left_x_offset - 3 193 | else: 194 | x_offset = right_x_offset + 3 195 | connect_offset = right_x_offset 196 | 197 | connect_at = y_offset + indices[branch_junction.dlvl] - 1 198 | sub_bounds = draw(sub_branch, current, plane, 199 | x_offset, connect_at, color, 200 | drawn, left, False) 201 | 202 | bounds = ( 203 | min(sub_bounds[0], bounds[0]), 204 | max(sub_bounds[1], bounds[1]), 205 | min(sub_bounds[2], bounds[2]), 206 | max(sub_bounds[3], bounds[3]), 207 | max(sub_bounds[4], bounds[4]), 208 | max(sub_bounds[5], bounds[5]), 209 | ) 210 | 211 | connect(plane, connect_offset, connect_at + 1, left) 212 | return bounds 213 | 214 | def _draw_branch_to(self, branch, current, plane, 215 | x_offset, y_offset, color, drawn, 216 | left=False, alternate=True): 217 | return self._draw_branch(branch, current, plane, 218 | x_offset, y_offset, color, 219 | drawn, True, left, alternate) 220 | 221 | def _draw_branch_at(self, branch, current, plane, 222 | x_offset, y_offset, color, drawn, 223 | left=False, alternate=True): 224 | return self._draw_branch(branch, current, plane, 225 | x_offset, y_offset, color, 226 | drawn, False, left, alternate) 227 | 228 | def _draw_branch(self, branch, current, plane, 229 | x_offset, y_offset, color, drawn, 230 | to=False, left=False, alternate=True): 231 | drawn.update({branch.name(): True}) 232 | 233 | indices, buf = self.unconnected_branch_as_buffer_with_indices( 234 | branch.name(), branch, to 235 | ) 236 | 237 | if to: 238 | real_y_offset = y_offset - len(buf) + 1 239 | else: 240 | real_y_offset = y_offset 241 | 242 | if left: 243 | real_x_offset = x_offset - len(buf[0]) 244 | else: 245 | real_x_offset = x_offset 246 | 247 | for index, line in enumerate(buf): 248 | plane.addstr(real_y_offset + index, real_x_offset, line) 249 | 250 | # Hilight the current level in bold green text if it's in this 251 | # branch. 252 | if current.branch == branch.name() and \ 253 | index >= indices[current.dlvl] and \ 254 | index < indices.get(current.dlvl + 1, len(buf) - 1): 255 | plane.chgat( 256 | real_y_offset + index, 257 | real_x_offset + 1, 258 | len(line) - 2, 259 | curses.A_BOLD | color(curses.COLOR_GREEN) 260 | ) 261 | 262 | # Determine our bounding box. 263 | left_x = real_x_offset 264 | right_x = real_x_offset + len(buf[0]) 265 | top_y = real_y_offset 266 | bottom_y = real_y_offset + len(buf) 267 | 268 | if current.branch == branch.name(): 269 | current_lvl_x = real_x_offset 270 | current_lvl_y = real_y_offset + indices[current.dlvl] 271 | else: 272 | current_lvl_x = left_x 273 | current_lvl_y = top_y 274 | 275 | bounds = (left_x, right_x, top_y, bottom_y, current_lvl_x, current_lvl_y) 276 | 277 | sub_bounds = self._draw_sub_branches( 278 | branch, current, plane, indices, 279 | real_x_offset, real_x_offset + len(buf[0]), 280 | real_y_offset, color, drawn, left, alternate 281 | ) 282 | 283 | return ( 284 | min(sub_bounds[0], bounds[0]), 285 | max(sub_bounds[1], bounds[1]), 286 | min(sub_bounds[2], bounds[2]), 287 | max(sub_bounds[3], bounds[3]), 288 | max(sub_bounds[4], bounds[4]), 289 | max(sub_bounds[5], bounds[5]), 290 | ) 291 | 292 | def draw_dungeon(self, dungeon, plane, x_offset, y_offset, color=get_color): 293 | return self._draw_branch_at( 294 | dungeon.main(), dungeon.current, plane, 295 | x_offset, y_offset, color, {} 296 | ) 297 | 298 | def display(self, dungeon, window, close="`"): 299 | plane = self.get_plane_for_map(dungeon.main()) 300 | bounds = self.draw_dungeon(dungeon, plane, plane.getmaxyx()[1] / 2, size()[0]) 301 | self.loop_and_listen_for_scroll_events(window, plane, bounds, close) 302 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # nethack is hard 2 | 3 | [NetHack](http://www.nethack.org/) is hard. noobhack makes it easier. It 4 | remembers stuff about the Mazes of Menace that would otherwise be tedious for 5 | you. 6 | 7 | # how does noobhack help me? 8 | 9 | * Found a rare bookshop but don't have the means to pay, the cunning to steal, 10 | or the muscle to murder? noobhack remembers features about the dungeon so you 11 | can come back later when you're a dragon riding badass. 12 | * Know how to price-identify, but can't be bothered to calculate it yourself? 13 | noobhack automatically price identifies items for you when the shopkeeper 14 | makes his offer. Subverting the plutocratic corporate shopkeepers fat cats. 15 | * Hear a sound in the dungeon and not sure what it means? noobhack translates 16 | noises and remembers the important ones. 17 | * &tc. and so forth... 18 | 19 | # awesome! how do i get started? 20 | 21 | Installing noobhack is easy for the gentleman adventurer who uses pip. 22 | 23 | $ pip install noobhack 24 | 25 | Or for those old buggers amongst us, easy\_install works just as well. 26 | 27 | $ easy_install noobhack 28 | 29 | Start noobhack by typing 30 | 31 | % noobhack 32 | 33 | This will start a normal game of nethack. You can play nethack without ever 34 | consulting noobhack, but if you want to consult the helper console simply press 35 | `tab`. To dismiss it press `tab` again. 36 | 37 | You can also open the map mode by typing the backtick key '\`' and dismiss it 38 | again by pressing the same. When in map mode, you can scroll the map with the 39 | 'h', 'j', 'k', and 'l' keys (just like walking in the game) 40 | 41 | # but explanations are boring, show me screenshots! 42 | 43 | ![Map](http://samfoo.github.com/noobhack/images/screenshots/map.png) 44 | ![Map 2](http://samfoo.github.com/noobhack/images/screenshots/map-2.png) 45 | ![DEC Graphics](http://samfoo.github.com/noobhack/images/screenshots/dec-graphics.png) 46 | ![Price ID 2](http://samfoo.github.com/noobhack/images/screenshots/price-id-2.png) 47 | ![Price ID Selling](http://samfoo.github.com/noobhack/images/screenshots/price-id-sell.png) 48 | ![Price ID](http://samfoo.github.com/noobhack/images/screenshots/price-id.png) 49 | 50 | # does noobhack work on public servers like nethack.alt.org (NAO)? 51 | 52 | Yup, you can start a remote game by doing something like: 53 | 54 | % noobhack -h nethack.alt.org 55 | 56 | # but, noobhack doesn't do (pudding farming | automatically solve sokoban | ascend for me)! 57 | 58 | Fret not, noobhack allows you to write your own plugins! 59 | 60 | *more plugin documentation coming soon* 61 | 62 | # isn't this cheating? 63 | 64 | Some people might (and I suspect *will*) consider noobhack cheating. However, 65 | before you start chuggin' a gallon of haterade, consider this: 66 | 67 | * noobhack doesn't actual know anything other than what it sees on screen. 68 | * I spent more time working on noobhack than most people spend ascending. 69 | * ... 70 | * Screw you, it's not cheating. 71 | 72 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flexmock==0.9.7 2 | nose==1.3.3 3 | vt102==0.3.3 4 | wsgiref==0.1.2 5 | -------------------------------------------------------------------------------- /scripts/noobhack: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python -u 2 | 3 | import os 4 | import re 5 | import sys 6 | import select 7 | import curses 8 | import locale 9 | import optparse 10 | 11 | import vt102 12 | 13 | from noobhack import telnet, process, proxy, plugins 14 | from noobhack.game import manager, save, events 15 | 16 | from noobhack.ui.game import * 17 | from noobhack.ui.helper import * 18 | from noobhack.ui.minimap import * 19 | from noobhack.ui.common import * 20 | 21 | def get_parser(): 22 | parser = optparse.OptionParser( 23 | description="noobhack helps you ascend playing nethack." 24 | ) 25 | 26 | parser.set_defaults( 27 | local=True, 28 | port=23, 29 | help=False, 30 | encoding="ascii", 31 | crumbs=False 32 | ) 33 | 34 | parser.remove_option("-h") 35 | 36 | parser.add_option("--help", 37 | help="show this message and exit", 38 | action="store_true", 39 | dest="help") 40 | 41 | parser.add_option("-l", 42 | "--local", 43 | help="play a local game [default: %default]", 44 | action="store_true", 45 | dest="local") 46 | 47 | parser.add_option("-h", 48 | "--host", 49 | help="play a remote game on HOST", 50 | type="string", 51 | dest="host") 52 | 53 | parser.add_option("-p", 54 | "--port", 55 | help="connect to the remote host on PORT [default: %default]", 56 | type="int", 57 | dest="port") 58 | 59 | parser.add_option("-s", 60 | "--save", 61 | help="use a specific save file label (if playing multiple games)", 62 | type="string", 63 | metavar="NAME", 64 | dest="save") 65 | 66 | parser.add_option("--crumbs", 67 | help="display breadcrumbs when the helper overlay is on screen", 68 | action="store_true", 69 | dest="crumbs") 70 | 71 | parser.add_option("-e", 72 | "--encoding", 73 | help="set the terminal emulator to ENC [default: %default]", 74 | type="string", 75 | metavar="ENC", 76 | dest="encoding") 77 | 78 | parser.add_option("-d", 79 | "--debug", 80 | help="start the game in debug mode", 81 | action="store_true", 82 | dest="debug") 83 | 84 | return parser 85 | 86 | 87 | def parse_options(): 88 | """ 89 | Parse commandline options and return a dict with any settings. 90 | """ 91 | 92 | parser = get_parser() 93 | (options, args) = parser.parse_args() 94 | 95 | if options.host is not None: 96 | options.local = False 97 | 98 | if options.help: 99 | get_parser().print_help() 100 | sys.exit(1) 101 | 102 | return options 103 | 104 | class Noobhack: 105 | """ 106 | This runs the main event loop and makes sure the screen gets updated as 107 | necessary. 108 | """ 109 | 110 | noobhack_dir = os.path.expanduser("~/.noobhack") 111 | 112 | def __init__(self, toggle_help="\t", toggle_map="`"): 113 | self.options = parse_options() 114 | 115 | if self.options.save: 116 | self.save_file = os.path.join(self.noobhack_dir, "save-%s" % self.options.save) 117 | else: 118 | self.save_file = os.path.join(self.noobhack_dir, "save") 119 | 120 | self.toggle_help = toggle_help 121 | self.toggle_map = toggle_map 122 | self.mode = "game" 123 | self.playing = False 124 | self.reloading = False 125 | self.plugins = plugins.load_plugins() 126 | 127 | if not os.path.exists(self.noobhack_dir): 128 | os.makedirs(self.noobhack_dir, 0655) 129 | 130 | self.nethack = self.connect_to_game() 131 | self.output_proxy = proxy.Output(self.nethack) 132 | self.input_proxy = proxy.Input(self.nethack) 133 | 134 | # Create an in-memory terminal screen and register it's stream 135 | # processor with the output proxy. 136 | self.stream = vt102.stream() 137 | 138 | rows, cols = size() 139 | self.term = vt102.screen((rows, cols), self.options.encoding) 140 | self.term.attach(self.stream) 141 | self.output_proxy.register(self.stream.process) 142 | 143 | self.game = Game(self.term, self.plugins) 144 | 145 | self.output_proxy.register(self._restore_game_checker) 146 | self.output_proxy.register(self._game_started_checker) 147 | self.output_proxy.register(self._quit_or_died_checker) 148 | 149 | # Register the `toggle` key to open up the interactive nooback 150 | # assistant. 151 | self.input_proxy.register(self._toggle) 152 | 153 | def _quit_or_died_checker(self, data): 154 | """ 155 | Check to see if the player quit or died. In either case, we need to 156 | delete our, now pointless, save file. 157 | """ 158 | 159 | match = re.search("Do you want your possessions identified\\?", data) 160 | if match is not None: 161 | save.delete(self.save_file) 162 | self.playing = False 163 | self.output_proxy.unregister(self._quit_or_died_checker) 164 | 165 | def _start(self): 166 | self.manager = manager.Manager(self.term, 167 | self.output_proxy, 168 | self.input_proxy, 169 | events.Dispatcher()) 170 | 171 | if self.reloading: 172 | self.manager.load(self.save_file) 173 | 174 | self.helper = Helper(self.manager) 175 | self.minimap = Minimap() 176 | 177 | def _game_started_checker(self, data): 178 | """ 179 | Check to see if the game is playing or not. 180 | """ 181 | match = re.search("welcome( back)? to NetHack!", data) 182 | if match is not None: 183 | self.playing = True 184 | self._start() 185 | self.output_proxy.unregister(self._game_started_checker) 186 | 187 | def _restore_game_checker(self, data): 188 | match = re.search("Restoring save file...", data) 189 | if match is not None: 190 | self.reloading = True 191 | self.output_proxy.unregister(self._restore_game_checker) 192 | 193 | def connect_to_game(self): 194 | """ 195 | Fork the game, or connect to a foreign host to play. 196 | 197 | :return: A file like object of the game. Reading/writing is the same as 198 | accessing stdout/stdin in the game respectively. 199 | """ 200 | 201 | try: 202 | if self.options.local: 203 | conn = process.Local(self.options.debug) 204 | else: 205 | conn = telnet.Telnet( 206 | self.options.host, 207 | self.options.port, 208 | size() 209 | ) 210 | conn.open() 211 | except IOError, error: 212 | sys.stderr.write("Unable to open nethack: `%s'\n" % error) 213 | raise 214 | 215 | return conn 216 | 217 | def _toggle(self, key): 218 | """ 219 | Toggle between game mode and help mode. 220 | """ 221 | 222 | if key == self.toggle_help: 223 | if self.mode == "game": 224 | self.mode = "help" 225 | else: 226 | self.mode = "game" 227 | return False 228 | elif key == self.toggle_map: 229 | self.mode = "map" 230 | return False 231 | elif key == "!": 232 | self.mode = "debug" 233 | return False 234 | 235 | def _game_loop(self, window): 236 | if self.mode == "map": 237 | # Map mode handles it's own input. 238 | self.minimap.display(self.manager.dungeon.graph, window, self.toggle_map) 239 | self.mode = "game" 240 | 241 | self.game.redraw(window) 242 | 243 | if self.mode == "help": 244 | self.helper.redraw(window, self.options.crumbs) 245 | 246 | window.refresh() 247 | 248 | if self.playing: 249 | self.manager.save(self.save_file) 250 | 251 | # Let's wait until we have something to do... 252 | try: 253 | available = select.select( 254 | [self.nethack.fileno(), sys.stdin.fileno()], [], [] 255 | )[0] 256 | 257 | if sys.stdin.fileno() in available: 258 | self.input_proxy.proxy() 259 | 260 | if self.nethack.fileno() in available: 261 | self.output_proxy.proxy() 262 | 263 | except select.error as e: 264 | if e[0] != 4: 265 | raise 266 | 267 | def run(self, window): 268 | # We prefer to let the console pick the colors for the bg/fg instead of 269 | # using what curses thinks looks good. 270 | curses.use_default_colors() 271 | 272 | while True: 273 | self._game_loop(window) 274 | 275 | if __name__ == "__main__": 276 | locale.setlocale(locale.LC_ALL, "") 277 | 278 | hack = Noobhack() 279 | try: 280 | curses.wrapper(hack.run) 281 | except process.ProcError, e: 282 | pid, exit = os.wait() 283 | sys.stdout.write(e.stdout.read()) 284 | except IOError, e: 285 | print e 286 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from distutils.core import setup 2 | 3 | setup( 4 | name="noobhack", 5 | version="0.4", 6 | author="Sam Gibson", 7 | author_email="sam@ifdown.net", 8 | url="https://samfoo.github.com/noobhack", 9 | description="noobhack helps you ascend at nethack", 10 | long_description=open("readme.md", "r").read(), 11 | install_requires=["vt102 >= 0.3.3"], 12 | packages=["noobhack", "noobhack.game", "noobhack.ui"], 13 | scripts=["scripts/noobhack"], 14 | keywords="nethack noobhack nao hack rogue roguelike", 15 | license="GPLv3" 16 | ) 17 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/samfoo/noobhack/ffb4901202ba1c6493edb411856b70c3ec78a53a/tests/__init__.py -------------------------------------------------------------------------------- /tests/game/test_branch.py: -------------------------------------------------------------------------------- 1 | from noobhack.game.mapping import Branch 2 | 3 | from tests.utils import level_chain 4 | 5 | def test_single_level_chain_has_no_branches(): 6 | levels = level_chain(5, "main") 7 | branch = Branch(levels[0]) 8 | assert branch.sub_branches() == [] 9 | 10 | def test_single_level_chain_finds_start_when_initialized_with_middle_level(): 11 | levels = level_chain(5, "main") 12 | branch = Branch(levels[3]) 13 | assert branch.start == levels[0] 14 | 15 | def test_complex_map_finds_start_of_sub_branch_when_initialized_with_middle_level(): 16 | levels = level_chain(4, "main") 17 | levels[1].change_branch_to("mines") 18 | 19 | more_main = level_chain(3, "main", 2) 20 | more_main[0].add_stairs(levels[0], (5, 5)) 21 | levels[0].add_stairs(more_main[0], (5, 5)) 22 | 23 | sokoban = level_chain(2, "sokoban", 2) 24 | more_main[-1].add_stairs(sokoban[-1], (1, 1)) 25 | sokoban[-1].add_stairs(more_main[-1], (2, 2)) 26 | 27 | branch = Branch(levels[2]) 28 | assert branch.start == levels[1] 29 | 30 | def test_level_chain_with_one_junction_should_have_one_subbranch(): 31 | levels = level_chain(5, "main") 32 | levels[1].change_branch_to("mines") 33 | assert levels[0].has_a_branch() == True 34 | branch = Branch(levels[0]) 35 | assert len(branch.sub_branches()) == 1 36 | 37 | def test_level_chain_with_two_junctions_has_two_subbranches(): 38 | levels = level_chain(4, "main") 39 | levels[1].change_branch_to("mines") 40 | 41 | more_main = level_chain(3, "main", 2) 42 | more_main[0].add_stairs(levels[0], (5, 5)) 43 | levels[0].add_stairs(more_main[0], (5, 5)) 44 | 45 | sokoban = level_chain(2, "sokoban", 2) 46 | more_main[-1].add_stairs(sokoban[-1], (1, 1)) 47 | sokoban[-1].add_stairs(more_main[-1], (2, 2)) 48 | 49 | branch = Branch(levels[0]) 50 | assert len(branch.sub_branches()) == 2 51 | -------------------------------------------------------------------------------- /tests/game/test_buy_id.py: -------------------------------------------------------------------------------- 1 | from noobhack.game.shops import buy_identify 2 | 3 | def test_sucker_penalty(): 4 | assert set([ 5 | ("orcish dagger", 5, 12, "crude dagger"), 6 | ("orcish dagger", 7, 12, "crude dagger") 7 | ]) == buy_identify(11, "crude dagger", 6, True) 8 | 9 | def test_appearance_identify(): 10 | assert set([ 11 | ("orcish dagger", 5, 12, "crude dagger"), 12 | ("orcish dagger", 4, 12, "crude dagger") 13 | ]) == buy_identify(11, "crude dagger", 4) 14 | 15 | def test_appearch_id_with_random_markup(): 16 | assert set([ 17 | ("orcish dagger", 5, 12, "crude dagger"), 18 | ("orcish dagger", 4, 12, "crude dagger") 19 | ]) == buy_identify(11, "crude dagger", 5) 20 | 21 | def test_random_markup(): 22 | assert set([ 23 | ("death", 667, 5, None), 24 | ("wishing", 667, 5, None) 25 | ]) == buy_identify(11, "wand", 666) 26 | 27 | def test_charisma_modifier(): 28 | assert set([ 29 | ("death", 1000, 5, None), 30 | ("wishing", 1000, 5, None) 31 | ]) == buy_identify(5, "wand", 1000) 32 | 33 | assert set([ 34 | ("death", 750, 5, None), 35 | ("wishing", 750, 5, None) 36 | ]) == buy_identify(6, "wand", 750) 37 | 38 | assert set([ 39 | ("death", 667, 5, None), 40 | ("wishing", 667, 5, None) 41 | ]) == buy_identify(8, "wand", 666) 42 | 43 | assert set([ 44 | ("death", 500, 5, None), 45 | ("wishing", 500, 5, None) 46 | ]) == buy_identify(11, "wand", 500) 47 | 48 | assert set([ 49 | ("death", 375, 5, None), 50 | ("wishing", 375, 5, None) 51 | ]) == buy_identify(16, "wand", 375) 52 | 53 | assert set([ 54 | ("death", 333, 5, None), 55 | ("wishing", 333, 5, None) 56 | ]) == buy_identify(18, "wand", 334) 57 | 58 | assert set([ 59 | ("death", 250, 5, None), 60 | ("wishing", 250, 5, None) 61 | ]) == buy_identify(19, "wand", 250) 62 | -------------------------------------------------------------------------------- /tests/game/test_dungeon.py: -------------------------------------------------------------------------------- 1 | from noobhack.game.dungeon import Dungeon, Level, Map, looks_like_sokoban, looks_like_mines 2 | 3 | def test_sokoban_a(): 4 | display = [ 5 | "-------- ------", 6 | "|<|@..=---....|", 7 | "|^|-.00....0..|", 8 | "|^||..00|.0.0.|", 9 | "|^||....|.....|", 10 | "|^|------0----|", 11 | "|^| |......|", 12 | "|^------......|", 13 | "|..^^^^0000...|", 14 | "|..-----......|", 15 | "---- --------", 16 | ] 17 | 18 | assert looks_like_sokoban(display) 19 | 20 | def test_sokoban_b(): 21 | display = [ 22 | "------ ----- ", 23 | "|....| |...| ", 24 | "|.0..----.0.| ", 25 | "|.0......0..| ", 26 | "|..--->---0.| ", 27 | "|---------.---", 28 | "|..^^^<|.....|", 29 | "|..----|0....|", 30 | "--^| |.0...|", 31 | " |^-----.0...|", 32 | " |..^^^^0.0..|", 33 | " |??----------", 34 | " ---- ", 35 | ] 36 | 37 | assert looks_like_sokoban(display) 38 | 39 | def test_not_mines_even_with_headstone(): 40 | display = [ 41 | "-----", 42 | "|...-", 43 | "|.|.|", 44 | "|^..|", 45 | "|.[<|", 46 | "---.-", 47 | ] 48 | 49 | assert not looks_like_mines(display) 50 | 51 | def test_not_mines_even_with_strip(): 52 | display = [ 53 | "-- ", 54 | " ---- ", 55 | ] 56 | assert not looks_like_mines(display) 57 | 58 | -------------------------------------------------------------------------------- /tests/game/test_level.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from noobhack.game.mapping import Level 3 | 4 | from tests.utils import level_chain 5 | 6 | def test_changing_branches_changes_my_branch(): 7 | l = Level(1, "main") 8 | l.change_branch_to("mines") 9 | assert l.branch == "mines" 10 | 11 | def test_changing_branches_changes_my_childrens_branch(): 12 | levels = level_chain(3, "main") 13 | levels[0].change_branch_to("mines") 14 | 15 | assert all(l.branch == "mines" for l in levels) 16 | 17 | def test_changing_branches_changes_only_those_levels_that_are_below_the_branch_that_changed(): 18 | levels = level_chain(5, "main") 19 | levels[1].change_branch_to("mines") 20 | 21 | assert all(l.branch == "mines" for l in levels if l.dlvl > 1) 22 | assert levels[0].branch == "main" 23 | 24 | def test_level_with_branch_has_a_branch(): 25 | levels = level_chain(2, "main") 26 | levels[1].change_branch_to("mines") 27 | first = levels[0] 28 | 29 | assert first.has_a_branch() == True 30 | assert first.branches() == [levels[1]] 31 | 32 | def test_level_with_no_children_doesnt_have_a_branch(): 33 | l = Level(1, "main") 34 | 35 | assert l.has_a_branch() == False 36 | 37 | def test_level_with_only_one_child_that_doesnt_have_a_branch_has_no_branch(): 38 | levels = level_chain(2, "main") 39 | first = levels[0] 40 | 41 | assert first.has_a_branch() == False 42 | 43 | def test_changing_branch_to_sokoban_doesnt_change_children_branches(): 44 | levels = level_chain(5, "main") 45 | sokoban = Level(3) 46 | levels[4].add_stairs(sokoban, (3, 3)) 47 | sokoban.add_stairs(levels[4], (4, 4)) 48 | sokoban.change_branch_to("sokoban") 49 | 50 | assert sokoban.branch == "sokoban" 51 | assert levels[4].branch == "main" 52 | 53 | def test_is_a_junction_when_there_are_two_children(): 54 | main = level_chain(2, "main") 55 | mines = level_chain(2, "mines", start_at=2) 56 | main[0].add_stairs(mines[0], (2, 2)) 57 | mines[0].add_stairs(main[0], (1, 1)) 58 | 59 | assert main[0].is_a_junction() 60 | 61 | def test_is_not_a_junction_when_there_is_only_one_child(): 62 | main = level_chain(2, "main") 63 | 64 | assert not main[0].is_a_junction() 65 | 66 | def test_a_level_with_a_parent_and_a_child_is_not_a_junction(): 67 | main = level_chain(3, "main") 68 | 69 | assert not main[1].is_a_junction() 70 | 71 | def test_a_level_with_two_parents_is_a_junction(): 72 | main = level_chain(3, "main") 73 | sokoban = level_chain(2, "sokoban") 74 | 75 | main[2].add_stairs(sokoban[-1], (1, 1)) 76 | sokoban[-1].add_stairs(main[2], (2, 2)) 77 | 78 | assert main[2].is_a_junction() 79 | -------------------------------------------------------------------------------- /tests/game/test_manager.py: -------------------------------------------------------------------------------- 1 | from flexmock import flexmock 2 | 3 | from noobhack.game.events import Dispatcher 4 | from noobhack.game.manager import Manager 5 | 6 | stats = u"Sam the Evoker St:10 Dx:15 Co:11 In:17 Wi:12 Ch:10 Neutral" 7 | stats_dict = {"In": 17, "St": 10, "Dx": 15, "Co": 11, "Wi": 12, "Ch": 10} 8 | 9 | def mock_proxy(): 10 | return flexmock(register=lambda _: None) 11 | 12 | def mock_term(): 13 | return flexmock(cursor=lambda: (0, 0), 14 | display=[u" "]) 15 | 16 | def mock_events(): 17 | return flexmock(listen=lambda _, __: None) 18 | 19 | def test_dispatches_changes_in_stats(): 20 | events = mock_events() 21 | events.should_receive("dispatch") 22 | events.should_receive("dispatch")\ 23 | .with_args("stats-changed", stats_dict)\ 24 | .once() 25 | 26 | manager = Manager(mock_term(), mock_proxy(), mock_proxy(), events) 27 | 28 | manager.process(stats) 29 | 30 | def test_doesnt_dispatch_changes_in_stats_when_nothing_has_changed(): 31 | events = mock_events() 32 | events.should_receive("dispatch")\ 33 | .with_args("moved", (0, 0)) 34 | 35 | manager = Manager(mock_term(), mock_proxy(), mock_proxy(), events) 36 | manager.stats = stats_dict 37 | 38 | manager.process(stats) 39 | 40 | def test_doesnt_dispatch_changes_in_stats_when_no_stats_on_screen(): 41 | events = mock_events() 42 | 43 | events.should_receive("dispatch")\ 44 | .with_args("moved", (0, 0)) 45 | 46 | manager = Manager(mock_term(), mock_proxy(), mock_proxy(), events) 47 | manager.stats = stats_dict 48 | 49 | manager.process("this string doesn't have stats") 50 | 51 | -------------------------------------------------------------------------------- /tests/game/test_mapping.py: -------------------------------------------------------------------------------- 1 | from flexmock import flexmock 2 | 3 | from noobhack.game.mapping import Map, Level 4 | 5 | from tests.utils import level_chain 6 | 7 | def level(dlvl): 8 | return flexmock(Level(dlvl)) 9 | 10 | def test_walking_down_a_single_level_in_the_same_branch_that_doesnt_exists(): 11 | m = Map(level(1), 1, 1) 12 | 13 | second = flexmock(Level(2)) 14 | flexmock(Level).new_instances(second).once 15 | 16 | m.current.should_receive("add_stairs").with_args(second, (1, 1)).once 17 | second.should_receive("add_stairs").with_args(m.current, (2, 2)).once 18 | 19 | m.travel_by_stairs(2, (2, 2)) 20 | 21 | assert m.current == second 22 | 23 | def test_moving_changes_current_position(): 24 | m = Map(level(1), 1, 1) 25 | m.move(5, 5) 26 | 27 | assert m.location == (5, 5) 28 | 29 | def test_walking_up_a_single_level_in_the_same_branch_that_already_exists(): 30 | first = level(1) 31 | m = Map(first, 1, 1) 32 | 33 | second = flexmock(Level(2)) 34 | second.should_receive("add_stairs").once 35 | first.should_receive("add_stairs").once 36 | flexmock(Level).new_instances(second).once 37 | 38 | # Travel down once to create the second level 39 | m.travel_by_stairs(2, (2, 2)) 40 | 41 | second.should_receive("has_stairs_at").and_return(True).once 42 | second.should_receive("stairs_at").and_return(first).once 43 | 44 | # Now traveling back up should go back to the first 45 | m.travel_by_stairs(1, (1, 1)) 46 | 47 | assert len(m.levels) == 2 48 | assert m.current == first 49 | 50 | def test_traveling_down_an_existing_link_doesnt_create_a_new_link_or_level(): 51 | m = Map(level(1), 1, 1) 52 | 53 | second = Level(2) 54 | 55 | first = flexmock(Level(1)) 56 | first.should_receive("has_stairs_at").and_return(True).once 57 | first.should_receive("stairs_at").and_return(second) 58 | first.should_receive("add_stairs").never 59 | 60 | m.current = first 61 | 62 | m.travel_by_stairs(2, (2, 2)) 63 | 64 | assert m.current == second 65 | 66 | def test_traveling_down_when_a_link_doesnt_exist_creates_a_new_link_and_level(): 67 | m = Map(level(1), 1, 1) 68 | 69 | first = flexmock(Level(1)) 70 | first.should_receive("has_stairs_at").and_return(False).once 71 | first.should_receive("add_stairs").with_args(object, (1, 1)) 72 | 73 | m.current = first 74 | 75 | flexmock(Level).new_instances(flexmock(Level(5))).once 76 | 77 | m.travel_by_stairs(2, (2, 2)) 78 | 79 | assert len(m.levels) == 2 80 | 81 | def test_teleporting_to_an_undiscovered_level_doesnt_link(): 82 | m = Map(level(1), 1, 1) 83 | 84 | first = flexmock(Level(1)) 85 | m.current = first 86 | 87 | first.should_receive("add_stairs").never 88 | flexmock(Level).new_instances(flexmock(Level(5))).once 89 | 90 | m.travel_by_teleport(5, (5, 5)) 91 | 92 | assert m.current.dlvl == 5 93 | assert len(m.levels) == 2 94 | 95 | def test_moving_up_stairs_to_an_unknown_level_creates_a_new_link_and_level(): 96 | m = Map(level(1), 5, 5) 97 | 98 | fifth = flexmock(Level(5)) 99 | fifth.should_receive("has_stairs_at").and_return(False).once 100 | fifth.should_receive("add_stairs").with_args(object, (5, 5)) 101 | 102 | m.current = fifth 103 | 104 | m.travel_by_stairs(4, (4, 4)) 105 | 106 | assert m.current.dlvl == 4 107 | assert len(m.levels) == 2 108 | 109 | def test_moving_upstairs_from_to_a_known_level_that_isnt_yet_linked_with_the_current(): 110 | m = Map(level(1), 2, 2) 111 | 112 | first = flexmock(Level(1)) 113 | second = flexmock(Level(2)) 114 | 115 | m.levels = set([first, second]) 116 | m.current = second 117 | 118 | first.should_receive("add_stairs").with_args(second, (1, 1)).once 119 | second.should_receive("add_stairs").with_args(first, (2, 2)).once 120 | 121 | m.travel_by_stairs(1, (1, 1)) 122 | 123 | assert m.current.dlvl == 1 124 | 125 | def test_switching_a_branch_changes_the_current_level_to_a_new_branch(): 126 | m = Map(level(1), 2, 2) 127 | 128 | second = flexmock(Level(2)) 129 | second.should_receive("change_branch_to").with_args("mines").once 130 | m.levels.add(second) 131 | m.current = second 132 | m.change_branch_to("mines") 133 | 134 | def test_moving_down_main_then_up_one_and_down_to_the_mines_before_mines_are_identified(): 135 | levels = level_chain(3, "main") 136 | m = Map(levels[-1], 3, 2) 137 | m.levels = set(levels) 138 | m.travel_by_stairs(2, (2, 3)) 139 | 140 | assert len(m.levels) == 3 141 | 142 | m.move(4, 4) 143 | m.travel_by_stairs(3, (4, 4)) 144 | 145 | assert len(m.levels) == 4 146 | 147 | m.change_branch_to("mines") 148 | 149 | assert len([l for l in m.levels if l.branch == "main"]) == 3 150 | assert len([l for l in m.levels if l.branch == "mines"]) == 1 151 | -------------------------------------------------------------------------------- /tests/game/test_player.py: -------------------------------------------------------------------------------- 1 | from nose.tools import * 2 | 3 | from noobhack.game.player import Player 4 | from noobhack.game.events import Dispatcher 5 | 6 | stats_dict = {"In": 17, "St": 10, "Dx": 15, "Co": 11, "Wi": 12, "Ch": 10} 7 | 8 | def test_should_update_stats(): 9 | events = Dispatcher() 10 | player = Player(events) 11 | player.listen() 12 | 13 | events.dispatch("stats-changed", stats_dict) 14 | 15 | assert_equals(stats_dict, player.stats) 16 | 17 | def test_should_update_only_the_stat_that_changed(): 18 | events = Dispatcher() 19 | player = Player(events) 20 | player.stats = stats_dict 21 | player.listen() 22 | 23 | changes = {"Ch": 100} 24 | events.dispatch("stats-changed", changes) 25 | 26 | assert_equals(dict(stats_dict.items() + changes.items()), player.stats) 27 | 28 | -------------------------------------------------------------------------------- /tests/game/test_sell_id.py: -------------------------------------------------------------------------------- 1 | from noobhack.game.shops import sell_identify 2 | 3 | def test_sucker_penalty(): 4 | assert set([ 5 | ("orcish dagger", 1, 12, "crude dagger") 6 | ]) == sell_identify("crude dagger", 1, True) 7 | 8 | def test_appearance_identify(): 9 | # the shop functions return both(all) the possible prices. Updated test to match 10 | assert set([ 11 | ("orcish dagger", 2, 12, "crude dagger"), 12 | ("orcish dagger", 1, 12, "crude dagger") 13 | ]) == sell_identify("crude dagger", 2) 14 | 15 | def test_random_markdown(): 16 | assert set([ 17 | ("death", 125, 5, None), 18 | ("wishing", 125, 5, None) 19 | ]) == sell_identify("wand", 125) 20 | -------------------------------------------------------------------------------- /tests/plugins/test_hp_color.py: -------------------------------------------------------------------------------- 1 | from flexmock import flexmock 2 | from nose.tools import * 3 | 4 | from noobhack.game.plugins.hp_color import * 5 | 6 | def test_text_should_be_white_on_red_when_ratio_is_less_or_equal_to_25_percent(): 7 | assert_equal( 8 | (["bold"], "white", "red"), 9 | color_for_ratio(0.15) 10 | ) 11 | assert_equal( 12 | (["bold"], "white", "red"), 13 | color_for_ratio(0.25) 14 | ) 15 | 16 | def test_text_should_be_yellow_when_ratio_is_less_or_equal_to_50_percent(): 17 | assert_equal( 18 | (["bold"], "yellow", "default"), 19 | color_for_ratio(0.5) 20 | ) 21 | assert_equal( 22 | (["bold"], "yellow", "default"), 23 | color_for_ratio(0.3) 24 | ) 25 | 26 | def test_text_should_be_green_when_ratio_is_greater_than_50_percent(): 27 | assert_equal( 28 | (["bold"], "green", "default"), 29 | color_for_ratio(0.51) 30 | ) 31 | 32 | 33 | def test_redraw_should_set_the_attributes_on_the_hp_text_in_terminal(): 34 | with_hp = "Dlvl:1 $:0 HP:16(16) Pw:6(6) AC:3 Xp:1/0 T:1" 35 | term = flexmock(display=[with_hp], 36 | attributes=[[()] * len(with_hp)]) 37 | 38 | redraw(term) 39 | 40 | expected = (['bold'], 'green', 'default') 41 | assert_equal([expected] * 6, term.attributes[0][16:22]) 42 | -------------------------------------------------------------------------------- /tests/ui/test_curses_interface.py: -------------------------------------------------------------------------------- 1 | import curses 2 | 3 | from flexmock import flexmock 4 | 5 | from noobhack.ui.common import size 6 | from noobhack.ui.minimap import Minimap 7 | from noobhack.game.mapping import Level, Map 8 | 9 | from tests.utils import level_chain, MemoryPad 10 | 11 | def get_color(_): 12 | return 0 13 | 14 | def fixed_graph(levels=None): 15 | if levels is None: 16 | levels = level_chain(5, "main") 17 | dmap = Map(levels[0], 0, 0) 18 | dmap.levels = set(levels) 19 | return dmap 20 | 21 | def newpad(): 22 | pad = flexmock(MemoryPad()) 23 | return pad 24 | 25 | def test_color_doesnt_color_the_ellipsis(): 26 | window = newpad() 27 | window.should_receive("chgat").times(3) 28 | 29 | m = Minimap() 30 | dungeon = fixed_graph() 31 | dungeon.current = [l for l in dungeon.levels if l.dlvl == 5][0] 32 | m.draw_dungeon(dungeon, window, 0, 0, get_color) 33 | 34 | def test_color_only_the_text_not_the_border(): 35 | window = newpad() 36 | window.should_receive("chgat").with_args(3, 1, 25, curses.A_BOLD | 0).times(1) 37 | window.should_receive("chgat").with_args(4, 1, 25, curses.A_BOLD | 0).times(1) 38 | window.should_receive("chgat").with_args(5, 1, 25, curses.A_BOLD | 0).times(1) 39 | 40 | m = Minimap() 41 | dungeon = fixed_graph() 42 | m.draw_dungeon(dungeon, window, 0, 0, get_color) 43 | 44 | def test_drawing_the_graph_draws_the_current_level_in_a_different_color(): 45 | window = newpad() 46 | window.should_receive("chgat").times(3) 47 | 48 | m = Minimap() 49 | dungeon = fixed_graph() 50 | m.draw_dungeon(dungeon, window, 0, 0, get_color) 51 | 52 | def test_drawing_a_graph_with_multiple_branches_colors_a_current_level_on_the_main_branch(): 53 | levels = level_chain(3, "main") 54 | levels[1].change_branch_to("mines") 55 | 56 | window = newpad() 57 | window.should_receive("chgat").with_args(3, 1, 25, curses.A_BOLD | 0).times(1) 58 | window.should_receive("chgat").with_args(4, 1, 25, curses.A_BOLD | 0).times(1) 59 | window.should_receive("chgat").with_args(5, 1, 25, curses.A_BOLD | 0).times(1) 60 | 61 | m = Minimap() 62 | dungeon = fixed_graph(levels) 63 | m.draw_dungeon(dungeon, window, 0, 0, get_color) 64 | 65 | def test_drawing_a_graph_with_multiple_branches_colors_a_current_level_off_the_main_branche(): 66 | levels = level_chain(3, "main") 67 | levels[1].change_branch_to("mines") 68 | 69 | window = newpad() 70 | window.should_receive("chgat").with_args(5, 31, 25, curses.A_BOLD | 0).times(1) 71 | window.should_receive("chgat").with_args(6, 31, 25, curses.A_BOLD | 0).times(1) 72 | window.should_receive("chgat").with_args(7, 31, 25, curses.A_BOLD | 0).times(1) 73 | 74 | m = Minimap() 75 | dungeon = fixed_graph(levels) 76 | dungeon.current = levels[1] 77 | m.draw_dungeon(dungeon, window, 0, 0, get_color) 78 | 79 | def test_drawing_a_dungeon_with_one_branch_properly_computes_bounds(): 80 | levels = level_chain(3, "main") 81 | window = newpad() 82 | m = Minimap() 83 | dungeon = fixed_graph(levels) 84 | left, right, top, bottom = m.draw_dungeon(dungeon, window, 0, 0, get_color)[:-2] 85 | 86 | assert left == 0 87 | assert right == 27 88 | assert top == 0 89 | assert bottom == 13 90 | 91 | def test_drawing_a_dungeon_with_one_branch_offset_properly_computes_bounds(): 92 | levels = level_chain(3, "main") 93 | window = newpad() 94 | m = Minimap() 95 | dungeon = fixed_graph(levels) 96 | left, right, top, bottom = m.draw_dungeon(dungeon, window, 10, 10, get_color)[:-2] 97 | 98 | assert left == 10 99 | assert right == 37 100 | assert top == 10 101 | assert bottom == 23 102 | 103 | def test_drawing_a_dungeon_with_multiple_branches_properly_computes_bounds(): 104 | levels = level_chain(3, "main") 105 | levels[1].change_branch_to("mines") 106 | 107 | window = newpad() 108 | m = Minimap() 109 | dungeon = fixed_graph(levels) 110 | left, right, top, bottom = m.draw_dungeon(dungeon, window, 0, 0, get_color)[:-2] 111 | 112 | assert left == 0 113 | assert right == 57 114 | assert top == 0 115 | assert bottom == 12 116 | 117 | def test_drawing_a_dungeon_with_multiple_sub_branches_properly_computes_bounds(): 118 | levels = level_chain(4, "main") 119 | levels[1].change_branch_to("mines") 120 | levels[2].change_branch_to("other") 121 | more_main = level_chain(3, "main", 2) 122 | more_main[0].add_stairs(levels[0], (5, 5)) 123 | levels[0].add_stairs(more_main[0], (5, 5)) 124 | 125 | window = newpad() 126 | m = Minimap() 127 | dungeon = fixed_graph(levels + more_main) 128 | left, right, top, bottom = m.draw_dungeon(dungeon, window, 0, 0, get_color)[:-2] 129 | 130 | assert left == 0 131 | assert right == 87 132 | assert top == 0 133 | assert bottom == 16 134 | 135 | def test_a_current_level_in_a_single_branch_is_properly_bounded(): 136 | levels = level_chain(3, "main") 137 | window = newpad() 138 | m = Minimap() 139 | dungeon = fixed_graph(levels) 140 | x, y = m.draw_dungeon(dungeon, window, 0, 0, get_color)[-2:] 141 | 142 | assert x == 0 143 | assert y == 3 144 | 145 | def test_a_current_level_in_the_middle_of_a_single_branch_is_properly_bounded(): 146 | levels = level_chain(3, "main") 147 | window = newpad() 148 | m = Minimap() 149 | dungeon = fixed_graph(levels) 150 | dungeon.current = levels[1] 151 | x, y = m.draw_dungeon(dungeon, window, 0, 0, get_color)[-2:] 152 | 153 | assert x == 0 154 | assert y == 6 155 | 156 | def test_a_current_level_on_a_second_branch_is_properly_bounded(): 157 | levels = level_chain(3, "main") 158 | levels[1].change_branch_to("mines") 159 | 160 | window = newpad() 161 | m = Minimap() 162 | dungeon = fixed_graph(levels) 163 | dungeon.current = levels[1] 164 | x, y = m.draw_dungeon(dungeon, window, 0, 0, get_color)[-2:] 165 | 166 | assert x == 30 167 | assert y == 5 168 | 169 | -------------------------------------------------------------------------------- /tests/ui/test_drawing_individual_branches.py: -------------------------------------------------------------------------------- 1 | from flexmock import flexmock 2 | 3 | from noobhack.ui.minimap import Minimap 4 | from noobhack.game.mapping import Level, Branch 5 | 6 | def expect_dlvl_at_index(dlvl, index, branch): 7 | m = Minimap() 8 | indices, _ = m.unconnected_branch_as_buffer_with_indices("", branch) 9 | assert indices[dlvl] == index 10 | 11 | def expect(branch, results): 12 | m = Minimap() 13 | _, buf = m.unconnected_branch_as_buffer_with_indices("Dungeons of Doom", branch) 14 | assert buf == results 15 | 16 | def test_drawing_a_branch_draws_the_header_and_the_border(): 17 | level = Level(1) 18 | expect([level], [ 19 | ".-------------------------.", 20 | "| Dungeons of Doom |", 21 | "|=========================|", 22 | "| Level 1: |", 23 | "| (nothing interesting) |", 24 | "| |", 25 | "' ... '" 26 | ]) 27 | 28 | def test_drawing_a_branch_with_multiple_levels_draws_all_the_levels(): 29 | levels = [Level(1), Level(2), Level(3)] 30 | expect(levels, [ 31 | ".-------------------------.", 32 | "| Dungeons of Doom |", 33 | "|=========================|", 34 | "| Level 1: |", 35 | "| (nothing interesting) |", 36 | "| |", 37 | "| Level 2: |", 38 | "| (nothing interesting) |", 39 | "| |", 40 | "| Level 3: |", 41 | "| (nothing interesting) |", 42 | "| |", 43 | "' ... '" 44 | ]) 45 | 46 | def test_drawing_a_branch_with_multiple_levels_that_arent_monotonically_increasing_puts_an_ellipses_between_disjoins(): 47 | levels = [Level(1), Level(5)] 48 | expect(levels, [ 49 | ".-------------------------.", 50 | "| Dungeons of Doom |", 51 | "|=========================|", 52 | "| Level 1: |", 53 | "| (nothing interesting) |", 54 | "| |", 55 | "| ... |", 56 | "| |", 57 | "| Level 5: |", 58 | "| (nothing interesting) |", 59 | "| |", 60 | "' ... '" 61 | ]) 62 | 63 | def test_a_branch_with_only_a_single_level_can_reference_that_level_at_the_right_index(): 64 | levels = [Level(1)] 65 | expect_dlvl_at_index(1, 3, levels) 66 | 67 | def test_a_branch_with_disconnected_levels_has_the_furthest_at_the_right_index(): 68 | levels = [Level(1), Level(5)] 69 | expect_dlvl_at_index(5, 8, levels) 70 | 71 | def test_a_branch_with_multiple_adjacent_levels_has_the_furthest_at_the_right_index(): 72 | levels = [Level(1), Level(2), Level(3)] 73 | expect_dlvl_at_index(3, 9, levels) 74 | -------------------------------------------------------------------------------- /tests/ui/test_drawing_individual_levels.py: -------------------------------------------------------------------------------- 1 | from noobhack.ui.minimap import Minimap 2 | from noobhack.game.mapping import Level 3 | 4 | def expect(level, results): 5 | m = Minimap() 6 | buf = m.level_text_as_buffer(level) 7 | assert buf == results 8 | 9 | def test_drawing_a_single_levels_buffer_with_an_empty_level_writes_nothing_interesting(): 10 | level = Level(1) 11 | assert len(level.features) == 0 12 | expect(level, [ 13 | "Level 1:", 14 | " (nothing interesting)" 15 | ]) 16 | 17 | def test_drawing_a_single_level_draws_the_correct_dlvl(): 18 | level = Level(5) 19 | expect(level, [ 20 | "Level 5:", 21 | " (nothing interesting)" 22 | ]) 23 | 24 | def test_drawing_a_single_level_draws_all_the_features(): 25 | level = Level(1) 26 | level.features.add("Altar (neutral)") 27 | level.features.add("Altar (chaotic)") 28 | expect(level, [ 29 | "Level 1:", 30 | " * Altar (chaotic)", 31 | " * Altar (neutral)", 32 | ]) 33 | 34 | def test_drawing_features_sorts_them_first(): 35 | from random import shuffle, seed 36 | seed(3141) 37 | 38 | level = Level(1) 39 | features = [str(i) for i in xrange(1, 10)] 40 | shuffle(features) 41 | assert features[0] != 1 42 | level.features.update(features) 43 | 44 | expected = [ 45 | "Level 1:" 46 | ] + [" * %s" % i for i in xrange(1, 10)] 47 | expect(level, expected) 48 | 49 | def test_drawing_a_single_shop_indents_and_translates(): 50 | level = Level(1) 51 | level.shops.add("random") 52 | 53 | expect(level, [ 54 | "Level 1:", 55 | " Shops:", 56 | " * Random", 57 | ]) 58 | 59 | def test_drawing_features_and_shops_draws_the_shops_first(): 60 | level = Level(1) 61 | level.features.update(["Fountain", "Oracle"]) 62 | level.shops.add("90/10 arm/weap") 63 | 64 | expect(level, [ 65 | "Level 1:", 66 | " Shops:", 67 | " * 90/10 arm/weap", 68 | " * Fountain", 69 | " * Oracle" 70 | ]) 71 | 72 | -------------------------------------------------------------------------------- /tests/ui/test_drawing_multiple_branches.py: -------------------------------------------------------------------------------- 1 | import curses 2 | 3 | from flexmock import flexmock 4 | 5 | from noobhack.ui.common import size 6 | from noobhack.ui.minimap import Minimap 7 | from noobhack.game.mapping import Level, Map 8 | 9 | from tests.utils import level_chain, MemoryPad 10 | 11 | def get_color(_): 12 | return 0 13 | 14 | def graph(levels=None): 15 | if levels is None: 16 | levels = level_chain(5, "main") 17 | dmap = Map(levels[0], 0, 0) 18 | dmap.levels = set(levels) 19 | return dmap 20 | 21 | def expect(dungeon, results, x_offset=0, y_offset=0): 22 | m = Minimap() 23 | pad = flexmock(MemoryPad()) 24 | pad.should_receive("chgat") 25 | m.draw_dungeon(dungeon, pad, x_offset, y_offset, get_color) 26 | assert results == pad.buf 27 | 28 | def test_drawing_a_graph_with_mines(): 29 | levels = level_chain(3, "main") 30 | levels[1].change_branch_to("mines") 31 | 32 | dungeon = graph(levels) 33 | expect(dungeon, [ 34 | ".-------------------------.", 35 | "| main |", 36 | "|=========================| .-------------------------.", 37 | "| Level 1: |\ | mines |", 38 | "| (nothing interesting) | \ |=========================|", 39 | "| | *| Level 2: |", 40 | "' ... ' | (nothing interesting) |", 41 | " | |", 42 | " | Level 3: |", 43 | " | (nothing interesting) |", 44 | " | |", 45 | " ' ... '", 46 | ]) 47 | 48 | def test_drawing_a_graph_with_mines_and_a_parallel_main(): 49 | levels = level_chain(4, "main") 50 | levels[1].change_branch_to("mines") 51 | more_main = level_chain(3, "main", 2) 52 | more_main[0].add_stairs(levels[0], (5, 5)) 53 | levels[0].add_stairs(more_main[0], (5, 5)) 54 | 55 | dungeon = graph(levels + more_main) 56 | expect(dungeon, [ 57 | ".-------------------------.", 58 | "| main |", 59 | "|=========================| .-------------------------.", 60 | "| Level 1: |\ | mines |", 61 | "| (nothing interesting) | \ |=========================|", 62 | "| | *| Level 2: |", 63 | "| Level 2: | | (nothing interesting) |", 64 | "| (nothing interesting) | | |", 65 | "| | | Level 3: |", 66 | "| Level 3: | | (nothing interesting) |", 67 | "| (nothing interesting) | | |", 68 | "| | | Level 4: |", 69 | "| Level 4: | | (nothing interesting) |", 70 | "| (nothing interesting) | | |", 71 | "| | ' ... '", 72 | "' ... '", 73 | ]) 74 | 75 | def test_drawing_a_graph_with_mines_that_have_a_branch_themselves(): 76 | levels = level_chain(4, "main") 77 | levels[1].change_branch_to("mines") 78 | levels[2].change_branch_to("other") 79 | more_main = level_chain(3, "main", 2) 80 | more_main[0].add_stairs(levels[0], (5, 5)) 81 | levels[0].add_stairs(more_main[0], (5, 5)) 82 | 83 | dungeon = graph(levels + more_main) 84 | expect(dungeon, [ 85 | ".-------------------------.", 86 | "| main |", 87 | "|=========================| .-------------------------.", 88 | "| Level 1: |\ | mines |", 89 | "| (nothing interesting) | \ |=========================| .-------------------------.", 90 | "| | *| Level 2: |\ | other |", 91 | "| Level 2: | | (nothing interesting) | \ |=========================|", 92 | "| (nothing interesting) | | | *| Level 3: |", 93 | "| | ' ... ' | (nothing interesting) |", 94 | "| Level 3: | | |", 95 | "| (nothing interesting) | | Level 4: |", 96 | "| | | (nothing interesting) |", 97 | "| Level 4: | | |", 98 | "| (nothing interesting) | ' ... '", 99 | "| |", 100 | "' ... '", 101 | ]) 102 | 103 | def test_drawing_a_graph_with_sokoban(): 104 | main = level_chain(4, "main") 105 | sokoban = level_chain(2, "sokoban", 2) 106 | 107 | main[3].add_stairs(sokoban[-1], (1, 1)) 108 | sokoban[-1].add_stairs(main[3], (2, 2)) 109 | 110 | dungeon = graph(main + sokoban) 111 | expect(dungeon, [ 112 | ".-------------------------.", 113 | "| main |", 114 | "|=========================| .-------------------------.", 115 | "| Level 1: | | sokoban |", 116 | "| (nothing interesting) | |=========================|", 117 | "| | | Level 2: |", 118 | "| Level 2: | | (nothing interesting) |", 119 | "| (nothing interesting) | | |", 120 | "| | | Level 3: |", 121 | "| Level 3: | | (nothing interesting) |", 122 | "| (nothing interesting) | *| |", 123 | "| | / '-------------------------'", 124 | "| Level 4: |/", 125 | "| (nothing interesting) |", 126 | "| |", 127 | "' ... '", 128 | ]) 129 | 130 | def test_drawing_a_graph_with_both_mines_and_sokoban(): 131 | levels = level_chain(4, "main") 132 | levels[1].change_branch_to("mines") 133 | more_main = level_chain(3, "main", 2) 134 | more_main[0].add_stairs(levels[0], (5, 5)) 135 | levels[0].add_stairs(more_main[0], (5, 5)) 136 | 137 | sokoban = level_chain(2, "sokoban", 2) 138 | 139 | more_main[-1].add_stairs(sokoban[-1], (1, 1)) 140 | sokoban[-1].add_stairs(more_main[-1], (2, 2)) 141 | 142 | dungeon = graph(levels + more_main + sokoban) 143 | expect(dungeon, [ 144 | " .-------------------------.", 145 | " | main |", 146 | " .-------------------------. |=========================| .-------------------------.", 147 | " | sokoban | | Level 1: |\ | mines |", 148 | " |=========================| | (nothing interesting) | \ |=========================|", 149 | " | Level 2: | | | *| Level 2: |", 150 | " | (nothing interesting) | | Level 2: | | (nothing interesting) |", 151 | " | | | (nothing interesting) | | |", 152 | " | Level 3: | | | | Level 3: |", 153 | " | (nothing interesting) |* | Level 3: | | (nothing interesting) |", 154 | " | | \ | (nothing interesting) | | |", 155 | " '-------------------------' \| | | Level 4: |", 156 | " | Level 4: | | (nothing interesting) |", 157 | " | (nothing interesting) | | |", 158 | " | | ' ... '", 159 | " ' ... '", 160 | ], 50, 0) 161 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from noobhack.game.mapping import Level 2 | 3 | def level_chain(size, branch, start_at=1): 4 | def link(first, second): 5 | first.add_stairs(second, (first.dlvl, second.dlvl)) 6 | second.add_stairs(first, (second.dlvl, first.dlvl)) 7 | return second 8 | 9 | levels = [Level(i, branch) for i in xrange(start_at, size + start_at)] 10 | reduce(link, levels) 11 | return levels 12 | 13 | class MemoryPad: 14 | def __init__(self): 15 | self.buf = [] 16 | 17 | def chgat(self, *args): 18 | pass 19 | 20 | def getmaxyx(self): 21 | # This number doesn't really matter, it just needs to be bigger than 22 | # the space that's going to be drawn to. 23 | return (9999, 9999) 24 | 25 | def addstr(self, y_offset, x_offset, text): 26 | if len(self.buf) <= y_offset: 27 | self.buf.extend([""] * (y_offset - len(self.buf) + 1)) 28 | line = self.buf[y_offset] 29 | if len(line) <= (x_offset + len(text)): 30 | line += " " * ((x_offset + len(text)) - len(line)) 31 | line = line[:x_offset] + text + line[x_offset+len(text):] 32 | self.buf[y_offset] = line 33 | 34 | def __str__(self): 35 | return "\n".join(self.buf) 36 | 37 | -------------------------------------------------------------------------------- /todo.md: -------------------------------------------------------------------------------- 1 | # Noobhack 0.4 2 | 3 | * Plugin features 4 | * Should support hooking into events, and redrawing 5 | * Should support input (As a player, I want to write a pudding farming bot...) 6 | * Track spellbook read count 7 | * Track spell learned turn 8 | * Monitor pray safety 9 | * Input macros (elbereth) 10 | * Smash all the grid bugzeseseses 11 | 12 | # Future 13 | 14 | * Overlay known maps on screen as grey when detected (e.g. sokoban) 15 | 16 | # Misc. 17 | 18 | * Full support for all dungeon branch detection 19 | * Branch porting that isn't buggy 20 | * Determine if the player is a "sucker" when price id'ing 21 | * When the player is below the overlay, move the overlay up top 22 | --------------------------------------------------------------------------------