ifmapper 0.7 → 0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,748 @@
1
+
2
+
3
+ #
4
+ # Class used to parse an Infocom-style transcript and automatically
5
+ # generate a map. This can be used to automatically map a game as
6
+ # you play it.
7
+ #
8
+ # This code is based on Perl code by Dave Chapeskie, used in IFM,
9
+ # albeit it has been enhanced to handle multi-line commands, room stub
10
+ # exits, objects parsing and the ability to parse transcripts as
11
+ # they are being spit out.
12
+ #
13
+ class TranscriptReader
14
+
15
+ PROMPT = /^>\s*/
16
+ LOOK = /^l(ook)?/i
17
+ UNDO = /^undo$/i
18
+ TELEPORT = /^(restart|restore)$/i
19
+ IGNORE = /transcript/i
20
+ OTHERS = /^(read|inventory$|i$)/i
21
+ UNSCRIPT = /^unscript$/i
22
+ BLANK = /^\s*$/
23
+ MOVE = /^(walk|run|go)\s+/i
24
+ TAKE = /^(take|get)\s+(a\s+|the\s+)?(.*)/i
25
+ DROP = /^(drop|leave)\s+()/i
26
+ STARTUP = /(^[A-Z]+$|Copyright|\([cC]\)\s*\d|Trademark|Release|Version|[Ss]erial [Nn]umber|Written by)/
27
+ DARKNESS = /dark/i
28
+ DEAD = /(You die|You have died|You are dead)/i
29
+
30
+ # Compass direction command -> direction mapping.
31
+ DIRMAP = {
32
+ "n" => 0, "north" => 0, "ne" => 1, "northeast" => 1,
33
+ "e" => 2, "east" => 2, "southeast" => 3, "se" => 3,
34
+ "south" => 4, "s" => 4, "southwest" => 5, "sw" => 5,
35
+ "west" => 6, "w" => 6, "northwest" => 7, "nw" => 7
36
+ }
37
+
38
+ ODIRMAP = {"up" => 1, "u" => 1, "down" => 2, "d" => 2,
39
+ "in" => 3, "out" => 4, 'enter' => 3, 'exit' => 4 }
40
+
41
+ # Direction list in order of positioning preference.
42
+ DIRLIST = [ 0, 4, 2, 6, 1, 3, 5, 7 ]
43
+
44
+ NAME_REMOVE = /(\s+\(.+\)|,\s+[io]n\s+.+)/ # remove things like (on the bed)
45
+ NAME_INVALID = /[\[\]\.!\?]/
46
+ NAME_MAXWORDS = 20
47
+ NAME_MAXUNCAP = 4 # so that lowercase room/end will be accepted
48
+
49
+ # Default room description recognition parameters.
50
+ DESC_MINWORDS = 10
51
+
52
+
53
+ ## Change this to non-dil to print out debugging info
54
+ @@debug = nil
55
+
56
+
57
+ def debug(*msg)
58
+ if @@debug
59
+ $stdout.puts msg
60
+ end
61
+ end
62
+
63
+ ## Possible nessages indicating get/take succeeded
64
+ TAKE_OK = [
65
+ /taken/i,
66
+ /you\s+now\s+have\s+(got\s+)?the/i,
67
+ ]
68
+
69
+ IT = /^(it|them)$/
70
+
71
+ #
72
+ # Handle a take action
73
+ #
74
+ def take(move, objs)
75
+ return unless @here
76
+ objs.each { |cmd, dummy, obj|
77
+ next if not obj
78
+
79
+ objlist = obj.split(',')
80
+ o = objlist[0]
81
+ if objlist.size == 1 and o != 'all'
82
+ # ignore 'get up'
83
+ next if cmd == 'get' and o == 'up'
84
+ o = @last_obj if o =~ IT
85
+ next if not o
86
+ status = move[:reply].to_s
87
+ TAKE_OK.each { |re|
88
+ if status =~ re and not @objects.has_key?(o)
89
+ @here.objects << o << "\n"
90
+ @objects[o] = 1
91
+ @last_obj = o
92
+ break
93
+ end
94
+ }
95
+ else
96
+ # Handle multiple objects
97
+ move[:reply].each { |reply|
98
+ o, status = reply.split(':')
99
+ next if not status
100
+ o = @last_obj if o =~ IT
101
+ if o and not @objects.has_key?(o)
102
+ @here.objects << o << "\n"
103
+ @objects[o] = 1
104
+ @last_obj = o
105
+ end
106
+ }
107
+ end
108
+ }
109
+ end
110
+
111
+ #
112
+ # Add a new room to the automapper
113
+ #
114
+ def add(room)
115
+ @rooms[room] = {:section => @map.section, :desc => nil }
116
+ end
117
+
118
+ #
119
+ # Remove some rooms from the automap knowledge (user removed these manually)
120
+ #
121
+ def remove(rooms)
122
+ rooms.each { |r| @rooms.delete(r) }
123
+ end
124
+
125
+
126
+ def parse
127
+ # Find first command prompt
128
+ while line = @f.gets
129
+ break if PROMPT =~ line
130
+ end
131
+ parse_line(line)
132
+ end
133
+
134
+ #
135
+ # Given a room description, parse it to try to find out all exits
136
+ # from room.
137
+ #
138
+
139
+ DIRS = {
140
+ 'north' => 0, 'northeast' => 1, 'east' => 2, 'southeast' => 3, 'south' => 4,
141
+ 'southwest' => 5, 'west' => 6, 'northwest' => 7
142
+ }
143
+
144
+ DIR = '(' + DIRS.keys.join('|') + ')'
145
+ OR = '(and|or)'
146
+
147
+ EXITS_REGEX =
148
+ [
149
+ # You can go south or east
150
+ /you\s+can\s+go\s+#{DIR}\s+#{OR}\s+#{DIR}[,\.\s+]/i,
151
+ # You can go south
152
+ /you\s+can\s+go\s+#{DIR}[,\.\s+]/i,
153
+ # to the east or west
154
+ /to\s+the\s+#{DIR}\s+#{OR}\s+#{DIR}[,\.\s+]/i,
155
+ # to the east
156
+ /to\s+the\s+#{DIR}[,\.\s+]/i,
157
+ # paths lead west and north
158
+ /(run|lead|wander|winds)\s+#{DIR}\s+#{OR}\s+#{DIR}[,\.\s+]/i,
159
+ # east-west corridor
160
+ /[\.\s+]#{DIR}[\/-]#{DIR}[,\.\s+]/i,
161
+ # East is the postoffice
162
+ /[,\.\s+]#{DIR}\s+is/i,
163
+ # continues|lies|etc... east
164
+ /(runs|leads|heads|opens|winds|continues|branches|lies|wanders|bends|curves)\s+#{DIR}[,\.\s+]/i,
165
+ /(running|leading|heading|opening|branching|lying|wandering|looking|bending)\s+#{DIR}[,\.\s+]/i,
166
+ ]
167
+
168
+ EXITS_SPECIAL = {
169
+ /four\s+directions/i => [0, 2, 4, 6],
170
+ /four\s+compass\s+points/i => [0, 2, 4, 6],
171
+ /all\s+directions/i => [0, 1, 2, 3, 4, 5, 6, 7],
172
+ }
173
+
174
+ def parse_exits(r, desc)
175
+ return if not desc
176
+ exits = []
177
+
178
+ # Remove \n from descriptions
179
+ desc.gsub(/\n/, ' ')
180
+
181
+ # Now, start searching for stuff
182
+
183
+ # First try the special directions...
184
+ EXITS_SPECIAL.each { |re, dirs|
185
+ if desc =~ re
186
+ exits += dirs
187
+ break
188
+ end
189
+ }
190
+
191
+ # If that failed, start searching for exits
192
+ if exits.empty?
193
+ EXITS_REGEX.each { |re|
194
+ matches = desc.scan(re)
195
+ next if matches.empty?
196
+ matches.each { |arr|
197
+ arr.each { |m|
198
+ next unless DIRS[m]
199
+ exits << DIRS[m]
200
+ }
201
+ }
202
+ }
203
+ end
204
+
205
+ exits.uniq!
206
+
207
+ # Add a 'stub' for the new connection
208
+ exits.each { |exit|
209
+ next if r[exit]
210
+ c = @map.new_connection( r, exit, nil )
211
+ c.dir = Connection::AtoB
212
+ debug "\tADDED STUB #{c}"
213
+ }
214
+ end
215
+
216
+ def parse_line(line)
217
+ return unless line
218
+
219
+ @moves.clear
220
+
221
+ #
222
+ # Read all commands
223
+ #
224
+ loop do
225
+ line.sub!(PROMPT, '')
226
+ line.chop!
227
+ line.sub!(/\s+$/, '')
228
+ cmd = line
229
+
230
+ # Read reply
231
+ reply = []
232
+ while line = @f.gets
233
+ break if line =~ PROMPT
234
+ line.chop!
235
+ line.sub!(/\s+$/,'')
236
+ reply << line
237
+ end
238
+
239
+ break if not line
240
+
241
+ if cmd =~ UNDO
242
+ @moves.pop
243
+ else
244
+ move = { }
245
+ move[:cmd] = cmd
246
+ move[:reply] = reply
247
+ @moves << move
248
+ end
249
+
250
+ end
251
+
252
+
253
+ # Step 2
254
+ tele = nil
255
+ @moves.each { |move|
256
+ cmd = move[:cmd]
257
+ next if cmd =~ IGNORE or cmd =~ OTHERS
258
+ if cmd =~ UNSCRIPT
259
+ tele = 1
260
+ next
261
+ end
262
+
263
+ tele = 1 if cmd =~ TELEPORT
264
+
265
+ name = nil
266
+ desc = ''
267
+ roomflag = false
268
+ desc_gap = false
269
+ startup = false
270
+ rooms = []
271
+ move[:reply].each { |r|
272
+ tele = 1 if r =~ DEAD
273
+
274
+ if r =~ STARTUP
275
+ # Dealing with a startup message, such as:
276
+ # MY GAME
277
+ # Copyright (C) 1984 ComputerQuest
278
+ # We skip the whole thing until the next blank line
279
+ debug "#{r} skipped due to startup"
280
+ startup = true
281
+ desc = ''
282
+ name = nil
283
+ roomflag = false
284
+ desc_gap = false
285
+ next
286
+ end
287
+ next if startup and r !~ BLANK
288
+ startup = false
289
+
290
+ if not roomflag and r !~ BLANK and roomname(r)
291
+ debug "set roomflag with #{r}"
292
+ roomflag = true
293
+ name = r
294
+ next
295
+ end
296
+
297
+ break if roomflag and desc_gap and r =~ BLANK
298
+
299
+ desc_gap = true if roomflag and r =~ BLANK
300
+
301
+
302
+ if roomflag and r !~ BLANK
303
+ desc << r << "\n"
304
+ end
305
+
306
+ if r =~ BLANK and roomflag # and desc != ''
307
+
308
+ if desc.count("\n") == 1 and desc =~ /\?$/
309
+ # A "What next?" type of prompt, not a room description
310
+ desc = ''
311
+ end
312
+ desc = nil if desc == ''
313
+
314
+ rooms << {
315
+ :name => name,
316
+ :desc => desc,
317
+ :tele => tele
318
+ }
319
+ roomflag = false
320
+ desc_gap = false
321
+ name = nil
322
+ desc = ''
323
+ tele = nil
324
+ end
325
+ }
326
+
327
+ if not rooms.empty?
328
+ move[:rooms] = rooms
329
+ move[:look] = true if cmd =~ LOOK
330
+ move[:reply] = nil
331
+ end
332
+ }
333
+
334
+
335
+ @moves.each { |move|
336
+ cmd = move[:cmd]
337
+ if objs = cmd.scan(TAKE)
338
+ take(move, objs)
339
+ end
340
+
341
+ next unless move[:rooms]
342
+ move[:rooms].each { |room|
343
+ name = room[:name]
344
+ debug "SECTION: #{@map.section}"
345
+ debug "HERE IS: #{@here}"
346
+ debug "CMD: #{cmd}"
347
+ debug "ENDS AT: #{name}"
348
+
349
+ desc = room[:desc]
350
+ line = move[:line]
351
+ cmd = move[:cmd]
352
+
353
+ # If we teleported, try to find room
354
+ if room[:tele]
355
+ debug "\t ****TELEPORT TO #{name}****"
356
+ @here = find_room(name, desc)
357
+ debug "\t TO: #{@here}"
358
+ end
359
+
360
+ # If it is a look command or we don't know where we are yet,
361
+ # set current room.
362
+ if move[:look] or not @here
363
+ @here = new_room(move, name, desc) unless @here
364
+ @here.selected = true
365
+ next
366
+ end
367
+
368
+ # Otherwise, assume we moved in some way. Try to find the new room.
369
+ there = find_room(name, desc)
370
+ next if there == @here
371
+
372
+ cmd.sub!(MOVE, '')
373
+ if DIRMAP[cmd]
374
+ dir = DIRMAP[cmd]
375
+ go = nil
376
+ cmd = nil
377
+ elsif ODIRMAP[cmd]
378
+ go = ODIRMAP[cmd]
379
+ dir = choose_dir(@here, there, go)
380
+ cmd = nil
381
+ else
382
+ # special move ---
383
+ dir = choose_dir(@here, there)
384
+ go = nil
385
+ end
386
+
387
+ @here.selected = false
388
+ if not there
389
+ # Unvisited -- new room
390
+ @here = new_room(move, name, desc, dir, @here, go, cmd)
391
+ else
392
+ # Visited before -- new link
393
+ new_link(move, @here, there, dir, go, cmd)
394
+ @here = there
395
+ end
396
+ }
397
+ }
398
+
399
+ @moves.clear
400
+ @map.fit
401
+ if @map.kind_of?(FXMap)
402
+ @here.selected = true if @here
403
+ @map.zoom = @map.zoom
404
+ @map.center_view_on_room(@here) if @here
405
+ @map.verify_integrity
406
+ @map.draw
407
+ end
408
+ end
409
+
410
+ def find_room(name, desc)
411
+ bestscore = 0
412
+ best = nil
413
+ @rooms.each { |r, room|
414
+ score = 0
415
+
416
+
417
+ if desc and room[:desc]
418
+ # We have a description...
419
+ # Try exact description match first
420
+ score += 10 if room[:desc] == desc
421
+
422
+ # Try substring match
423
+ score += 5 if room[:desc].index(desc)
424
+
425
+ # If still no luck, try first N words
426
+ if score == 0
427
+ dwords = room[:desc].split(' ')
428
+ words = desc.split(' ')
429
+ match = true
430
+ 0.upto(DESC_MINWORDS) { |i|
431
+ if words[i] != dwords[i]
432
+ match = false
433
+ break
434
+ end
435
+ }
436
+
437
+ score += 2 if match
438
+ end
439
+ else
440
+ # Just the name, not so good
441
+ score += 1 if r.name == name
442
+ end
443
+ next if score <= bestscore
444
+ bestscore = score
445
+ best = [r, room[:section]]
446
+ }
447
+ return nil if not best
448
+
449
+ if best[1] != @map.section
450
+ debug "\t ------> #{best[0]} in section #{best[1]}"
451
+ @map.section = best[1]
452
+ end
453
+ return best[0]
454
+ end
455
+
456
+ #
457
+ # Determine if line corresponds to a room name
458
+ #
459
+ def roomname(line)
460
+ # Remove unwanted stuff
461
+ line.sub!(NAME_REMOVE, '')
462
+
463
+ # quick check for invalid format
464
+ return false if line =~ NAME_INVALID
465
+ return false unless line =~ /\w/
466
+
467
+ # Check word count
468
+ words = line.split(' ')
469
+ return false if words.size > NAME_MAXWORDS
470
+
471
+ # Check uncapitalized words
472
+ return false if line =~ /^[ a-z\/\\\-\(\)]/
473
+
474
+ words.each { |w|
475
+ return false if w =~ /^[a-z]/ and w.size > NAME_MAXUNCAP
476
+ }
477
+ return true
478
+ end
479
+
480
+ #
481
+ # Create a new room
482
+ #
483
+ def new_room( move, name, desc, dir = nil, from = nil, go = nil, cmd = nil )
484
+ if not from
485
+ debug "FROM undefined. Increase section #{name}"
486
+ @section += 1
487
+ @map.new_section if @section >= @map.sections.size
488
+ r = @map.new_room(0, 0)
489
+ r.name = name
490
+ else
491
+ x = from.x
492
+ y = from.y
493
+ dx, dy = Room::DIR_TO_VECTOR[dir]
494
+ x += dx
495
+ y += dy
496
+ @map.shift(x, y, dx, dy) if not @map.free?(x, y)
497
+
498
+ if not @map.free?(x, y)
499
+ raise "Error when shifting rooms"
500
+ end
501
+
502
+ debug "+++ New Room #{name} from #{from}"
503
+ r = @map.new_room(x, y)
504
+ r.name = name
505
+ c = nil
506
+ if from[dir]
507
+ # oops, we had a connection there.
508
+ c = from[dir]
509
+ b = c.roomB
510
+ if not b
511
+ # Stub connection. Update it.
512
+ debug "\tUPDATE #{c}"
513
+ odir = (dir + 4) % 8
514
+ c.roomB = r
515
+ r[odir] = c
516
+ debug "\tNOW IT IS #{c}"
517
+ elsif b.name =~ DARKNESS
518
+ # Was it a dark room that is now lit?
519
+ @map.delete_connection(c)
520
+ @map.delete_room(b)
521
+ r.darkness = true
522
+ c = nil
523
+ else
524
+ # Probably a link that is complex
525
+ # or oneway. Shift it to some other location
526
+ exitB = b.exits.rindex(c)
527
+ shift_link(from, dir)
528
+ c = nil
529
+ end
530
+ end
531
+ if c == nil
532
+ c = @map.new_connection( from, dir, r )
533
+ c.exitAtext = go if go
534
+ c.dir = Connection::AtoB
535
+ end
536
+ end
537
+
538
+ parse_exits(r, desc)
539
+
540
+ # Update room description
541
+ @rooms[r][:desc] = desc
542
+ return r
543
+ end
544
+
545
+ def shift_link(room, dir)
546
+ idx = dir + 1
547
+ idx = 0 if idx > 7
548
+ while idx != dir
549
+ break if not room[idx]
550
+ idx += 1
551
+ idx = 0 if idx > 7
552
+ end
553
+ if idx != dir
554
+ room[idx] = room[dir]
555
+ room[dir] = nil
556
+ # get position of other room
557
+ ox, oy = Room::DIR_TO_VECTOR[dir]
558
+ c = room[idx]
559
+ if c.roomA == room
560
+ b = c.roomB
561
+ else
562
+ b = c.roomA
563
+ end
564
+ x, y = [b.x, b.y]
565
+ x -= ox
566
+ y -= oy
567
+ dx, dy = Room::DIR_TO_VECTOR[idx]
568
+ @map.shift(x, y, -dx, -dy)
569
+ else
570
+ raise "Warning. Cannot shift connection."
571
+ end
572
+ end
573
+
574
+ def new_link(move, from, to, dir, go, cmd)
575
+ odir = (dir + 4) % 8
576
+ c = nil
577
+ if from[dir]
578
+ c = from[dir]
579
+ debug "\tMOVE #{c} DIR: #{dir}"
580
+ if not c.roomB
581
+ # Stub connection, fill it
582
+ c.roomB = to
583
+ debug "\tREMOVE #{to[odir]}"
584
+ @map.delete_connection(to[odir]) if to[odir] and to[odir].stub?
585
+ to[odir] = c
586
+ debug "\tREPLACED WITH #{c}"
587
+ elsif c.roomB == to
588
+ # We already went this way. Nothing to do.
589
+ debug "\tWE ALREADY PASSED THRU HERE"
590
+ elsif c.roomA == to
591
+ # We verified we can travel thru this connection in both
592
+ # directions. Change its status to both.
593
+ c.dir = Connection::BOTH
594
+ debug "\tSECTION: #{@map.section}"
595
+ debug "\tVERIFIED EXIT BOTH WAYS"
596
+ else
597
+ if c.roomA == from
598
+ b = c.roomB
599
+ if b.name =~ DARKNESS
600
+ @map.delete_connection(c)
601
+ @map.delete_room(b)
602
+ to.darkness = true
603
+ c = nil
604
+ else
605
+ dir = Room::DIRECTIONS[dir]
606
+ @map.cannot_automap "Maze detected.\n'#{from}' #{dir} leads to '#{c.roomB}',\nnot to this '#{to}'."
607
+ return nil
608
+ end
609
+ else
610
+ # We have a connection that turns. Move the link around
611
+ shift_link(from, dir)
612
+ c = nil
613
+ end
614
+ end
615
+ elsif to[odir]
616
+ c = to[odir]
617
+ if not c.roomB
618
+ debug "\tREMOVE #{to[odir]} and REPLACE with #{c}"
619
+ # Stub connection, fill it
620
+ c.roomB = from
621
+ # @map.delete_connection(from[dir]) if from[dir].stub?
622
+ from[dir] = c
623
+ c.dir = Connection::BtoA
624
+ c.exitBtext = go if go
625
+ elsif c.roomB == from
626
+ c.exitBtext = go if go
627
+ else
628
+ # We need to change odir to something else
629
+ # shift_link(to, odir)
630
+ odir = choose_dir(to, from, go)
631
+ c = nil
632
+ end
633
+ end
634
+
635
+ if not c
636
+ # First, check all from exits that are AtoB to see if we have one
637
+ # that goes to the room we want.
638
+ from.exits.each_with_index { |e, idx|
639
+ next unless e
640
+ if e.roomA == to and e.dir == Connection::AtoB
641
+ c = e
642
+ from[idx] = nil
643
+ end
644
+ }
645
+ if c
646
+ # If so, make that connection go both ways and attach it to
647
+ # current direction.
648
+ from[dir] = c
649
+ c.dir = Connection::BOTH
650
+ end
651
+ end
652
+
653
+ if not c
654
+ # No link exists -- create new one
655
+ begin
656
+ c = @map.new_connection( from, dir, to, odir )
657
+ c.exitAtext = go if go
658
+ c.dir = Connection::AtoB
659
+ rescue
660
+ end
661
+ end
662
+
663
+ return c
664
+ end
665
+
666
+
667
+ # Choose a direction to represent up/down/in/out.
668
+ def choose_dir(a, b, go = nil)
669
+ best = nil
670
+ bestscore = 0
671
+ if go
672
+ rgo = go % 2 == 0? go - 1 : go + 1
673
+ debug "#{Connection::EXIT_TEXT[go]} <=> #{Connection::EXIT_TEXT[rgo]}"
674
+ # First, check if room already has exit moving towards other room
675
+ a.exits.each_with_index { |e, idx|
676
+ next if not e
677
+ roomA = e.roomA
678
+ roomB = e.roomB
679
+ if roomA == a and roomB == b and e.exitBtext == rgo
680
+ e.exitAtext = go
681
+ return idx
682
+ elsif roomB == a and roomA == b and e.exitAtext == rgo
683
+ e.exitBtext = go
684
+ return idx
685
+ end
686
+ }
687
+ end
688
+
689
+ # No such luck... Pick a direction.
690
+ DIRLIST.each { |dir|
691
+ # We prefer straight directions to diagonal ones
692
+ inc = dir % 2 == 1 ? 1 : 3
693
+ rdir = (dir + 4) % 8
694
+ score = 0
695
+ # We prefer directions where both that dir and the opposite side
696
+ # are empty.
697
+ score += inc unless a[dir]
698
+ score += 1 unless a[rdir]
699
+ next if score <= bestscore
700
+ bestscore = score
701
+ best = dir
702
+ }
703
+
704
+ if bestscore == 0
705
+ $stderr.puts "No free exit for up/down"
706
+ end
707
+
708
+ return best
709
+ end
710
+
711
+ def stop
712
+ @t.stop
713
+ end
714
+
715
+ def destroy
716
+ @t.kill
717
+ @f.close
718
+ GC.start
719
+ end
720
+
721
+ def initialize(map, file)
722
+ @file = file
723
+ @map = map
724
+ @objects = {}
725
+ @moves = []
726
+ @rooms = {}
727
+ @here = nil
728
+ @section = -1
729
+ @last_obj = nil
730
+ end
731
+
732
+ def start
733
+ @f = File.open(@file, 'r')
734
+ parse
735
+ @t = Thread.new {
736
+ loop do
737
+ begin
738
+ parse_line(@f.gets)
739
+ rescue => e
740
+ puts e
741
+ puts e.backtrace
742
+ end
743
+ Thread.pass
744
+ end
745
+ }
746
+ @t.run
747
+ end
748
+ end