cgame.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. #!/usr/bin/env python
  2. """
  3. Class definition for Carcassonne score keeping system.
  4. Copyright 2018-2019 George C. Privon
  5. This program is free software: you can redistribute it and/or modify
  6. it under the terms of the GNU General Public License as published by
  7. the Free Software Foundation, either version 3 of the License, or
  8. (at your option) any later version.
  9. This program is distributed in the hope that it will be useful,
  10. but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. GNU General Public License for more details.
  13. You should have received a copy of the GNU General Public License
  14. along with this program. If not, see <http://www.gnu.org/licenses/>.
  15. """
  16. import os as _os
  17. import re as _re
  18. import sys as _sys
  19. import configparser as _configparser
  20. from datetime import datetime as _datetime
  21. import sqlite3 as _sqlite3
  22. import numpy as _np
  23. class cgame:
  24. """
  25. Carcassonne game object
  26. """
  27. def __init__(self, args=None):
  28. """
  29. Initialize some variables and set up a game
  30. """
  31. self.commands = [('r', 'record score'),
  32. ('n', 'next turn'),
  33. ('e', 'end game (or end play if already in postgame scoring)'),
  34. ('s', '(current) score and game status'),
  35. ('q', 'quit (will be removed for real gameplay'),
  36. ('?', 'print help')]
  37. if args is None:
  38. _sys.stderr.write("Error: must provide a set of arguments when using the cgame class.\n")
  39. _sys.stderr.write("Exiting.\n\n")
  40. _sys.exit()
  41. self.args = args
  42. self.loadConfig()
  43. self.conn = _sqlite3.connect(self.config.get('CarcassonneScore', 'DBNAME'))
  44. self.cur = self.conn.cursor()
  45. self.timefmt = "%Y-%m-%dT%H:%M:%S"
  46. self.setupGame()
  47. def loadConfig(self):
  48. """
  49. Load configuration file
  50. """
  51. if not _os.path.isfile(self.args.config):
  52. _sys.stderr.write("Error: could not find configuration file '" + self.args.config + "'\n")
  53. _sys.exit()
  54. self.config = _configparser.RawConfigParser()
  55. self.config.read(self.args.config)
  56. # set up a preferences dictionary
  57. self.preferences = {}
  58. self.preferences['SHOWTILES'] = self.config['Status'].getboolean('SHOWTILES')
  59. self.preferences['SHOWTIME'] = self.config['Status'].getboolean('SHOWTIME')
  60. def showCommands(self):
  61. """
  62. Print out a list of valid commands for in-game play.
  63. """
  64. _sys.stderr.write('Possible commands:\n')
  65. for entry in self.commands:
  66. _sys.stderr.write('\t' + entry[0] + ': ' + entry[1] + '\n')
  67. def setupGame(self):
  68. """
  69. Initialize a game
  70. """
  71. # game state information
  72. self.state = 0 # 0 for main game, 1 for postgame, 2 for ended game
  73. self.nscore = 0
  74. self.ntile = 1 # number of tiles played
  75. self.nbuilder = 0 # number of tiles placed due to builders
  76. self.nabbey = 0 # number of Abbey tiles played
  77. self.nhill = 0 # number of tiles hidden under hills
  78. self.totaltiles = 72 # may be increased by expansions
  79. self.tokens = ["Meeple"]
  80. self.tiletypes = []
  81. self.scoretypes = ["Meadow", "City", "Road", "Monastery"]
  82. # get general game info (do this after expansions because
  83. # expansion info is entered into the game table)
  84. while self.gameInfo():
  85. continue
  86. def gameInfo(self):
  87. """
  88. Load basic game info
  89. """
  90. location = input("Where is the game being played? ")
  91. # get expansions used for this game
  92. _sys.stdout.write("Collecting expansion information...\n")
  93. while self.getExpansions():
  94. continue
  95. # get players for this game
  96. _sys.stdout.write("Collecting player information...\n")
  97. while self.getPlayers():
  98. continue
  99. self.starttime = _datetime.utcnow()
  100. self.lastturn = self.starttime
  101. starttime = self.starttime.strftime(self.timefmt)
  102. self.cur.execute('INSERT INTO games (location, starttime, expansions) VALUES ("' + location + '","' + starttime + '","' + ','.join(["{0:d}".format(x) for x in self.expansionIDs]) + '")')
  103. gID = self.cur.execute('select last_insert_rowid();').fetchall()[0]
  104. self.conn.commit()
  105. self.gameID = gID[0]
  106. _sys.stdout.write("Starting game #{0:d}".format(self.gameID))
  107. if location:
  108. _sys.stdout.write(" in " + location)
  109. _sys.stdout.write(".\n")
  110. def getPlayers(self):
  111. """
  112. Get a list of possible players from the database
  113. """
  114. self.players = []
  115. dbplayers = self.cur.execute('''SELECT * FROM players''').fetchall()
  116. if len(dbplayers):
  117. for dbplayer in dbplayers:
  118. _sys.stdout.write("{0:d}) ".format(dbplayer[0]) + dbplayer[1] + '\n')
  119. VALID = False
  120. while not VALID:
  121. playerinput = input("Please list the IDs for the players in this game (in order of play): ")
  122. try:
  123. playerIDs = [int(x) for x in playerinput.split()]
  124. VALID = True
  125. except:
  126. _sys.stderr.write("Error: input must be a list of integers separated by spaces.\n")
  127. if len(playerIDs) < 2:
  128. _sys.stderr.write("Playing alone? You need at least one opponent!\n")
  129. return 1
  130. for playerID in playerIDs:
  131. matched = False
  132. for dbplayer in dbplayers:
  133. if playerID == dbplayer[0]:
  134. self.players.append((playerID, dbplayer[1]))
  135. matched = True
  136. continue
  137. if not matched:
  138. _sys.stderr.write("Error: player ID {0:d} does not match an option from the list.\n".format(playerID))
  139. return 1
  140. else:
  141. _sys.stderr.write("Error: players table empty. Exiting.\n")
  142. _sys.exit(-1)
  143. return 0
  144. def getExpansions(self):
  145. """
  146. Get a list of playable expansions.
  147. Ask the user which ones are active.
  148. Based on the list, add token, tile, and score types to the basic list.
  149. """
  150. self.expansionIDs = []
  151. for minisel in range(0, 2):
  152. if minisel:
  153. exptype = "mini"
  154. else:
  155. exptype = "large"
  156. dbexpans = self.cur.execute('''SELECT expansionID,name,tokens,Ntiles,tiletypes,scoretypes FROM expansions WHERE active==1 and mini=={0:d}'''.format(minisel)).fetchall()
  157. if len(dbexpans):
  158. for dbexpan in dbexpans:
  159. _sys.stdout.write("{0:d}) ".format(dbexpan[0]) + dbexpan[1] + '\n')
  160. VALID = False
  161. while not VALID:
  162. expaninput = input("Please list the numbers for the " + exptype + " used in this game: ")
  163. try:
  164. expanIDs = [int(x) for x in expaninput.split()]
  165. VALID = True
  166. except:
  167. _sys.stderr.write("Error: input must be a list of integers separated by spaces.\n")
  168. for expanID in expanIDs:
  169. matched = False
  170. if expanID == 2:
  171. # add the builder cmd if Traders & Builders is used
  172. self.commands.append(('b', 'additional turn for a player due to a builder (use for the 2nd play by a player)'))
  173. elif expanID == 5:
  174. # add Abbey placement command
  175. self.commands.append(('a', 'Player places an abbey tile instead of a tile drawn from the pile'))
  176. elif expanID == 9:
  177. self.commands.append(('h', 'Player places a hill tile'))
  178. elif expanID == 101:
  179. # decrement totaltiles because the base pack starting tile is not used
  180. self.totaltiles -= 1
  181. for dbexpan in dbexpans:
  182. if expanID == dbexpan[0]:
  183. self.expansionIDs.append(expanID)
  184. self.totaltiles += dbexpan[3]
  185. ttypes = dbexpan[2].split(',')
  186. if len(ttypes):
  187. # add new types of tokens
  188. for token in ttypes:
  189. if token:
  190. self.tokens.append(token)
  191. tiletypes = dbexpan[4].split(',')
  192. if len(tiletypes):
  193. # add special tiles
  194. for tile in tiletypes:
  195. if tile:
  196. self.tiletypes.append(tile)
  197. stypes = dbexpan[5].split(',')
  198. if len(stypes):
  199. # add new types of scoring
  200. for stype in stypes:
  201. if stype:
  202. self.scoretypes.append(stype)
  203. matched = True
  204. continue
  205. if not matched:
  206. _sys.stderr.write("Error: expansion ID {0:d} does not match an option from the list.\n".format(expanID))
  207. return 1
  208. else:
  209. _sys.stdout.write("No active " + exptype + " expansions found. Continuing.\n")
  210. return 0
  211. def getRandomExpansions(self):
  212. """
  213. Select a set of random expansions for play.
  214. """
  215. res = cur.execute('''SELECT DISTINCT expansionID FROM expansions WHERE active=1;''')
  216. explist = res.fetchall()
  217. explist = np.array([x[0] for x in explist])
  218. exps = explist[explist < 100]
  219. miniexps = explist[explist >= 100]
  220. nexp = random.randint(0, len(exps))
  221. nminiexp = random.randint(0, len(miniexps))
  222. print("Selecting {0:d} full expansions and {1:d} mini expansions.".format(nexp,
  223. nminiexp))
  224. if nexp:
  225. selexp = sorted(random.sample(list(exps), nexp))
  226. print("Full expansions: ", selexp)
  227. else:
  228. selexp = []
  229. if nminiexp:
  230. selminiexp = sorted(random.sample(list(miniexps), nminiexp))
  231. print("Mini expansions: ", selminiexp)
  232. else:
  233. selminiexp = []
  234. return selexp + selminiexp
  235. def getPlayerName(self, playerID):
  236. """
  237. Given a playerID, return a player's name from the database.
  238. """
  239. playerName = self.cur.execute('''SELECT name FROM players WHERE playerID={0:1.0f}'''.format(playerID)).fetchall()[0]
  240. return playerName[0]
  241. def recordScore(self):
  242. """
  243. Record a score event in the game
  244. """
  245. score = {'playerIDs': -1,
  246. 'ingame' : 1,
  247. 'points' : 0,
  248. 'scoretype': '',
  249. 'sharedscore': 0,
  250. 'tokens': '',
  251. 'extras': '',
  252. 'comments': ''}
  253. if self.state:
  254. score['ingame'] = 0
  255. # ask the user which player scored
  256. VALID = False
  257. while not VALID:
  258. for player in self.players:
  259. _sys.stdout.write("{0:d}) ".format(player[0]) + player[1] + "\n")
  260. scoreplayers = input("Please enter the numbers for the players who scored: ")
  261. try:
  262. score['playerIDs'] = [int(x) for x in scoreplayers.split()]
  263. if not len(score['playerIDs']):
  264. _sys.stderr.write("There must be at least one player.\n")
  265. continue
  266. elif self.checkPlayers(score['playerIDs']):
  267. _sys.stderr.write("At least one player entered is not playing this game.\n")
  268. else:
  269. VALID = True
  270. except:
  271. _sys.stderr.write("Error, could not parse players list.\n")
  272. continue
  273. if len(score['playerIDs']) > 1:
  274. score['sharedscore'] = 1
  275. # see which token scored
  276. # really this should be expanded to allow multiple token types for one score
  277. if score['scoretype'] == 'Trade token':
  278. score['tokens'] = 'none'
  279. elif len(self.tokens) > 1:
  280. VALID = False
  281. while not VALID:
  282. for i, token in enumerate(self.tokens):
  283. _sys.stdout.write("{0:d}) ".format(i+1) + token + "\n")
  284. tID = input("Please select the token type(s): ")
  285. try:
  286. score['tokens'] = ','.join(self.tokens[int(x)-1] for x in tID.split())
  287. VALID = True
  288. except:
  289. _sys.stderr.write("'" + tID + "' is not a valid token.\n")
  290. continue
  291. else:
  292. score['tokens'] = self.tokens[0]
  293. # get the score type
  294. VALID = False
  295. while not VALID:
  296. for i, stype in enumerate(self.scoretypes):
  297. _sys.stdout.write("{0:d}) ".format(i+1) + stype + "\n")
  298. # here i want a list of valid score types
  299. stype = input("Please select the score type: ")
  300. try:
  301. score['scoretype'] = self.scoretypes[int(stype)-1]
  302. VALID = True
  303. except:
  304. _sys.stderr.write("'" + stype + "' is not a valid score type.\n")
  305. continue
  306. # get points for score
  307. VALID = False
  308. while not VALID:
  309. points = input("Enter the total number of points: ")
  310. try:
  311. pts = int(points)
  312. if pts >= 0:
  313. score['points'] = int(points)
  314. VALID = True
  315. else:
  316. _sys.stderr.write("Score cannot be negative.\n")
  317. except:
  318. _sys.stderr.write("'" + points + "' is not a valid score.\n")
  319. continue
  320. score['comments'] = input("Enter any comments you would like saved (a single line): ")
  321. # check score input to make sure it's correct
  322. _sys.stdout.write('\n')
  323. _sys.stdout.write(', '.join([self.getPlayerName(x) for x in score['playerIDs']]) + ' ')
  324. _sys.stdout.write('scores {0:d} points on a '.format(score['points']) + score['scoretype'])
  325. _sys.stdout.write(' with ' + score['tokens'] + '.\n')
  326. answer = input("Is this correct? (y/n) ")
  327. if not _re.match('y', answer, _re.IGNORECASE):
  328. _sys.stdout.write("Note: score not recorded.\n")
  329. return 1
  330. # now construct a SQL query
  331. for player in score['playerIDs']:
  332. command = 'INSERT INTO scores VALUES ({0:d},'.format(self.gameID)
  333. command = command + '{0:d},'.format(player)
  334. command = command + '{0:d},{1:d},'.format(self.ntile,
  335. self.nscore)
  336. command = command + '{0:d},{1:d},'.format(score['ingame'],
  337. score['points'])
  338. command = command + '"' + score['scoretype'] + '",'
  339. command = command + '{0:d},'.format(score['sharedscore'])
  340. command = command + '"' + score['tokens'] + '",'
  341. command = command + '"' + score['extras'] + '",'
  342. command = command + '"' + score['comments'] + '")'
  343. self.cur.execute(command)
  344. self.conn.commit()
  345. # now increment the score number
  346. self.nscore += 1
  347. # newline after score for aesthetics
  348. _sys.stdout.write("\n")
  349. return 0
  350. def advanceTurn(self, builder=False, abbey=False, hill=False):
  351. """
  352. Make a new entry in the turns table
  353. - builder: if True, give the user another turn
  354. - abbey: if True, the turn is advanced as normal, but don't increment the number of tiles
  355. - hill: if True, the turn is advanced as nourmal, but increment by two tiles
  356. (hill tile and tile that goes under it)
  357. """
  358. self.lastturn = _datetime.utcnow()
  359. cmdtime = self.lastturn.strftime(self.timefmt)
  360. command = '''INSERT INTO turns VALUES ({0:d}, {1:d}, "'''.format(self.gameID, self.ntile)
  361. command = command + cmdtime + '"'
  362. if builder:
  363. bID = 1
  364. elif abbey:
  365. bID = 2
  366. else:
  367. bID = 0
  368. # compute playerID based on the turn number minus nbuilders / number of players
  369. player = self.getCurrentPlayer()
  370. command = command + ', {0:d}, {1:d})'.format(bID, player[0])
  371. self.cur.execute(command)
  372. self.conn.commit()
  373. # only increment the number of tiles played if the player did not play an Abbey
  374. if not abbey:
  375. self.ntile += 1
  376. else:
  377. self.nabbey += 1
  378. if builder:
  379. self.nbuilder += 1
  380. if hill:
  381. # advance by an extra tile (that goes under the hill)
  382. self.nhill += 1
  383. def runGame(self):
  384. """
  385. Main routine for entering games
  386. """
  387. # here wait for input for scores, advancing to next round, or completion of game
  388. # for each step of entry, present a series of options, based on the list
  389. # of playerIDs and expansions
  390. while self.state < 2:
  391. # set up prompt based on current round
  392. if self.state:
  393. prompt = "postgame > "
  394. else:
  395. player = self.getCurrentPlayer()
  396. prompt = "round: {0:d}, turn: {1:d} ".format(int(_np.floor((self.ntile-self.nbuilder-self.nhill-1) / len(self.players))),
  397. self.ntile-self.nbuilder)
  398. prompt = prompt + "(" + player[1] + ") > "
  399. try:
  400. cmd = input(prompt)
  401. except (EOFError, KeyboardInterrupt):
  402. _sys.stderr.write('Improper input. Please retry\n')
  403. self.showCommands()
  404. if _re.match('e', cmd, _re.IGNORECASE):
  405. self.advanceState()
  406. elif _re.match('q', cmd, _re.IGNORECASE):
  407. _sys.exit(0)
  408. elif _re.match('s', cmd, _re.IGNORECASE):
  409. if self.state:
  410. self.printStatus(tilestats=False)
  411. else:
  412. self.printStatus(tilestats=self.preferences['SHOWTILES'],
  413. timestats=self.preferences['SHOWTIME'])
  414. elif _re.match('n', cmd, _re.IGNORECASE):
  415. self.advanceTurn(builder=False,
  416. abbey=False)
  417. elif _re.match('r', cmd, _re.IGNORECASE):
  418. self.recordScore()
  419. elif _re.match('b', cmd, _re.IGNORECASE):
  420. self.advanceTurn(builder=True,
  421. abbey=False)
  422. elif _re.match('a', cmd, _re.IGNORECASE):
  423. self.advanceTurn(builder=False,
  424. abbey=True)
  425. elif _re.match('h', cmd, _re.IGNORECASE):
  426. self.advanceTurn(builder=False,
  427. abbey=False,
  428. hill=True)
  429. elif _re.match('\?', cmd, _re.IGNORECASE):
  430. self.showCommands()
  431. else:
  432. _sys.stderr.write('Command not understood. Please try again.\n')
  433. self.showCommands()
  434. if self.state == 2:
  435. #game is over. write end time to the games table
  436. time = _datetime.utcnow().strftime(self.timefmt)
  437. self.cur.execute('''UPDATE games SET endtime = "''' + time + '''" WHERE gameID = ''' + str(self.gameID))
  438. self.conn.commit()
  439. _sys.stdout.write("Game over!\n")
  440. self.printStatus(tilestats=False, sort=True)
  441. self.conn.close()
  442. #### Is there a way to capture "ineffective" uses? For example,
  443. #### meeples that don't score points because they end up in a meadow that's
  444. #### controled by someone else?
  445. return 0
  446. def advanceState(self):
  447. """
  448. End the main part of play or finish the game.
  449. Does not change the turn number, so turn should be ended before ending
  450. the game.
  451. """
  452. self.state += 1
  453. if self.state < 2:
  454. self.commands = [('r', 'record score'),
  455. ('e', 'end game (or end play if already in postgame scoring)'),
  456. ('s', '(current) score and game status')]
  457. # add trade token scoring to the game scoring options
  458. if 2 in self.expansionIDs:
  459. self.scoretypes.append('Trade token')
  460. self.commands.append(('?', 'print help'))
  461. _sys.stdout.write("At the end of regulation... ")
  462. self.printStatus(tilestats=False, sort=True)
  463. def printStatus(self, tilestats=False, timestats=False, sort=False):
  464. """
  465. Print the total score (current or final) for the specified gameID
  466. tilestats controls printing info on the number of tiles played/remaining
  467. sort will trigger sorting by score
  468. timestats will print out some information about time elapsed (game and turn)
  469. """
  470. _sys.stdout.write('\n')
  471. if not self.state:
  472. _sys.stdout.write('Current ')
  473. _sys.stdout.write('Score\n')
  474. tscore = []
  475. for player in self.players:
  476. a = self.cur.execute('SELECT points FROM scores WHERE gameID={0:d} and playerID={1:d}'.format(self.gameID, player[0]))
  477. res = a.fetchall()
  478. score = _np.sum(res)
  479. tscore.append((score, player[1]))
  480. if sort:
  481. tscore.sort(reverse=True)
  482. for player in tscore:
  483. _sys.stdout.write('\t' + player[1]+ ': {0:1.0f}'.format(player[0]) + '\n')
  484. _sys.stdout.write('\n')
  485. if tilestats:
  486. _sys.stdout.write("{0:1.0f} tiles played, {1:1.0f} remaining.\n\n".format(self.ntile + self.nabbey + self.nhill,
  487. self.totaltiles - self.ntile))
  488. if timestats:
  489. gamedt = _datetime.utcnow() - self.starttime
  490. turndt = _datetime.utcnow() - self.lastturn
  491. #_sys.stdout.write('Game time elapsed: ' + gamedt + '\n')
  492. _sys.stdout.write('Time since last turn: {0:1.0f} seconds'.format(turndt.total_seconds()) + '\n\n')
  493. def getCurrentPlayer(self):
  494. """
  495. Return the current player, determined by the turn number
  496. """
  497. return self.players[int((self.ntile + self.nabbey - self.nbuilder - 1) % len(self.players))]
  498. def checkPlayers(self, trial):
  499. """
  500. Check to make sure all members of `trial` are in `comp`. If so, return
  501. 0. Otherwise return a list containing the missing members.
  502. """
  503. playerIDs = [x[0] for x in self.players]
  504. missing = []
  505. for obj in trial:
  506. if obj not in playerIDs:
  507. missing.append(obj)
  508. if len(missing):
  509. return missing
  510. else:
  511. return 0