4 cfmaplog.py - Crossfire GTK Client plug-in to track per-character map visits.
5 Copyright (C) 2025, "Kevin R. Bulgrien" <kbulgrien@att.net>
7 This program is free software: you can redistribute it and/or modify it under
8 the terms of the GNU General Public License as published by the Free Software
9 Foundation, either version 3 of the License, or (at your option) any later
12 This program is distributed in the hope that it will be useful, but WITHOUT
13 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
14 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
17 You should have received a copy of the GNU General Public License along with
18 this program. If not, see <https://www.gnu.org/licenses/>.
48 sys.stdout.write(f
"{text}{os.linesep}")
56 sys.stderr.write(f
"{text}{os.linesep}")
142 lines = text.split(os.linesep)
144 line = line.rstrip(os.linesep)
145 client_send(f
"draw 0 [color=darkorange]{codefile}: {line}")
150 lines = text.split(os.linesep)
152 line = line.rstrip(os.linesep)
163 if map_id
and player_id:
165 SELECT COMPLETED, COMPLETED_DATE FROM visit
166 WHERE MAP_ID = ? AND PLAYER_ID = ?
167 ''', ( map_id, player_id ))
168 query = cursor.fetchone()
171 completed_date = query[1]
173 player_send(f
"You marked this area completed {completed} times.")
174 player_send(f
"The most recent completion was: {completed_date}.")
179 server_name = os.environ[
'CF_SERVER_NAME']
180 player_name = os.environ[
'CF_PLAYER_NAME']
183 console_send(f
"This plug-in is not meant to run as a standalone program.")
190 "-------------------------------------------------------------------------")
194 codefile = re.compile(
"[.][^.]+\Z").sub(
"", __file__)
195 datafile = codefile +
".db"
196 codefile = re.compile(
"^.+\/").sub(
"", codefile)
201 dbConn = sqlite3.connect(f
"{datafile}")
205 player_send(f
"[color=red]sqlite3.connect error[/color]")
209 cursor = dbConn.cursor()
220 CREATE TABLE IF NOT EXISTS server (
221 SERVER_ID INTEGER PRIMARY KEY NOT NULL,
222 SERVER_NAME TEXT UNIQUE NOT NULL
228 player_send(f
"[color=red]CREATE TABLE server error[/color]")
234 SELECT COUNT(*) FROM server
239 player_send(f
"[color=red]SELECT FROM server error[/color]")
243 query = cursor.fetchone()
250 SELECT SERVER_ID FROM server
251 WHERE SERVER_NAME = ?
252 ''', ( server_name, ))
256 player_send(f
"[color=red]SELECT FROM server error[/color]")
259 query = cursor.fetchone()
261 servers = servers + 1
266 ( SERVER_ID, SERVER_NAME )
268 ''', (servers, server_name))
272 player_send(f
"[color=red]INSERT INTO server error[/color]")
280 debug_send(f
"server_name (server_id): {server_name} ({server_id})")
290 player_seen = time.strftime(
"%Y/%m/%d %H:%M")
294 regx_rqst_player =
"^request\splayer\s"
295 regc_rqst_player = re.compile(regx_rqst_player)
296 regx_rqst_player_id =
"(\d+)\s+"
297 regc_rqst_player_id = re.compile(regx_rqst_player_id)
298 regx_rqst_player_strt = regx_rqst_player + regx_rqst_player_id
299 regc_rqst_player_strt = re.compile(regx_rqst_player_strt)
300 regx_rqst_player_name =
"Player:\s+(\w+)\s+(.+)"
301 regc_rqst_player_name = re.compile(regx_rqst_player_name)
302 regx_rqst_player_data = regx_rqst_player_id + regx_rqst_player_name
303 regc_rqst_player_data = re.compile(regx_rqst_player_data)
307 CREATE TABLE IF NOT EXISTS player (
308 PLAYER_ID INTEGER PRIMARY KEY NOT NULL,
309 SERVER_ID INTEGER NOT NULL,
310 PLAYER_NAME TEXT NOT NULL,
311 PLAYER_TITLE TEXT NOT NULL,
312 PLAYER_SEEN TEXT NOT NULL
318 player_send(f
"[color=red]CREATE TABLE player error[/color]")
324 SELECT COUNT(*) from player
329 player_send(f
"[color=red]SELECT FROM player error[/color]")
332 query = cursor.fetchone()
336 SELECT PLAYER_ID, PLAYER_TITLE FROM player
337 WHERE PLAYER_NAME = ? AND SERVER_ID = ?
338 ''', ( player_name, server_id ))
342 player_send(f
"[color=red]SELECT FROM player error[/color]")
345 query = cursor.fetchone()
349 for buffer
in sys.stdin:
350 buffer = buffer.rstrip(os.linesep)
352 if regc_rqst_player_strt.match(buffer):
353 buffer = regc_rqst_player.sub(
'', buffer)
354 matches = regc_rqst_player_data.match(buffer)
355 player_nmbr = matches.group(1)
357 player_title = matches.group(3)
358 players = players + 1
362 ( PLAYER_ID, SERVER_ID, PLAYER_NAME, PLAYER_TITLE, PLAYER_SEEN )
363 VALUES ( ?, ?, ?, ?, ? )
364 ''', (players, server_id, player_name, player_title, player_seen))
367 player_send(f
"[color=red]INSERT INTO player error[/color]")
377 player_title = query[1]
386 ''', (player_seen, player_id))
389 player_send(f
"[color=red]UPDATE player error[/color]")
426 regx_wtch_draw =
"^watch drawextinfo (\d+\s){3}"
427 regc_wtch_draw = re.compile(regx_wtch_draw, 0)
428 regx_wtch_draw_strt = regx_wtch_draw +
"([^\(]+|\(null\)\s*)\("
429 regc_wtch_draw_strt = re.compile(regx_wtch_draw_strt, 0)
430 regx_wtch_draw_name = regx_wtch_draw_strt +
"[~/]"
431 regc_wtch_draw_name = re.compile(regx_wtch_draw_name, 0)
432 regx_wtch_draw_path =
"^([^\(\)]+|\(null\)\s)\(([^\)]+)\)\s*(.*)"
433 regc_wtch_draw_path = re.compile(regx_wtch_draw_path, 0)
434 regx_wtch_draw_made =
"Created:\s+(.*)"
435 regc_wtch_draw_made = re.compile(regx_wtch_draw_made, 0)
436 regx_wtch_draw_date =
"Modified:\s+(.+)"
437 regc_wtch_draw_date = re.compile(regx_wtch_draw_date, 0)
438 regx_wtch_draw_xsiz = regx_wtch_draw +
"xsize\s[-]?\d+"
439 regc_wtch_draw_xsiz = re.compile(regx_wtch_draw_xsiz, 0)
440 regx_wtch_draw_rnds =
"random_seed\s\d+"
441 regc_wtch_draw_rnds = re.compile(regx_wtch_draw_rnds, 0)
444 CREATE TABLE IF NOT EXISTS map (
445 MAP_ID INTEGER PRIMARY KEY NOT NULL,
446 MAP_PATH TEXT UNIQUE NOT NULL,
447 MAP_NAME TEXT NOT NULL,
448 MAP_MADE TEXT NOT NULL,
449 MAP_DATE TEXT NOT NULL
454 SELECT COUNT(*) from map
456 query = cursor.fetchone()
470 CREATE TABLE IF NOT EXISTS quiet (
473 MAP_PATTERN TEXT NOT NULL
479 for loop
in (
'world_%_%',
'%Apartment%',
'%Inn %' ):
487 PLAYER_ID IS NULL AND SERVER_ID IS NULL AND MAP_PATTERN = ?
489 if cursor.fetchone()
is None:
492 ( PLAYER_ID, SERVER_ID, MAP_PATTERN )
498 SELECT COUNT(*) from quiet
500 query = cursor.fetchone()
506 CREATE TABLE IF NOT EXISTS visit (
507 MAP_ID INTEGER NOT NULL,
508 PLAYER_ID INTEGER NOT NULL,
509 SERVER_ID INTEGER NOT NULL,
510 VISIT_TOTAL INTEGER NOT NULL,
511 VISIT_DATE TEXT NOT NULL,
512 COMPLETED INTEGER DEFAULT 0,
513 VISIT_DATE TEXT NOT NULL DEFAULT ""
520 PRAGMA table_info(visit)
523 query = cursor.fetchall()
526 if row[1] ==
"COMPLETED":
529 debug_send(f
"ALTER TABLE visit ADD COLUMN COMPLETED INTEGER DEFAULT 0")
531 ALTER TABLE visit ADD COLUMN COMPLETED INTEGER DEFAULT 0
537 PRAGMA table_info(visit)
540 query = cursor.fetchall()
543 if row[1] ==
"COMPLETED_DATE":
546 debug_send(f
"ALTER TABLE visit ADD COLUMN COMPLETED_DATE STRING NOT NULL" \
549 ALTER TABLE visit ADD COLUMN COMPLETED_DATE STRING NOT NULL DEFAULT ""
565 PRAGMA table_info(visit)
568 query = cursor.fetchall()
571 if row[1] ==
"SERVER_ID":
574 player_send(f
"[color=red]NOTE: visit database schema repair![color]")
575 player_send(f
"Visit data previously failed to track the server that a " + \
576 f
"visit occurred on. Unfortunately, if you logged visits " + \
577 f
"on multiple servers, it is not possible to automatically " + \
578 f
"determine which server was in use at the time. We are " + \
579 f
"assuming (sorry) that the current server is the one that " + \
580 f
"should be used for all old visit data. While playing, if" + \
581 f
" you notice incorrect completion data upon entering a map" + \
582 f
", use 'scripttell {codepath} incomplete' to erase it. If" + \
583 f
" the map was completed on another server, logon to it and" + \
584 f
" re-visit the map and mark it complete (again) there." \
586 debug_send(f
"ALTER TABLE visit ADD COLUMN SERVER_ID INTEGER DEFAULT " + \
589 ALTER TABLE visit ADD COLUMN SERVER_ID INTEGER DEFAULT ''' + \
596 SELECT COUNT(*) from visit
598 query = cursor.fetchone()
603 vcConn = sqlite3.connect(
":memory:")
604 vcursor = vcConn.cursor()
607 CREATE TABLE IF NOT EXISTS vcache (
608 MAP_SEQ INTEGER NOT NULL,
609 MAP_ID INTEGER NOT NULL
618 regc_scripttell = re.compile(
'^scripttell\s+', 0)
627 for buffer
in sys.stdin:
628 buffer = buffer.rstrip(os.linesep)
636 if regc_scripttell.match(buffer):
637 match regc_scripttell.sub(
'', buffer).split(os.linesep):
652 SELECT v.COMPLETED_DATE, v.COMPLETED, m.MAP_NAME, m.MAP_PATH
654 JOIN server s ON s.SERVER_ID = v.SERVER_ID
655 JOIN player p ON p.PLAYER_ID = v.PLAYER_ID
656 JOIN map m ON m.MAP_ID = v.MAP_ID
657 WHERE v.PLAYER_ID = ? AND
660 ORDER BY COMPLETED_DATE DESC
661 ''', ( player_id, server_id ))
665 player_send(f
"You marked the following maps as completed:")
667 query = cursor.fetchall()
671 case [
'complete' |
'incomplete']:
676 SELECT COMPLETED, COMPLETED_DATE
677 FROM visit WHERE MAP_ID = ? and PLAYER_ID = ?
678 ''', ( map_id, player_id))
681 debug_send(f
"map_id {map_id} player_id {player_id}")
683 query = cursor.fetchone()
685 if buffer ==
'scripttell complete':
687 completed_date = time.strftime(
"%Y/%m/%d %H:%M")
689 if buffer ==
'scripttell complete':
690 completed = query[0] + 1
691 player_send(f
"You've marked this map complete {completed} times.")
692 if completed > 1
and len(query[1]):
693 player_send(f
"The most recent previous time was: {query[1]}.")
694 completed_date = time.strftime(
"%Y/%m/%d %H:%M")
697 player_send(f
"This map is already marked as not completed.")
699 player_send(f
"This map is now marked as not completed.")
701 debug_send(f
"map_id {map_id} player_id {player_id}")
702 debug_send(f
"completed {completed} completed_date {completed_date}")
706 SET COMPLETED = ?, COMPLETED_DATE = ?
707 WHERE MAP_ID = ? AND PLAYER_ID = ?
708 ''', (completed, completed_date, map_id, player_id))
714 case [
'help', *arguments]:
716 A Crossfire RPG client plug-in that keeps and reports statistics related to map
717 visits. Statistics are kept separate for different characters, and are unique
718 per-server played. Additionally, it serves as a utility to track which maps
719 have been 'completed'. Statistics include the date and time of the most recent
720 visit and completion, if any. A number of reporting options are supported to
721 allow the player to review the collected data.
723 The player interacts with the plug-in via 'scripttell' commands. Supported
727 While in a map, mark it to show that you believe you have 'finished' all you
728 want to do. What you consider 'finished' is entirely up to you. A tally is
729 kept of the number of times the command is used on each map. It is usually
730 most helpful to mark the entrance map 'completed' since the number of times
731 the map was 'completed' is shown upon entry after the tally is greater than
732 zero. Unfortunately, when the entrance map is a random map, this does not
733 presently work since the entry map path may change from visit to visit.
734 That said marking it anyway assures the completion is reported on entry to
735 the map in the event the same map is shown first in the future. For these
736 maps, one could mark every level, but probably most importantly, the last
737 non-random map of the dungeon.
743 Enable console (not in-game) messages. To see these messages, start the
744 client in a console. This is a toggle, so entering the command another
745 time disables the messages.
748 Clear the tally of a particular 'completed' map because one was erroneously
749 marked as complete, or, due to discovering that something was missed on the
750 objectives of the map. Using this command means no 'completed' tally is
754 Stop the plugin. Basically, what 'scriptkill' does, except that the script
755 initiates. At present, the plugin doesn't know how to 'catch' a scriptkill
756 (if that is even possible).
759 * visited <MaxToShow>
760 List all, or up to <MaxToShow> entries, in date order, with recent first.
761 <MaxToShow> is an optional integer.
764 * visited least <MaxToShow>
765 List all, or up to <MaxToShow> entries, with lowest number of visits as the
766 primary sort key, and date order for ties, with the most recent first.
769 * visited most <MaxToShow>
770 List all, or up to <MaxToShow> entries, with highest number of visits as the
771 primary sort key, and date order for ties, with the most recent first.
777 case [
'visited', *arguments]:
779 SELECT v.VISIT_DATE, v.VISIT_TOTAL, m.MAP_NAME, m.MAP_PATH
781 JOIN server s ON s.SERVER_ID = v.SERVER_ID
782 JOIN player p ON p.PLAYER_ID = v.PLAYER_ID
783 JOIN map m ON m.MAP_ID = v.MAP_ID
784 WHERE v.PLAYER_ID = ? AND
786 v.VISIT_TOTAL >= 1'''
788 match buffer.split():
789 case [
'scripttell',
'visited',
'least', *limit]:
790 sqlcmd = sqlcmd +
'''
791 ORDER BY v.VISIT_TOTAL ASC, VISIT_DATE ASC'''
792 case [
'scripttell',
'visited',
'most', *limit]:
793 sqlcmd = sqlcmd +
'''
794 ORDER BY v.VISIT_TOTAL DESC, VISIT_DATE DESC'''
795 case [
'scripttell',
'visited', *limit]:
796 sqlcmd = sqlcmd +
'''
797 ORDER BY v.VISIT_DATE DESC'''
803 if not limit[0].isnumeric():
804 player_send(f
"[color=red]LIMIT argument must be numeric." + \
808 sqlcmd = sqlcmd +
'''
809 LIMIT ''' + f
"{limit[0]}"
811 player_send(f
"[color=red]Only one LIMIT argument wanted." + \
815 debug_send(f
'''{sqlcmd}, ({player_id}, {server_id})''')
818 cursor.execute(sqlcmd, (player_id, server_id))
819 except sqlite3.Error
as e:
824 query = cursor.fetchall()
830 player_send(f
"[color=red]Not a recognized command.[\color]")
840 if buffer ==
"watch newmap":
854 map_line = map_line + 1
858 if regc_wtch_draw_name.match(buffer):
859 buffer = regc_wtch_draw.sub(
'', buffer)
860 map_data =
"map_name"
861 elif regc_wtch_draw_made.search(buffer):
862 buffer = regc_wtch_draw.sub(
'', buffer)
863 map_data =
"map_made"
864 elif regc_wtch_draw_date.search(buffer):
865 buffer = regc_wtch_draw.sub(
'', buffer)
866 map_data =
"map_date"
867 elif regc_wtch_draw_xsiz.search(buffer):
868 buffer = regc_wtch_draw.sub(
'', buffer)
869 map_date = time.strftime(
"%Y-%m-%d")
870 map_data =
"map_xsize"
872 elif regc_wtch_draw_rnds.match(buffer):
873 map_data =
"map_write"
882 matches = regc_wtch_draw_path.match(buffer)
883 debug_send(f
"{map_line}_1: '" + matches.group(1) +
"'")
884 debug_send(f
"{map_line}_2: '" + matches.group(2) +
"'")
885 debug_send(f
"{map_line}_3: '" + matches.group(3) +
"'")
887 map_name = matches.group(1) + matches.group(3)
888 map_path = matches.group(2)
891 matches = regc_wtch_draw_made.search(buffer)
892 map_made = matches.group(1)
896 matches = regc_wtch_draw_date.search(buffer)
897 map_date = matches.group(1)
898 map_data =
'map_write'
900 if map_data ==
'map_write':
902 debug_send(f
"map_name (map_path) {map_name} ({map_path})")
916 ( MAP_ID, MAP_PATH, MAP_NAME, MAP_MADE, MAP_DATE )
917 VALUES ( ?, ?, ?, ?, ? )
918 ''', (maps, map_path, map_name, map_made, map_date))
920 except sqlite3.IntegrityError:
927 SELECT MAP_ID FROM map
930 query = cursor.fetchone()
945 SELECT MAP_SEQ FROM vcache
948 query = vcursor.fetchone()
961 ''', (v_head, map_id) )
965 if v_head - v_tail >= 10:
979 visit_date = time.strftime(
"%Y/%m/%d %H:%M")
981 SELECT VISIT_TOTAL, COMPLETED, COMPLETED_DATE FROM visit
982 WHERE MAP_ID = ? AND PLAYER_ID = ?
983 ''', ( map_id, player_id ))
984 query = cursor.fetchone()
989 ( MAP_ID, PLAYER_ID, VISIT_TOTAL, VISIT_DATE )
990 VALUES ( ?, ?, ?, ? )
991 ''', (map_id, player_id, 1, visit_date))
993 player_send(f
"I don't think I remember this place!")
995 completed_date = query[2]
996 visit_total = query[0]
1003 WHERE PLAYER_ID IS NULL AND SERVER_ID IS NULL AND ? LIKE MAP_PATTERN
1005 query = cursor.fetchone()
1009 player_send(f
"You were here at least {visit_total} times prior.")
1013 visit_total = visit_total + 1
1019 SET VISIT_TOTAL = ?, VISIT_DATE = ?
1020 WHERE MAP_ID = ? AND PLAYER_ID = ?
1021 ''', (visit_total, visit_date, map_id, player_id))