more cleanup WIP

This commit is contained in:
Ben Harris 2022-04-02 14:38:34 -04:00
parent 64afe9dc14
commit 8ed4c1fdb5
11 changed files with 1455 additions and 1570 deletions

View File

@ -1,5 +1,9 @@
#!/usr/bin/env python2
import datetime
from botany import *
from data_manager import DataManager
from plant import Plant
def ascii_render(filename):
@ -39,7 +43,7 @@ def draw_plant_ascii(this_plant):
'palm',
'pachypodium',
]
if this_plant.dead == True:
if this_plant.dead:
ascii_render('rip.txt')
elif datetime.date.today().month == 10 and datetime.date.today().day == 31:
ascii_render('jackolantern.txt')

629
botany.py
View File

@ -1,632 +1,9 @@
#!/usr/bin/env python3
import errno
import pickle
import uuid
from cursed_menu import CursedMenu
from data_manager import DataManager
from menu_screen import *
# TODO:
# - Switch from personal data file to table in DB
class Plant(object):
# This is your plant!
stage_list = [
'seed',
'seedling',
'young',
'mature',
'flowering',
'seed-bearing',
]
color_list = [
'red',
'orange',
'yellow',
'green',
'blue',
'indigo',
'violet',
'white',
'black',
'gold',
'rainbow',
]
rarity_list = [
'common',
'uncommon',
'rare',
'legendary',
'godly',
]
species_list = [
'poppy',
'cactus',
'aloe',
'venus flytrap',
'jade plant',
'fern',
'daffodil',
'sunflower',
'baobab',
'lithops',
'hemp',
'pansy',
'iris',
'agave',
'ficus',
'moss',
'sage',
'snapdragon',
'columbine',
'brugmansia',
'palm',
'pachypodium',
]
mutation_list = [
'',
'humming',
'noxious',
'vorpal',
'glowing',
'electric',
'icy',
'flaming',
'psychic',
'screaming',
'chaotic',
'hissing',
'gelatinous',
'deformed',
'shaggy',
'scaly',
'depressed',
'anxious',
'metallic',
'glossy',
'psychedelic',
'bonsai',
'foamy',
'singing',
'fractal',
'crunchy',
'goth',
'oozing',
'stinky',
'aromatic',
'juicy',
'smug',
'vibrating',
'lithe',
'chalky',
'naive',
'ersatz',
'disco',
'levitating',
'colossal',
'luminous',
'cosmic',
'ethereal',
'cursed',
'buff',
'narcotic',
'gnu/linux',
'abraxan', # rip dear friend
]
def __init__(self, this_filename, generation=1):
# Constructor
self.time_delta_watered = 0
self.plant_id = str(uuid.uuid4())
self.life_stages = (3600 * 24, (3600 * 24) * 3, (3600 * 24) * 10, (3600 * 24) * 20, (3600 * 24) * 30)
# self.life_stages = (2, 4, 6, 8, 10) # debug mode
self.stage = 0
self.mutation = 0
self.species = random.randint(0, len(self.species_list) - 1)
self.color = random.randint(0, len(self.color_list) - 1)
self.rarity = self.rarity_check()
self.ticks = 0
self.age_formatted = "0"
self.generation = generation
self.dead = False
self.write_lock = False
self.owner = getpass.getuser()
self.file_name = this_filename
self.start_time = int(time.time())
self.last_time = int(time.time())
# must water plant first day
self.watered_timestamp = int(time.time()) - (24 * 3600) - 1
self.watered_24h = False
self.visitors = []
def migrate_properties(self):
# Migrates old data files to new
if not hasattr(self, 'generation'):
self.generation = 1
if not hasattr(self, 'visitors'):
self.visitors = []
def parse_plant(self):
# Converts plant data to human-readable format
output = ""
if self.stage >= 3:
output += self.rarity_list[self.rarity] + " "
if self.mutation != 0:
output += self.mutation_list[self.mutation] + " "
if self.stage >= 4:
output += self.color_list[self.color] + " "
output += self.stage_list[self.stage] + " "
if self.stage >= 2:
output += self.species_list[self.species] + " "
return output.strip()
def rarity_check(self):
# Generate plant rarity
CONST_RARITY_MAX = 256.0
rare_seed = random.randint(1, int(CONST_RARITY_MAX))
common_range = round((2.0 / 3) * CONST_RARITY_MAX)
uncommon_range = round((2.0 / 3) * (CONST_RARITY_MAX - common_range))
rare_range = round((2.0 / 3) * (CONST_RARITY_MAX - common_range - uncommon_range))
legendary_range = round((2.0 / 3) * (CONST_RARITY_MAX - common_range - uncommon_range - rare_range))
common_max = common_range
uncommon_max = common_max + uncommon_range
rare_max = uncommon_max + rare_range
legendary_max = rare_max + legendary_range
godly_max = CONST_RARITY_MAX
rarity = 0
if 0 <= rare_seed <= common_max:
rarity = 0
elif common_max < rare_seed <= uncommon_max:
rarity = 1
elif uncommon_max < rare_seed <= rare_max:
rarity = 2
elif rare_max < rare_seed <= legendary_max:
rarity = 3
elif legendary_max < rare_seed <= godly_max:
rarity = 4
return rarity
def dead_check(self):
# if it has been >5 days since watering, sorry plant is dead :(
time_delta_watered = int(time.time()) - self.watered_timestamp
if time_delta_watered > (5 * (24 * 3600)):
self.dead = True
return self.dead
def update_visitor_db(self, visitor_names):
game_dir = os.path.dirname(os.path.realpath(__file__))
garden_db_path = os.path.join(game_dir, 'sqlite/garden_db.sqlite')
conn = sqlite3.connect(garden_db_path)
for name in visitor_names:
c = conn.cursor()
c.execute(
"SELECT * FROM visitors WHERE garden_name = ? AND visitor_name = ?", (self.owner, name))
data = c.fetchone()
if data is None:
c.execute(" INSERT INTO visitors (garden_name,visitor_name,weekly_visits) VALUES(?, ?, 1)",
(self.owner, name))
else:
c.execute(
"UPDATE visitors SET weekly_visits = weekly_visits + 1 WHERE garden_name = ? AND visitor_name = ?",
(self.owner, name))
conn.commit()
conn.close()
def guest_check(self):
user_dir = os.path.expanduser("~")
botany_dir = os.path.join(user_dir, '.botany')
visitor_filepath = os.path.join(botany_dir, 'visitors.json')
guest_timestamps = []
visitors_this_check = []
if os.path.isfile(visitor_filepath):
with open(visitor_filepath, 'r') as visit:
data = json.load(visit)
if data:
for element in data:
if element['user'] not in self.visitors:
self.visitors.append(element['user'])
if element['user'] not in visitors_this_check:
visitors_this_check.append(element['user'])
# prevent users from manually setting watered_time in the future
if int(time.time()) >= element['timestamp'] >= self.watered_timestamp:
guest_timestamps.append(element['timestamp'])
try:
self.update_visitor_db(visitors_this_check)
except:
pass
with open(visitor_filepath, 'w') as visitor:
visitor.write('[]')
else:
with open(visitor_filepath, mode='w') as f:
json.dump([], f)
os.chmod(visitor_filepath, 0o666)
if not guest_timestamps:
return self.watered_timestamp
all_timestamps = [self.watered_timestamp] + guest_timestamps
all_timestamps.sort()
# calculate # of days between each guest watering
timestamp_diffs = [(j - i) / 86400.0 for i, j in zip(all_timestamps[:-1], all_timestamps[1:])]
# plant's latest timestamp should be set to last timestamp before a
# gap of 5 days
# TODO: this considers a plant watered only on day 1 and day 4 to be
# watered for all 4 days - need to figure out how to only add score
# from 24h after each watered timestamp
last_valid_element = next((x for x in timestamp_diffs if x > 5), None)
if not last_valid_element:
# all timestamps are within a 5-day range, can just use the latest one
return all_timestamps[-1]
last_valid_index = timestamp_diffs.index(last_valid_element)
# slice list to only include up until a >5 day gap
valid_timestamps = all_timestamps[:last_valid_index + 1]
return valid_timestamps[-1]
def water_check(self):
self.watered_timestamp = self.guest_check()
self.time_delta_watered = int(time.time()) - self.watered_timestamp
if self.time_delta_watered <= (24 * 3600):
if not self.watered_24h:
self.watered_24h = True
return True
else:
self.watered_24h = False
return False
def mutate_check(self):
# Create plant mutation
# Increase this # to make mutation rarer (chance 1 out of x each second)
CONST_MUTATION_RARITY = 20000
mutation_seed = random.randint(1, CONST_MUTATION_RARITY)
if mutation_seed == CONST_MUTATION_RARITY:
# mutation gained!
mutation = random.randint(0, len(self.mutation_list) - 1)
if self.mutation == 0:
self.mutation = mutation
return True
else:
return False
def growth(self):
# Increase plant growth stage
if self.stage < (len(self.stage_list) - 1):
self.stage += 1
def water(self):
# Increase plant growth stage
if not self.dead:
self.watered_timestamp = int(time.time())
self.watered_24h = True
def start_over(self):
# After plant reaches final stage, given option to restart
# increment generation only if previous stage is final stage and plant
# is alive
if not self.dead:
next_generation = self.generation + 1
else:
# Should this reset to 1? Seems unfair… for now generations will
# persist through death.
next_generation = self.generation
self.write_lock = True
self.kill_plant()
while self.write_lock:
# Wait for garden writer to unlock
# garden db needs to update before allowing the user to reset
pass
if not self.write_lock:
self.__init__(self.file_name, next_generation)
def kill_plant(self):
self.dead = True
def unlock_new_creation(self):
self.write_lock = False
def start_life(self):
# runs life on a thread
thread = threading.Thread(target=self.life, args=())
thread.daemon = True
thread.start()
def life(self):
# I've created life :)
while True:
if not self.dead:
if self.watered_24h:
self.ticks += 1
if self.stage < len(self.stage_list) - 1:
if self.ticks >= self.life_stages[self.stage]:
self.growth()
if self.mutate_check():
pass
if self.water_check():
# Do something
pass
if self.dead_check():
# Do something else
pass
# TODO: event check
generation_bonus = 0.2 * (self.generation - 1)
adjusted_sleep_time = 1 / (1 + generation_bonus)
time.sleep(adjusted_sleep_time)
class DataManager(object):
# handles user data, puts a .botany dir in user's home dir (OSX/Linux)
# handles shared data with sqlite db
# TODO: .dat save should only happen on mutation, water, death, exit,
# harvest, otherwise
# data hasn't changed...
# can write json whenever bc this isn't ever read for data within botany
user_dir = os.path.expanduser("~")
botany_dir = os.path.join(user_dir, '.botany')
game_dir = os.path.dirname(os.path.realpath(__file__))
this_user = getpass.getuser()
savefile_name = this_user + '_plant.dat'
savefile_path = os.path.join(botany_dir, savefile_name)
# set this.savefile_path to guest_garden path
garden_db_path = os.path.join(game_dir, 'sqlite/garden_db.sqlite')
garden_json_path = os.path.join(game_dir, 'garden_file.json')
harvest_file_path = os.path.join(botany_dir, 'harvest_file.dat')
harvest_json_path = os.path.join(botany_dir, 'harvest_file.json')
def __init__(self):
self.this_user = getpass.getuser()
# check if instance is already running
# check for .botany dir in home
try:
os.makedirs(self.botany_dir)
except OSError as exception:
if exception.errno != errno.EEXIST:
raise
self.savefile_name = self.this_user + '_plant.dat'
def check_plant(self):
# check for existing save file
if os.path.isfile(self.savefile_path):
return True
else:
return False
def start_threads(self, this_plant):
# create threads to save files every minute
death_check_thread = threading.Thread(target=self.death_check_update, args=(this_plant,))
death_check_thread.daemon = True
death_check_thread.start()
autosave_thread = threading.Thread(target=self.autosave, args=(this_plant,))
autosave_thread.daemon = True
autosave_thread.start()
def death_check_update(self, this_plant):
# .1 second updates and lock to minimize race condition
while True:
is_dead = this_plant.dead_check()
if is_dead:
self.save_plant(this_plant)
self.data_write_json(this_plant)
self.update_garden_db(this_plant)
self.harvest_plant(this_plant)
this_plant.unlock_new_creation()
time.sleep(.1)
def autosave(self, this_plant):
# running on thread, saves plant every 5s TODO: this is unnecessary
# and breaks shit probably
file_update_count = 0
while True:
file_update_count += 1
self.save_plant(this_plant)
self.data_write_json(this_plant)
self.update_garden_db(this_plant)
if file_update_count == 12:
# only update garden json every 60s
self.update_garden_json()
time.sleep(5)
file_update_count %= 12
def load_plant(self):
# load savefile
with open(self.savefile_path, 'rb') as f:
this_plant = pickle.load(f)
# migrate data structure to create data for empty/nonexistent plant
# properties
this_plant.migrate_properties()
# get status since last login
is_watered = this_plant.water_check()
is_dead = this_plant.dead_check()
if not is_dead:
if is_watered:
time_delta_last = int(time.time()) - this_plant.last_time
ticks_to_add = min(time_delta_last, 24 * 3600)
this_plant.time_delta_watered = 0
self.last_water_gain = time.time()
else:
ticks_to_add = 0
this_plant.ticks += ticks_to_add * (0.2 * (this_plant.generation - 1) + 1)
return this_plant
def plant_age_convert(self, this_plant):
# human-readable plant age
age_seconds = int(time.time()) - this_plant.start_time
days, age_seconds = divmod(age_seconds, 24 * 60 * 60)
hours, age_seconds = divmod(age_seconds, 60 * 60)
minutes, age_seconds = divmod(age_seconds, 60)
age_formatted = ("%dd:%dh:%dm:%ds" % (days, hours, minutes, age_seconds))
return age_formatted
def init_database(self):
# check if dir exists, create sqlite directory and set OS permissions to 777
sqlite_dir_path = os.path.join(self.game_dir, 'sqlite')
if not os.path.exists(sqlite_dir_path):
os.makedirs(sqlite_dir_path)
os.chmod(sqlite_dir_path, 0o777)
conn = sqlite3.connect(self.garden_db_path)
init_table_string = """CREATE TABLE IF NOT EXISTS garden (
plant_id tinytext PRIMARY KEY,
owner text,
description text,
age text,
score integer,
is_dead numeric
)"""
c = conn.cursor()
c.execute(init_table_string)
conn.close()
# init only, creates and sets permissions for garden db and json
if os.stat(self.garden_db_path).st_uid == os.getuid():
os.chmod(self.garden_db_path, 0o666)
open(self.garden_json_path, 'a').close()
os.chmod(self.garden_json_path, 0o666)
def migrate_database(self):
conn = sqlite3.connect(self.garden_db_path)
migrate_table_string = """CREATE TABLE IF NOT EXISTS visitors (
id integer PRIMARY KEY,
garden_name text,
visitor_name text,
weekly_visits integer
)"""
c = conn.cursor()
c.execute(migrate_table_string)
conn.close()
return True
def update_garden_db(self, this_plant):
# insert or update this plant id's entry in DB
# TODO: make sure other instances of user are deleted
# Could create a clean db function
self.init_database()
self.migrate_database()
age_formatted = self.plant_age_convert(this_plant)
conn = sqlite3.connect(self.garden_db_path)
c = conn.cursor()
# try to insert or replace
update_query = """INSERT OR REPLACE INTO garden (
plant_id, owner, description, age, score, is_dead
) VALUES (
:pid, :owner, :description, :page, :score, :dead
)"""
c.execute(update_query, {"pid": this_plant.plant_id,
"owner": this_plant.owner,
"description": this_plant.parse_plant(),
"page": age_formatted,
"score": str(this_plant.ticks),
"dead": int(this_plant.dead)})
conn.commit()
conn.close()
def retrieve_garden_from_db(self):
# Builds a dict of dicts from garden sqlite db
garden_dict = {}
conn = sqlite3.connect(self.garden_db_path)
# Need to allow write permissions by others
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute('SELECT * FROM garden ORDER BY owner')
tuple_list = c.fetchall()
conn.close()
# Building dict from table rows
for item in tuple_list:
garden_dict[item[0]] = {
"owner": item[1],
"description": item[2],
"age": item[3],
"score": item[4],
"dead": item[5],
}
return garden_dict
def update_garden_json(self):
this_garden = self.retrieve_garden_from_db()
with open(self.garden_json_path, 'w') as outfile:
json.dump(this_garden, outfile)
pass
def save_plant(self, this_plant):
# create savefile
this_plant.last_time = int(time.time())
temp_path = self.savefile_path + ".temp"
with open(temp_path, 'wb') as f:
pickle.dump(this_plant, f, protocol=2)
os.rename(temp_path, self.savefile_path)
def data_write_json(self, this_plant):
# create personal json file for user to use outside the game (website?)
json_file = os.path.join(self.botany_dir, self.this_user + '_plant_data.json')
# also updates age
age_formatted = self.plant_age_convert(this_plant)
plant_info = {
"owner": this_plant.owner,
"description": this_plant.parse_plant(),
"age": age_formatted,
"score": this_plant.ticks,
"is_dead": this_plant.dead,
"last_watered": this_plant.watered_timestamp,
"file_name": this_plant.file_name,
"stage": this_plant.stage_list[this_plant.stage],
"generation": this_plant.generation,
}
if this_plant.stage >= 3:
plant_info["rarity"] = this_plant.rarity_list[this_plant.rarity]
if this_plant.mutation != 0:
plant_info["mutation"] = this_plant.mutation_list[this_plant.mutation]
if this_plant.stage >= 4:
plant_info["color"] = this_plant.color_list[this_plant.color]
if this_plant.stage >= 2:
plant_info["species"] = this_plant.species_list[this_plant.species]
with open(json_file, 'w') as outfile:
json.dump(plant_info, outfile)
def harvest_plant(self, this_plant):
# TODO: plant history feature - could just use a sqlite query to retrieve all of user's dead plants
# harvest is a dict of dicts
# harvest contains one entry for each plant id
age_formatted = self.plant_age_convert(this_plant)
this_plant_id = this_plant.plant_id
plant_info = {
"description": this_plant.parse_plant(),
"age": age_formatted,
"score": this_plant.ticks,
}
if os.path.isfile(self.harvest_file_path):
# harvest file exists: load data
with open(self.harvest_file_path, 'rb') as f:
this_harvest = pickle.load(f)
new_file_check = False
else:
this_harvest = {}
new_file_check = True
this_harvest[this_plant_id] = plant_info
# dump harvest file
temp_path = self.harvest_file_path + ".temp"
with open(temp_path, 'wb') as f:
pickle.dump(this_harvest, f, protocol=2)
os.rename(temp_path, self.harvest_file_path)
# dump json file
with open(self.harvest_json_path, 'w') as outfile:
json.dump(this_harvest, outfile)
return new_file_check
from plant import Plant
if __name__ == '__main__':
my_data = DataManager()

View File

@ -36,7 +36,7 @@ class LoginCompleter:
backwards if direction is negative
"""
def loginFilter(x):
def login_filter(x):
return x.startswith(self.s) & (x != self.s)
# Refresh possible completions after the user edits
@ -44,7 +44,7 @@ class LoginCompleter:
if self.logins is None:
self.initialize()
self.completion_base = self.s
self.completions = list(filter(loginFilter, self.logins))
self.completions = list(filter(login_filter, self.logins))
self.completion_id += direction
# Loop from the back

109
consts.py Normal file
View File

@ -0,0 +1,109 @@
RARITY_MAX = 256.0
MUTATION_RARITY = 20000
stages = [
'seed',
'seedling',
'young',
'mature',
'flowering',
'seed-bearing',
]
colors = [
'red',
'orange',
'yellow',
'green',
'blue',
'indigo',
'violet',
'white',
'black',
'gold',
'rainbow',
]
rarities = [
'common',
'uncommon',
'rare',
'legendary',
'godly',
]
species = [
'poppy',
'cactus',
'aloe',
'venus flytrap',
'jade plant',
'fern',
'daffodil',
'sunflower',
'baobab',
'lithops',
'hemp',
'pansy',
'iris',
'agave',
'ficus',
'moss',
'sage',
'snapdragon',
'columbine',
'brugmansia',
'palm',
'pachypodium',
]
mutations = [
'',
'humming',
'noxious',
'vorpal',
'glowing',
'electric',
'icy',
'flaming',
'psychic',
'screaming',
'chaotic',
'hissing',
'gelatinous',
'deformed',
'shaggy',
'scaly',
'depressed',
'anxious',
'metallic',
'glossy',
'psychedelic',
'bonsai',
'foamy',
'singing',
'fractal',
'crunchy',
'goth',
'oozing',
'stinky',
'aromatic',
'juicy',
'smug',
'vibrating',
'lithe',
'chalky',
'naive',
'ersatz',
'disco',
'levitating',
'colossal',
'luminous',
'cosmic',
'ethereal',
'cursed',
'buff',
'narcotic',
'gnu/linux',
'abraxan', # rip dear friend
]

849
cursed_menu.py Normal file
View File

@ -0,0 +1,849 @@
import curses
import datetime
import getpass
import json
import math
import os
import random
import re
import sqlite3
import string
import threading
import time
import traceback
import completer
import consts
from menu_screen import cleanup
class CursedMenu(object):
# TODO: name your plant
"""A class which abstracts the horrors of building a curses-based menu system"""
def __init__(self, this_plant, this_data):
"""Initialization"""
self.title = None
self.selected = None
self.options = None
self.selected = None
self.subtitle = None
self.initialized = False
self.screen = curses.initscr()
curses.noecho()
curses.raw()
if curses.has_colors():
curses.start_color()
try:
curses.curs_set(0)
except curses.error:
# Not all terminals support this functionality.
# When the error is ignored the screen will look a little uglier, but that's not terrible
# So in order to keep botany as accessible as possible to everyone, it should be safe to ignore the error.
pass
self.screen.keypad(True)
self.plant = this_plant
self.visited_plant = None
self.user_data = this_data
self.plant_string = self.plant.parse_plant()
self.plant_ticks = str(int(self.plant.ticks))
self.exit = False
self.infotoggle = 0
self.maxy, self.maxx = self.screen.getmaxyx()
# Highlighted and Normal line definitions
if curses.has_colors():
self.define_colors()
self.highlighted = curses.color_pair(1)
else:
self.highlighted = curses.A_REVERSE
self.normal = curses.A_NORMAL
# Threaded screen update for live changes
screen_thread = threading.Thread(target=self.update_plant_live, args=())
screen_thread.daemon = True
screen_thread.start()
# Recursive lock to prevent both threads from drawing at the same time
self.screen_lock = threading.RLock()
self.screen.clear()
self.show(["water", "look", "garden", "visit", "instructions"], title=' botany ', subtitle='options')
@staticmethod
def define_colors():
# TODO: implement colors
# set curses color pairs manually
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLACK)
curses.init_pair(3, curses.COLOR_GREEN, curses.COLOR_BLACK)
curses.init_pair(4, curses.COLOR_BLUE, curses.COLOR_BLACK)
curses.init_pair(5, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
curses.init_pair(6, curses.COLOR_YELLOW, curses.COLOR_BLACK)
curses.init_pair(7, curses.COLOR_RED, curses.COLOR_BLACK)
curses.init_pair(8, curses.COLOR_CYAN, curses.COLOR_BLACK)
def show(self, options, title, subtitle):
# Draws a menu with parameters
self.set_options(options)
self.update_options()
self.title = title
self.subtitle = subtitle
self.selected = 0
self.initialized = True
self.draw_menu()
def update_options(self):
# Makes sure you can get a new plant if it dies
if self.plant.dead or self.plant.stage == 5:
if "harvest" not in self.options:
self.options.insert(-1, "harvest")
else:
if "harvest" in self.options:
self.options.remove("harvest")
def set_options(self, options):
# Validates that the last option is "exit"
if options[-1] != 'exit':
options.append('exit')
self.options = options
def draw(self):
# Draw the menu and lines
self.maxy, self.maxx = self.screen.getmaxyx()
self.screen_lock.acquire()
self.screen.refresh()
try:
self.draw_default()
self.screen.refresh()
except Exception:
# Makes sure data is saved in event of a crash due to window resizing
self.screen.clear()
self.screen.addstr(0, 0, "Enlarge terminal!", curses.A_NORMAL)
self.screen.refresh()
self.__exit__()
traceback.print_exc()
self.screen_lock.release()
def draw_menu(self):
# Actually draws the menu and handles branching
request = ""
try:
while request != "exit":
self.draw()
request = self.get_user_input()
self.handle_request(request)
self.__exit__()
# Also calls __exit__, but adds traceback after
except IOError:
self.screen.clear()
self.screen.refresh()
self.__exit__()
except Exception:
self.screen.clear()
self.screen.addstr(0, 0, "Enlarge terminal!", curses.A_NORMAL)
self.screen.refresh()
self.__exit__()
# traceback.print_exc()
def ascii_render(self, filename, ypos, xpos):
# Prints ASCII art from file at given coordinates
this_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "art")
this_filename = os.path.join(this_dir, filename)
this_file = open(this_filename, "r")
this_string = this_file.readlines()
this_file.close()
self.screen_lock.acquire()
for y, line in enumerate(this_string, 2):
self.screen.addstr(ypos + y, xpos, line, curses.A_NORMAL)
# self.screen.refresh()
self.screen_lock.release()
def draw_plant_ascii(self, this_plant):
ypos = 0
xpos = int((self.maxx - 37) / 2 + 25)
if this_plant.dead:
self.ascii_render('rip.txt', ypos, xpos)
elif datetime.date.today().month == 10 and datetime.date.today().day == 31:
self.ascii_render('jackolantern.txt', ypos, xpos)
elif this_plant.stage == 0:
self.ascii_render('seed.txt', ypos, xpos)
elif this_plant.stage == 1:
self.ascii_render('seedling.txt', ypos, xpos)
elif this_plant.stage == 2:
this_filename = consts.species[this_plant.species] + '1.txt'
self.ascii_render(this_filename, ypos, xpos)
elif this_plant.stage == 3 or this_plant.stage == 5:
this_filename = consts.species[this_plant.species] + '2.txt'
self.ascii_render(this_filename, ypos, xpos)
elif this_plant.stage == 4:
this_filename = consts.species[this_plant.species] + '3.txt'
self.ascii_render(this_filename, ypos, xpos)
def draw_default(self):
# draws default menu
clear_bar = " " * (int(self.maxx * 2 / 3))
self.screen_lock.acquire()
self.screen.addstr(1, 2, self.title, curses.A_STANDOUT) # Title for this menu
self.screen.addstr(3, 2, self.subtitle, curses.A_BOLD) # Subtitle for this menu
# clear menu on screen
for index in range(len(self.options) + 1):
self.screen.addstr(4 + index, 4, clear_bar, curses.A_NORMAL)
# display all the menu items, showing the 'pos' item highlighted
for index in range(len(self.options)):
textstyle = self.normal
if index == self.selected:
textstyle = self.highlighted
self.screen.addstr(4 + index, 4, clear_bar, curses.A_NORMAL)
self.screen.addstr(4 + index, 4, "%d - %s" % (index + 1, self.options[index]), textstyle)
self.screen.addstr(12, 2, clear_bar, curses.A_NORMAL)
self.screen.addstr(13, 2, clear_bar, curses.A_NORMAL)
self.screen.addstr(12, 2, "plant: ", curses.A_DIM)
self.screen.addstr(12, 9, self.plant_string, curses.A_NORMAL)
self.screen.addstr(13, 2, "score: ", curses.A_DIM)
self.screen.addstr(13, 9, self.plant_ticks, curses.A_NORMAL)
# display fancy water gauge
if not self.plant.dead:
water_gauge_str = self.water_gauge()
self.screen.addstr(4, 14, water_gauge_str, curses.A_NORMAL)
else:
self.screen.addstr(4, 13, clear_bar, curses.A_NORMAL)
self.screen.addstr(4, 14, "( RIP )", curses.A_NORMAL)
# draw cute ascii from files
if self.visited_plant:
# Needed to prevent drawing over a visited plant
self.draw_plant_ascii(self.visited_plant)
else:
self.draw_plant_ascii(self.plant)
self.screen_lock.release()
def water_gauge(self):
# build nice looking water gauge
water_left_pct = 1 - ((time.time() - self.plant.watered_timestamp) / 86400)
# don't allow negative value
water_left_pct = max(0, water_left_pct)
water_left = int(math.ceil(water_left_pct * 10))
water_string = "(" + (")" * water_left) + ("." * (10 - water_left)) + ") " + str(
int(water_left_pct * 100)) + "% "
return water_string
def update_plant_live(self):
# updates plant data on menu screen, live!
while not self.exit:
self.plant_string = self.plant.parse_plant()
self.plant_ticks = str(int(self.plant.ticks))
if self.initialized:
self.update_options()
self.draw()
time.sleep(1)
def get_user_input(self):
# gets the user's input
user_in = 0
try:
user_in = self.screen.getch() # Gets user input
except Exception:
self.__exit__()
if user_in == -1: # Input comes from pipe/file and is closed
raise IOError
# DEBUG KEYS - enable these lines to see curses key codes
# self.screen.addstr(2, 2, str(user_in), curses.A_NORMAL)
# self.screen.refresh()
# Resize sends curses.KEY_RESIZE, update display
if user_in == curses.KEY_RESIZE:
self.maxy, self.maxx = self.screen.getmaxyx()
self.screen.clear()
self.screen.refresh()
# enter, exit, and Q Keys are special cases
if user_in == 10:
return self.options[self.selected]
if user_in == 27:
return self.options[-1]
if user_in == 113:
self.selected = len(self.options) - 1
return
# this is a number; check to see if we can set it
if ord('1') <= user_in <= ord(str(min(7, len(self.options)))):
self.selected = user_in - ord('0') - 1 # convert keypress back to a number, then subtract 1 to get index
return
# increment or Decrement
down_keys = [curses.KEY_DOWN, 14, ord('j')]
up_keys = [curses.KEY_UP, 16, ord('k')]
if user_in in down_keys: # down arrow
self.selected += 1
if user_in in up_keys: # up arrow
self.selected -= 1
# modulo to wrap menu cursor
self.selected = self.selected % len(self.options)
return
@staticmethod
def format_garden_data(this_garden):
# Returns list of lists (pages) of garden entries
plant_table = []
for plant_id in this_garden:
if this_garden[plant_id]:
if not this_garden[plant_id]["dead"]:
this_plant = this_garden[plant_id]
plant_table.append((this_plant["owner"],
this_plant["age"],
int(this_plant["score"]),
this_plant["description"]))
return plant_table
@staticmethod
def format_garden_entry(entry):
return "{:14.14} - {:>16} - {:>8}p - {}".format(*entry)
@staticmethod
def sort_garden_table(table, column, ascending):
""" Sort table in place by a specified column """
def key(entry):
entry = entry[column]
# In when sorting ages, convert to seconds
if column == 1:
coeffs = [24 * 60 * 60, 60 * 60, 60, 1]
nums = [int(n[:-1]) for n in entry.split(":")]
if len(nums) == len(coeffs):
entry = sum(nums[i] * coeffs[i] for i in range(len(nums)))
return entry
return table.sort(key=key, reverse=not ascending)
def filter_garden_table(self, table, pattern):
""" Filter table using a pattern, and return the new table """
def filterfunc(entry):
if len(pattern) == 0:
return True
entry_txt = self.format_garden_entry(entry)
try:
result = bool(re.search(pattern, entry_txt))
except Exception:
# In case of invalid regex, don't match anything
result = False
return result
return list(filter(filterfunc, table))
def draw_garden(self):
# draws community garden
# load data from sqlite db
this_garden = self.user_data.retrieve_garden_from_db()
# format data
self.clear_info_pane()
if self.infotoggle == 2:
# the screen IS currently showing the garden (1 page), make the
# text a bunch of blanks to clear it out
self.infotoggle = 0
return
# if infotoggle isn't 2, the screen currently displays other stuff
plant_table_orig = self.format_garden_data(this_garden)
self.infotoggle = 2
# print garden information OR clear it
index = 0
sort_column, sort_ascending = 0, True
sort_keys = ["n", "a", "s", "d"] # Name, Age, Score, Description
plant_table = plant_table_orig
self.sort_garden_table(plant_table, sort_column, sort_ascending)
while True:
entries_per_page = self.maxy - 16
index_max = min(len(plant_table), index + entries_per_page)
plants = plant_table[index:index_max]
page = [self.format_garden_entry(entry) for entry in plants]
self.screen_lock.acquire()
self.draw_info_text(page)
# Multiple pages, paginate and require keypress
page_text = "(%d-%d/%d) | sp/next | bksp/prev | s <col #>/sort | f/filter | q/quit" % (
index, index_max, len(plant_table))
self.screen.addstr(self.maxy - 2, 2, page_text)
self.screen.refresh()
self.screen_lock.release()
c = self.screen.getch()
if c == -1: # Input comes from pipe/file and is closed
raise IOError
self.infotoggle = 0
# Quit
if c == ord("q") or c == ord("x") or c == 27:
break
# Next page
elif c in [curses.KEY_ENTER, curses.KEY_NPAGE, ord(" "), ord("\n")]:
index += entries_per_page
if index >= len(plant_table):
break
# Previous page
elif c == curses.KEY_BACKSPACE or c == curses.KEY_PPAGE:
index = max(index - entries_per_page, 0)
# Next line
elif c == ord("j") or c == curses.KEY_DOWN:
index = max(min(index + 1, len(plant_table) - 1), 0)
# Previous line
elif c == ord("k") or c == curses.KEY_UP:
index = max(index - 1, 0)
# Sort entries
elif c == ord("s"):
c = self.screen.getch()
if c == -1: # Input comes from pipe/file and is closed
raise IOError
column = -1
if c < 255 and chr(c) in sort_keys:
column = sort_keys.index(chr(c))
elif ord("1") <= c <= ord("4"):
column = c - ord("1")
if column != -1:
if sort_column == column:
sort_ascending = not sort_ascending
else:
sort_column = column
sort_ascending = True
self.sort_garden_table(plant_table, sort_column, sort_ascending)
# Filter entries
elif c == ord("/") or c == ord("f"):
self.screen.addstr(self.maxy - 2, 2, "Filter: " + " " * (len(page_text) - 8))
pattern = self.get_user_string(10, self.maxy - 2, lambda x: x in string.printable)
plant_table = self.filter_garden_table(plant_table_orig, pattern)
self.sort_garden_table(plant_table, sort_column, sort_ascending)
index = 0
# Clear page before drawing next
self.clear_info_pane()
self.clear_info_pane()
@staticmethod
def get_plant_description(this_plant):
output_text = ""
this_species = this_plant.species_list[this_plant.species]
this_color = this_plant.color_list[this_plant.color]
this_stage = this_plant.stage
stage_descriptions = {
0: [
"You're excited about your new seed.",
"You wonder what kind of plant your seed will grow into.",
"You're ready for a new start with this plant.",
"You're tired of waiting for your seed to grow.",
"You wish your seed could tell you what it needs.",
"You can feel the spirit inside your seed.",
"These pretzels are making you thirsty.",
"Way to plant, Ann!",
"'To see things in the seed, that is genius' - Lao Tzu",
],
1: [
"The seedling fills you with hope.",
"The seedling shakes in the wind.",
"You can make out a tiny leaf - or is that a thorn?",
"You can feel the seedling looking back at you.",
"You blow a kiss to your seedling.",
"You think about all the seedlings who came before it.",
"You and your seedling make a great team.",
"Your seedling grows slowly and quietly.",
"You meditate on the paths your plant's life could take.",
],
2: [
"The " + this_species + " makes you feel relaxed.",
"You sing a song to your " + this_species + ".",
"You quietly sit with your " + this_species + " for a few minutes.",
"Your " + this_species + " looks pretty good.",
"You play loud techno to your " + this_species + ".",
"You play piano to your " + this_species + ".",
"You play rap music to your " + this_species + ".",
"You whistle a tune to your " + this_species + ".",
"You read a poem to your " + this_species + ".",
"You tell a secret to your " + this_species + ".",
"You play your favorite record for your " + this_species + ".",
],
3: [
"Your " + this_species + " is growing nicely!",
"You're proud of the dedication it took to grow your " + this_species + ".",
"You take a deep breath with your " + this_species + ".",
"You think of all the words that rhyme with " + this_species + ".",
"The " + this_species + " looks full of life.",
"The " + this_species + " inspires you.",
"Your " + this_species + " makes you forget about your problems.",
"Your " + this_species + " gives you a reason to keep going.",
"Looking at your " + this_species + " helps you focus on what matters.",
"You think about how nice this " + this_species + " looks here.",
"The buds of your " + this_species + " might bloom soon.",
],
4: [
"The " + this_color + " flowers look nice on your " + this_species + "!",
"The " + this_color + " flowers have bloomed and fill you with positivity.",
"The " + this_color + " flowers remind you of your childhood.",
"The " + this_color + " flowers remind you of spring mornings.",
"The " + this_color + " flowers remind you of a forgotten memory.",
"The " + this_color + " flowers remind you of your happy place.",
"The aroma of the " + this_color + " flowers energize you.",
"The " + this_species + " has grown beautiful " + this_color + " flowers.",
"The " + this_color + " petals remind you of that favorite shirt you lost.",
"The " + this_color + " flowers remind you of your crush.",
"You smell the " + this_color + " flowers and are filled with peace.",
],
5: [
"You fondly remember the time you spent caring for your " + this_species + ".",
"Seed pods have grown on your " + this_species + ".",
"You feel like your " + this_species + " appreciates your care.",
"The " + this_species + " fills you with love.",
"You're ready for whatever comes after your " + this_species + ".",
"You're excited to start growing your next plant.",
"You reflect on when your " + this_species + " was just a seedling.",
"You grow nostalgic about the early days with your " + this_species + ".",
],
99: [
"You wish you had taken better care of your plant.",
"If only you had watered your plant more often..",
"Your plant is dead, there's always next time.",
"You cry over the withered leaves of your plant.",
"Your plant died. Maybe you need a fresh start.",
],
}
# self.life_stages is tuple containing length of each stage
# (seed, seedling, young, mature, flowering)
if this_plant.dead:
this_stage = 99
this_stage_descriptions = stage_descriptions[this_stage]
description_num = random.randint(0, len(this_stage_descriptions) - 1)
# If not fully grown
if this_stage <= 4:
# Growth hint
if this_stage >= 1:
last_growth_at = this_plant.life_stages[this_stage - 1]
else:
last_growth_at = 0
ticks_since_last = this_plant.ticks - last_growth_at
ticks_between_stage = this_plant.life_stages[this_stage] - last_growth_at
if ticks_since_last >= ticks_between_stage * 0.8:
output_text += "You notice your plant looks different.\n"
output_text += this_stage_descriptions[description_num] + "\n"
# if seedling
if this_stage == 1:
species_options = [this_plant.species_list[this_plant.species],
this_plant.species_list[(this_plant.species + 3) % len(this_plant.species_list)],
this_plant.species_list[(this_plant.species - 3) % len(this_plant.species_list)]]
random.shuffle(species_options)
plant_hint = "It could be a(n) " + species_options[0] + ", " + species_options[1] + ", or " + \
species_options[2]
output_text += plant_hint + ".\n"
# if young plant
if this_stage == 2:
if this_plant.rarity >= 2:
rarity_hint = "You feel like your plant is special."
output_text += rarity_hint + ".\n"
# if mature plant
if this_stage == 3:
color_options = [this_plant.color_list[this_plant.color],
this_plant.color_list[(this_plant.color + 3) % len(this_plant.color_list)],
this_plant.color_list[(this_plant.color - 3) % len(this_plant.color_list)]]
random.shuffle(color_options)
plant_hint = "You can see the first hints of " + color_options[0] + ", " + color_options[1] + ", or " + \
color_options[2]
output_text += plant_hint + ".\n"
return output_text
def draw_plant_description(self, this_plant):
# If menu is currently showing something other than the description
self.clear_info_pane()
if self.infotoggle != 1:
# get plant description before printing
output_string = self.get_plant_description(this_plant)
growth_multiplier = 1 + (0.2 * (this_plant.generation - 1))
output_string += "Generation: {}\nGrowth rate: {}x".format(self.plant.generation, growth_multiplier)
self.draw_info_text(output_string)
self.infotoggle = 1
else:
# otherwise just set toggle
self.infotoggle = 0
def draw_instructions(self):
# Draw instructions on screen
self.clear_info_pane()
if self.infotoggle != 4:
instructions_txt = ("welcome to botany. you've been given a seed\n"
"that will grow into a beautiful plant. check\n"
"in and water your plant every 24h to keep it\n"
"growing. 5 days without water = death. your\n"
"plant depends on you & your friends to live!\n"
"more info is available in the readme :)\n"
"https://github.com/jifunks/botany/blob/master/README.md\n"
" cheers,\n"
" curio\n"
)
self.draw_info_text(instructions_txt)
self.infotoggle = 4
else:
self.infotoggle = 0
def clear_info_pane(self):
# Clears bottom part of screen
self.screen_lock.acquire()
clear_bar = " " * (self.maxx - 3)
this_y = 14
while this_y < self.maxy:
self.screen.addstr(this_y, 2, clear_bar, curses.A_NORMAL)
this_y += 1
self.screen.refresh()
self.screen_lock.release()
def draw_info_text(self, info_text, y_offset=0):
# print lines of text to info pane at bottom of screen
self.screen_lock.acquire()
if type(info_text) is str:
info_text = info_text.splitlines()
for y, line in enumerate(info_text, 2):
this_y = y + 12 + y_offset
if len(line) > self.maxx - 3:
line = line[:self.maxx - 3]
if this_y < self.maxy:
self.screen.addstr(this_y, 2, line, curses.A_NORMAL)
self.screen.refresh()
self.screen_lock.release()
def harvest_confirmation(self):
self.clear_info_pane()
# get plant description before printing
max_stage = len(self.plant.stage_list) - 1
harvest_text = ""
if not self.plant.dead:
if self.plant.stage == max_stage:
harvest_text += "Congratulations! You raised your plant to its final stage of growth.\n"
harvest_text += "Your next plant will grow at a speed of: {}x\n".format(
1 + (0.2 * self.plant.generation))
harvest_text += "If you harvest your plant you'll start over from a seed.\nContinue? (Y/n)"
self.draw_info_text(harvest_text)
user_in = 0
try:
user_in = self.screen.getch() # Gets user input
except Exception:
self.__exit__()
if user_in == -1: # Input comes from pipe/file and is closed
raise IOError
if user_in in [ord('Y'), ord('y')]:
self.plant.start_over()
else:
pass
self.clear_info_pane()
def build_weekly_visitor_output(self, visitors):
visitor_block = ""
visitor_line = ""
for visitor in visitors:
this_visitor_string = str(visitor) + "({}) ".format(visitors[str(visitor)])
if len(visitor_line + this_visitor_string) > self.maxx - 3:
visitor_block += '\n'
visitor_line = ""
visitor_block += this_visitor_string
visitor_line += this_visitor_string
return visitor_block
def build_latest_visitor_output(self, visitors):
visitor_line = ""
for visitor in visitors:
if len(visitor_line + visitor) > self.maxx - 10:
visitor_line += "and more"
break
visitor_line += visitor + ' '
return [visitor_line]
def get_weekly_visitors(self):
game_dir = os.path.dirname(os.path.realpath(__file__))
garden_db_path = os.path.join(game_dir, 'sqlite/garden_db.sqlite')
conn = sqlite3.connect(garden_db_path)
c = conn.cursor()
c.execute("SELECT * FROM visitors WHERE garden_name = ? ORDER BY weekly_visits", self.plant.owner)
visitor_data = c.fetchall()
conn.close()
visitor_block = ""
visitor_line = ""
if visitor_data:
for visitor in visitor_data:
visitor_name = visitor[2]
weekly_visits = visitor[3]
this_visitor_string = "{}({}) ".format(visitor_name, weekly_visits)
if len(visitor_line + this_visitor_string) > self.maxx - 3:
visitor_block += '\n'
visitor_line = ""
visitor_block += this_visitor_string
visitor_line += this_visitor_string
else:
visitor_block = 'nobody :('
return visitor_block
def get_user_string(self, xpos=3, ypos=15, filterfunc=str.isalnum, completer=None):
# filter allowed characters using filterfunc, alphanumeric by default
user_string = ""
user_input = 0
if completer:
completer = completer(self)
while user_input != 10:
user_input = self.screen.getch()
if user_input == -1: # Input comes from pipe/file and is closed
raise IOError
self.screen_lock.acquire()
# osx and unix backspace chars...
if user_input == 127 or user_input == 263:
if len(user_string) > 0:
user_string = user_string[:-1]
if completer:
completer.update_input(user_string)
self.screen.addstr(ypos, xpos, " " * (self.maxx - xpos - 1))
elif user_input in [ord('\t'), curses.KEY_BTAB] and completer:
direction = 1 if user_input == ord('\t') else -1
user_string = completer.complete(direction)
self.screen.addstr(ypos, xpos, " " * (self.maxx - xpos - 1))
elif user_input < 256 and user_input != 10:
if filterfunc(chr(user_input)) or chr(user_input) == '_':
user_string += chr(user_input)
if completer:
completer.update_input(user_string)
self.screen.addstr(ypos, xpos, str(user_string))
self.screen.refresh()
self.screen_lock.release()
return user_string
def visit_handler(self):
self.clear_info_pane()
self.draw_info_text("whose plant would you like to visit?")
self.screen.addstr(15, 2, '~')
if self.plant.visitors:
latest_visitor_string = self.build_latest_visitor_output(self.plant.visitors)
self.draw_info_text("since last time, you were visited by: ", 3)
self.draw_info_text(latest_visitor_string, 4)
self.plant.visitors = []
weekly_visitor_text = self.get_weekly_visitors()
self.draw_info_text("this week you've been visited by: ", 6)
self.draw_info_text(weekly_visitor_text, 7)
guest_garden = self.get_user_string(completer=completer.LoginCompleter)
if not guest_garden:
self.clear_info_pane()
return None
if guest_garden.lower() == getpass.getuser().lower():
self.screen.addstr(16, 2, "you're already here!")
self.screen.getch()
self.clear_info_pane()
return None
home_folder = os.path.dirname(os.path.expanduser("~"))
guest_json = home_folder + "/{}/.botany/{}_plant_data.json".format(guest_garden, guest_garden)
guest_plant_description = ""
if os.path.isfile(guest_json):
with open(guest_json) as f:
visitor_data = json.load(f)
guest_plant_description = visitor_data['description']
self.visited_plant = self.get_visited_plant(visitor_data)
guest_visitor_file = home_folder + "/{}/.botany/visitors.json".format(guest_garden, guest_garden)
if os.path.isfile(guest_visitor_file):
water_success = self.water_on_visit(guest_visitor_file)
if water_success:
self.screen.addstr(16, 2,
"...you watered ~{}'s {}...".format(str(guest_garden), guest_plant_description))
if self.visited_plant:
self.draw_plant_ascii(self.visited_plant)
else:
self.screen.addstr(16, 2, "{}'s garden is locked, but you can see in...".format(guest_garden))
else:
self.screen.addstr(16, 2, "i can't seem to find directions to {}...".format(guest_garden))
try:
self.screen.getch()
self.clear_info_pane()
self.draw_plant_ascii(self.plant)
finally:
self.visited_plant = None
@staticmethod
def water_on_visit(guest_visitor_file):
# using -1 here so that old running instances can be watered
guest_data = {'user': getpass.getuser(), 'timestamp': int(time.time()) - 1}
if os.path.isfile(guest_visitor_file):
if not os.access(guest_visitor_file, os.W_OK):
return False
with open(guest_visitor_file) as f:
visitor_data = json.load(f)
visitor_data.append(guest_data)
with open(guest_visitor_file, 'w') as f:
f.write(json.dumps(visitor_data, indent=2))
return True
def get_visited_plant(self, visitor_data):
""" Returns a drawable pseudo plant object from json data """
class VisitedPlant:
pass
plant = VisitedPlant()
plant.stage = 0
plant.species = 0
if "is_dead" not in visitor_data:
return None
plant.dead = visitor_data["is_dead"]
if plant.dead:
return plant
if "stage" in visitor_data:
stage = visitor_data["stage"]
if stage in self.plant.stage_list:
plant.stage = self.plant.stage_list.index(stage)
if "species" in visitor_data:
species = visitor_data["species"]
if species in self.plant.species_list:
plant.species = self.plant.species_list.index(species)
else:
return None
elif plant.stage > 1:
return None
return plant
def handle_request(self, request):
# Menu options call functions here
if request is None:
return
if request == "harvest":
self.harvest_confirmation()
if request == "water":
self.plant.water()
if request == "look":
try:
self.draw_plant_description(self.plant)
except Exception:
self.screen.refresh()
# traceback.print_exc()
if request == "instructions":
try:
self.draw_instructions()
except Exception:
self.screen.refresh()
# traceback.print_exc()
if request == "visit":
try:
self.visit_handler()
except Exception:
self.screen.refresh()
# traceback.print_exc()
if request == "garden":
try:
self.draw_garden()
except Exception:
self.screen.refresh()
# traceback.print_exc()
def __exit__(self):
self.exit = True
cleanup()

236
data_manager.py Normal file
View File

@ -0,0 +1,236 @@
import errno
import getpass
import json
import os
import pickle
import sqlite3
import threading
import time
import consts
class DataManager(object):
# handles user data, puts a .botany dir in user's home dir (OSX/Linux)
# handles shared data with sqlite db
# TODO: .dat save should only happen on mutation, water, death, exit,
# harvest, otherwise
# data hasn't changed...
# can write json whenever bc this isn't ever read for data within botany
user_dir = os.path.expanduser("~")
botany_dir = os.path.join(user_dir, '.botany')
game_dir = os.path.dirname(os.path.realpath(__file__))
this_user = getpass.getuser()
savefile_name = this_user + '_plant.dat'
savefile_path = os.path.join(botany_dir, savefile_name)
# set this.savefile_path to guest_garden path
garden_db_path = os.path.join(game_dir, 'sqlite/garden_db.sqlite')
garden_json_path = os.path.join(game_dir, 'garden_file.json')
harvest_file_path = os.path.join(botany_dir, 'harvest_file.dat')
harvest_json_path = os.path.join(botany_dir, 'harvest_file.json')
def __init__(self):
self.conn = sqlite3.connect(self.garden_db_path)
self.conn.row_factory = sqlite3.Row
self.cursor = self.conn.cursor()
self.last_water_gain = 0.0
self.this_user = getpass.getuser()
# check if instance is already running
# check for .botany dir in home
try:
os.makedirs(self.botany_dir)
except OSError as exception:
if exception.errno != errno.EEXIST:
raise
self.savefile_name = self.this_user + '_plant.dat'
def check_plant(self):
# check for existing save file
return os.path.isfile(self.savefile_path)
def start_threads(self, this_plant):
# create threads to save files every minute
death_check_thread = threading.Thread(target=self.death_check_update, args=(this_plant,))
death_check_thread.daemon = True
death_check_thread.start()
def death_check_update(self, this_plant):
# .1 second updates and lock to minimize race condition
while True:
is_dead = this_plant.dead_check()
if is_dead:
self.save_plant(this_plant)
self.data_write_json(this_plant)
self.update_garden_db(this_plant)
self.harvest_plant(this_plant)
this_plant.unlock_new_creation()
time.sleep(.1)
def load_plant(self):
# load savefile
with open(self.savefile_path, 'rb') as f:
this_plant = pickle.load(f)
# migrate data structure to create data for empty/nonexistent plant
# properties
this_plant.migrate_properties()
# get status since last login
is_watered = this_plant.water_check()
is_dead = this_plant.dead_check()
if not is_dead:
if is_watered:
time_delta_last = int(time.time()) - this_plant.last_time
ticks_to_add = min(time_delta_last, 24 * 3600)
this_plant.time_delta_watered = 0
self.last_water_gain = time.time()
else:
ticks_to_add = 0
this_plant.ticks += ticks_to_add * (0.2 * (this_plant.generation - 1) + 1)
return this_plant
@staticmethod
def plant_age_convert(this_plant):
# human-readable plant age
age_seconds = int(time.time()) - this_plant.start_time
days, age_seconds = divmod(age_seconds, 24 * 60 * 60)
hours, age_seconds = divmod(age_seconds, 60 * 60)
minutes, age_seconds = divmod(age_seconds, 60)
age_formatted = ("%dd:%dh:%dm:%ds" % (days, hours, minutes, age_seconds))
return age_formatted
def init_database(self):
# check if dir exists, create sqlite directory and set OS permissions to 777
sqlite_dir_path = os.path.join(self.game_dir, 'sqlite')
if not os.path.exists(sqlite_dir_path):
os.makedirs(sqlite_dir_path, mode=0o777)
for schema in ['garden', 'visitors']:
with open(os.path.join(sqlite_dir_path, 'main', f'{schema}.sql')) as schema_file:
self.cursor.execute(schema_file.read())
self.conn.commit()
# init only, creates and sets permissions for garden db and json
if os.stat(self.garden_db_path).st_uid == os.getuid():
os.chmod(self.garden_db_path, 0o666)
open(self.garden_json_path, 'a').close()
os.chmod(self.garden_json_path, 0o666)
def update_garden_db(self, this_plant):
# insert or update this plant id's entry in DB
# TODO: make sure other instances of user are deleted
# Could create a clean db function
self.init_database()
age_formatted = self.plant_age_convert(this_plant)
# try to insert or replace
update_query = """INSERT OR REPLACE INTO garden (
plant_id, owner, description, age, score, is_dead
) VALUES (
:pid, :owner, :description, :page, :score, :dead
)"""
self.cursor.execute(update_query, {"pid": this_plant.plant_id,
"owner": this_plant.owner,
"description": this_plant.parse_plant(),
"page": age_formatted,
"score": str(this_plant.ticks),
"dead": int(this_plant.dead)})
self.conn.commit()
def retrieve_garden_from_db(self):
# Builds a dict of dicts from garden sqlite db
garden_dict = {}
# Need to allow write permissions by others
self.cursor.execute('SELECT * FROM garden ORDER BY owner')
tuple_list = self.cursor.fetchall()
# Building dict from table rows
for item in tuple_list:
garden_dict[item[0]] = {
"owner": item[1],
"description": item[2],
"age": item[3],
"score": item[4],
"dead": item[5],
}
return garden_dict
def update_garden_json(self):
with open(self.garden_json_path, 'w') as outfile:
json.dump(self.retrieve_garden_from_db(), outfile)
pass
def save_plant(self, this_plant):
# create savefile
this_plant.last_time = int(time.time())
temp_path = self.savefile_path + ".temp"
with open(temp_path, 'wb') as f:
pickle.dump(this_plant, f, protocol=2)
os.rename(temp_path, self.savefile_path)
def data_write_json(self, this_plant):
# create personal json file for user to use outside the game (website?)
json_file = os.path.join(self.botany_dir, self.this_user + '_plant_data.json')
# also updates age
age_formatted = self.plant_age_convert(this_plant)
plant_info = {
"owner": this_plant.owner,
"description": this_plant.parse_plant(),
"age": age_formatted,
"score": this_plant.ticks,
"is_dead": this_plant.dead,
"last_watered": this_plant.watered_timestamp,
"file_name": this_plant.file_name,
"stage": this_plant.stage_list[this_plant.stage],
"generation": this_plant.generation,
}
if this_plant.stage >= 3:
plant_info["rarity"] = this_plant.rarity_list[this_plant.rarity]
if this_plant.mutation != 0:
plant_info["mutation"] = consts.mutations[this_plant.mutation]
if this_plant.stage >= 4:
plant_info["color"] = this_plant.color_list[this_plant.color]
if this_plant.stage >= 2:
plant_info["species"] = this_plant.species_list[this_plant.species]
with open(json_file, 'w') as outfile:
json.dump(plant_info, outfile)
def harvest_plant(self, this_plant):
# TODO: plant history feature - could just use a sqlite query to retrieve all of user's dead plants
# harvest is a dict of dicts
# harvest contains one entry for each plant id
age_formatted = self.plant_age_convert(this_plant)
this_plant_id = this_plant.plant_id
plant_info = {
"description": this_plant.parse_plant(),
"age": age_formatted,
"score": this_plant.ticks,
}
if os.path.isfile(self.harvest_file_path):
# harvest file exists: load data
with open(self.harvest_file_path, 'rb') as f:
this_harvest = pickle.load(f)
new_file_check = False
else:
this_harvest = {}
new_file_check = True
this_harvest[this_plant_id] = plant_info
# dump harvest file
temp_path = self.harvest_file_path + ".temp"
with open(temp_path, 'wb') as f:
pickle.dump(this_harvest, f, protocol=2)
os.rename(temp_path, self.harvest_file_path)
# dump json file
with open(self.harvest_json_path, 'w') as outfile:
json.dump(this_harvest, outfile)
return new_file_check

View File

@ -1,860 +1,5 @@
import curses
import datetime
import getpass
import json
import math
import os
import random
import re
import sqlite3
import string
import threading
import time
import traceback
import completer
class CursedMenu(object):
# TODO: name your plant
"""A class which abstracts the horrors of building a curses-based menu system"""
def __init__(self, this_plant, this_data):
"""Initialization"""
self.initialized = False
self.screen = curses.initscr()
curses.noecho()
curses.raw()
if curses.has_colors():
curses.start_color()
try:
curses.curs_set(0)
except curses.error:
# Not all terminals support this functionality.
# When the error is ignored the screen will look a little uglier, but that's not terrible
# So in order to keep botany as accessible as possible to everyone, it should be safe to ignore the error.
pass
self.screen.keypad(1)
self.plant = this_plant
self.visited_plant = None
self.user_data = this_data
self.plant_string = self.plant.parse_plant()
self.plant_ticks = str(int(self.plant.ticks))
self.exit = False
self.infotoggle = 0
self.maxy, self.maxx = self.screen.getmaxyx()
# Highlighted and Normal line definitions
if curses.has_colors():
self.define_colors()
self.highlighted = curses.color_pair(1)
else:
self.highlighted = curses.A_REVERSE
self.normal = curses.A_NORMAL
# Threaded screen update for live changes
screen_thread = threading.Thread(target=self.update_plant_live, args=())
screen_thread.daemon = True
screen_thread.start()
# Recursive lock to prevent both threads from drawing at the same time
self.screen_lock = threading.RLock()
self.screen.clear()
self.show(["water", "look", "garden", "visit", "instructions"], title=' botany ', subtitle='options')
def define_colors(self):
# TODO: implement colors
# set curses color pairs manually
curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_WHITE)
curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_BLACK)
curses.init_pair(3, curses.COLOR_GREEN, curses.COLOR_BLACK)
curses.init_pair(4, curses.COLOR_BLUE, curses.COLOR_BLACK)
curses.init_pair(5, curses.COLOR_MAGENTA, curses.COLOR_BLACK)
curses.init_pair(6, curses.COLOR_YELLOW, curses.COLOR_BLACK)
curses.init_pair(7, curses.COLOR_RED, curses.COLOR_BLACK)
curses.init_pair(8, curses.COLOR_CYAN, curses.COLOR_BLACK)
def show(self, options, title, subtitle):
# Draws a menu with parameters
self.set_options(options)
self.update_options()
self.title = title
self.subtitle = subtitle
self.selected = 0
self.initialized = True
self.draw_menu()
def update_options(self):
# Makes sure you can get a new plant if it dies
if self.plant.dead or self.plant.stage == 5:
if "harvest" not in self.options:
self.options.insert(-1, "harvest")
else:
if "harvest" in self.options:
self.options.remove("harvest")
def set_options(self, options):
# Validates that the last option is "exit"
if options[-1] != 'exit':
options.append('exit')
self.options = options
def draw(self):
# Draw the menu and lines
self.maxy, self.maxx = self.screen.getmaxyx()
self.screen_lock.acquire()
self.screen.refresh()
try:
self.draw_default()
self.screen.refresh()
except Exception as exception:
# Makes sure data is saved in event of a crash due to window resizing
self.screen.clear()
self.screen.addstr(0, 0, "Enlarge terminal!", curses.A_NORMAL)
self.screen.refresh()
self.__exit__()
traceback.print_exc()
self.screen_lock.release()
def draw_menu(self):
# Actually draws the menu and handles branching
request = ""
try:
while request != "exit":
self.draw()
request = self.get_user_input()
self.handle_request(request)
self.__exit__()
# Also calls __exit__, but adds traceback after
except Exception as exception:
self.screen.clear()
self.screen.addstr(0, 0, "Enlarge terminal!", curses.A_NORMAL)
self.screen.refresh()
self.__exit__()
# traceback.print_exc()
except IOError as exception:
self.screen.clear()
self.screen.refresh()
self.__exit__()
def ascii_render(self, filename, ypos, xpos):
# Prints ASCII art from file at given coordinates
this_dir = os.path.join(os.path.dirname(os.path.realpath(__file__)), "art")
this_filename = os.path.join(this_dir, filename)
this_file = open(this_filename, "r")
this_string = this_file.readlines()
this_file.close()
self.screen_lock.acquire()
for y, line in enumerate(this_string, 2):
self.screen.addstr(ypos + y, xpos, line, curses.A_NORMAL)
# self.screen.refresh()
self.screen_lock.release()
def draw_plant_ascii(self, this_plant):
ypos = 0
xpos = int((self.maxx - 37) / 2 + 25)
plant_art_list = [
'poppy',
'cactus',
'aloe',
'flytrap',
'jadeplant',
'fern',
'daffodil',
'sunflower',
'baobab',
'lithops',
'hemp',
'pansy',
'iris',
'agave',
'ficus',
'moss',
'sage',
'snapdragon',
'columbine',
'brugmansia',
'palm',
'pachypodium',
]
if this_plant.dead == True:
self.ascii_render('rip.txt', ypos, xpos)
elif datetime.date.today().month == 10 and datetime.date.today().day == 31:
self.ascii_render('jackolantern.txt', ypos, xpos)
elif this_plant.stage == 0:
self.ascii_render('seed.txt', ypos, xpos)
elif this_plant.stage == 1:
self.ascii_render('seedling.txt', ypos, xpos)
elif this_plant.stage == 2:
this_filename = plant_art_list[this_plant.species] + '1.txt'
self.ascii_render(this_filename, ypos, xpos)
elif this_plant.stage == 3 or this_plant.stage == 5:
this_filename = plant_art_list[this_plant.species] + '2.txt'
self.ascii_render(this_filename, ypos, xpos)
elif this_plant.stage == 4:
this_filename = plant_art_list[this_plant.species] + '3.txt'
self.ascii_render(this_filename, ypos, xpos)
def draw_default(self):
# draws default menu
clear_bar = " " * (int(self.maxx * 2 / 3))
self.screen_lock.acquire()
self.screen.addstr(1, 2, self.title, curses.A_STANDOUT) # Title for this menu
self.screen.addstr(3, 2, self.subtitle, curses.A_BOLD) # Subtitle for this menu
# clear menu on screen
for index in range(len(self.options) + 1):
self.screen.addstr(4 + index, 4, clear_bar, curses.A_NORMAL)
# display all the menu items, showing the 'pos' item highlighted
for index in range(len(self.options)):
textstyle = self.normal
if index == self.selected:
textstyle = self.highlighted
self.screen.addstr(4 + index, 4, clear_bar, curses.A_NORMAL)
self.screen.addstr(4 + index, 4, "%d - %s" % (index + 1, self.options[index]), textstyle)
self.screen.addstr(12, 2, clear_bar, curses.A_NORMAL)
self.screen.addstr(13, 2, clear_bar, curses.A_NORMAL)
self.screen.addstr(12, 2, "plant: ", curses.A_DIM)
self.screen.addstr(12, 9, self.plant_string, curses.A_NORMAL)
self.screen.addstr(13, 2, "score: ", curses.A_DIM)
self.screen.addstr(13, 9, self.plant_ticks, curses.A_NORMAL)
# display fancy water gauge
if not self.plant.dead:
water_gauge_str = self.water_gauge()
self.screen.addstr(4, 14, water_gauge_str, curses.A_NORMAL)
else:
self.screen.addstr(4, 13, clear_bar, curses.A_NORMAL)
self.screen.addstr(4, 14, "( RIP )", curses.A_NORMAL)
# draw cute ascii from files
if self.visited_plant:
# Needed to prevent drawing over a visited plant
self.draw_plant_ascii(self.visited_plant)
else:
self.draw_plant_ascii(self.plant)
self.screen_lock.release()
def water_gauge(self):
# build nice looking water gauge
water_left_pct = 1 - ((time.time() - self.plant.watered_timestamp) / 86400)
# don't allow negative value
water_left_pct = max(0, water_left_pct)
water_left = int(math.ceil(water_left_pct * 10))
water_string = "(" + (")" * water_left) + ("." * (10 - water_left)) + ") " + str(
int(water_left_pct * 100)) + "% "
return water_string
def update_plant_live(self):
# updates plant data on menu screen, live!
while not self.exit:
self.plant_string = self.plant.parse_plant()
self.plant_ticks = str(int(self.plant.ticks))
if self.initialized:
self.update_options()
self.draw()
time.sleep(1)
def get_user_input(self):
# gets the user's input
try:
user_in = self.screen.getch() # Gets user input
except Exception as e:
self.__exit__()
if user_in == -1: # Input comes from pipe/file and is closed
raise IOError
## DEBUG KEYS - enable these lines to see curses key codes
# self.screen.addstr(2, 2, str(user_in), curses.A_NORMAL)
# self.screen.refresh()
# Resize sends curses.KEY_RESIZE, update display
if user_in == curses.KEY_RESIZE:
self.maxy, self.maxx = self.screen.getmaxyx()
self.screen.clear()
self.screen.refresh()
# enter, exit, and Q Keys are special cases
if user_in == 10:
return self.options[self.selected]
if user_in == 27:
return self.options[-1]
if user_in == 113:
self.selected = len(self.options) - 1
return
# this is a number; check to see if we can set it
if user_in >= ord('1') and user_in <= ord(str(min(7, len(self.options)))):
self.selected = user_in - ord('0') - 1 # convert keypress back to a number, then subtract 1 to get index
return
# increment or Decrement
down_keys = [curses.KEY_DOWN, 14, ord('j')]
up_keys = [curses.KEY_UP, 16, ord('k')]
if user_in in down_keys: # down arrow
self.selected += 1
if user_in in up_keys: # up arrow
self.selected -= 1
# modulo to wrap menu cursor
self.selected = self.selected % len(self.options)
return
def format_garden_data(self, this_garden):
# Returns list of lists (pages) of garden entries
plant_table = []
for plant_id in this_garden:
if this_garden[plant_id]:
if not this_garden[plant_id]["dead"]:
this_plant = this_garden[plant_id]
plant_table.append((this_plant["owner"],
this_plant["age"],
int(this_plant["score"]),
this_plant["description"]))
return plant_table
def format_garden_entry(self, entry):
return "{:14.14} - {:>16} - {:>8}p - {}".format(*entry)
def sort_garden_table(self, table, column, ascending):
""" Sort table in place by a specified column """
def key(entry):
entry = entry[column]
# In when sorting ages, convert to seconds
if column == 1:
coeffs = [24 * 60 * 60, 60 * 60, 60, 1]
nums = [int(n[:-1]) for n in entry.split(":")]
if len(nums) == len(coeffs):
entry = sum(nums[i] * coeffs[i] for i in range(len(nums)))
return entry
return table.sort(key=key, reverse=not ascending)
def filter_garden_table(self, table, pattern):
""" Filter table using a pattern, and return the new table """
def filterfunc(entry):
if len(pattern) == 0:
return True
entry_txt = self.format_garden_entry(entry)
try:
result = bool(re.search(pattern, entry_txt))
except Exception as e:
# In case of invalid regex, don't match anything
result = False
return result
return list(filter(filterfunc, table))
def draw_garden(self):
# draws community garden
# load data from sqlite db
this_garden = self.user_data.retrieve_garden_from_db()
# format data
self.clear_info_pane()
if self.infotoggle == 2:
# the screen IS currently showing the garden (1 page), make the
# text a bunch of blanks to clear it out
self.infotoggle = 0
return
# if infotoggle isn't 2, the screen currently displays other stuff
plant_table_orig = self.format_garden_data(this_garden)
self.infotoggle = 2
# print garden information OR clear it
index = 0
sort_column, sort_ascending = 0, True
sort_keys = ["n", "a", "s", "d"] # Name, Age, Score, Description
plant_table = plant_table_orig
self.sort_garden_table(plant_table, sort_column, sort_ascending)
while True:
entries_per_page = self.maxy - 16
index_max = min(len(plant_table), index + entries_per_page)
plants = plant_table[index:index_max]
page = [self.format_garden_entry(entry) for entry in plants]
self.screen_lock.acquire()
self.draw_info_text(page)
# Multiple pages, paginate and require keypress
page_text = "(%d-%d/%d) | sp/next | bksp/prev | s <col #>/sort | f/filter | q/quit" % (
index, index_max, len(plant_table))
self.screen.addstr(self.maxy - 2, 2, page_text)
self.screen.refresh()
self.screen_lock.release()
c = self.screen.getch()
if c == -1: # Input comes from pipe/file and is closed
raise IOError
self.infotoggle = 0
# Quit
if c == ord("q") or c == ord("x") or c == 27:
break
# Next page
elif c in [curses.KEY_ENTER, curses.KEY_NPAGE, ord(" "), ord("\n")]:
index += entries_per_page
if index >= len(plant_table):
break
# Previous page
elif c == curses.KEY_BACKSPACE or c == curses.KEY_PPAGE:
index = max(index - entries_per_page, 0)
# Next line
elif c == ord("j") or c == curses.KEY_DOWN:
index = max(min(index + 1, len(plant_table) - 1), 0)
# Previous line
elif c == ord("k") or c == curses.KEY_UP:
index = max(index - 1, 0)
# Sort entries
elif c == ord("s"):
c = self.screen.getch()
if c == -1: # Input comes from pipe/file and is closed
raise IOError
column = -1
if c < 255 and chr(c) in sort_keys:
column = sort_keys.index(chr(c))
elif ord("1") <= c <= ord("4"):
column = c - ord("1")
if column != -1:
if sort_column == column:
sort_ascending = not sort_ascending
else:
sort_column = column
sort_ascending = True
self.sort_garden_table(plant_table, sort_column, sort_ascending)
# Filter entries
elif c == ord("/") or c == ord("f"):
self.screen.addstr(self.maxy - 2, 2, "Filter: " + " " * (len(page_text) - 8))
pattern = self.get_user_string(10, self.maxy - 2, lambda x: x in string.printable)
plant_table = self.filter_garden_table(plant_table_orig, pattern)
self.sort_garden_table(plant_table, sort_column, sort_ascending)
index = 0
# Clear page before drawing next
self.clear_info_pane()
self.clear_info_pane()
def get_plant_description(self, this_plant):
output_text = ""
this_species = this_plant.species_list[this_plant.species]
this_color = this_plant.color_list[this_plant.color]
this_stage = this_plant.stage
stage_descriptions = {
0: [
"You're excited about your new seed.",
"You wonder what kind of plant your seed will grow into.",
"You're ready for a new start with this plant.",
"You're tired of waiting for your seed to grow.",
"You wish your seed could tell you what it needs.",
"You can feel the spirit inside your seed.",
"These pretzels are making you thirsty.",
"Way to plant, Ann!",
"'To see things in the seed, that is genius' - Lao Tzu",
],
1: [
"The seedling fills you with hope.",
"The seedling shakes in the wind.",
"You can make out a tiny leaf - or is that a thorn?",
"You can feel the seedling looking back at you.",
"You blow a kiss to your seedling.",
"You think about all the seedlings who came before it.",
"You and your seedling make a great team.",
"Your seedling grows slowly and quietly.",
"You meditate on the paths your plant's life could take.",
],
2: [
"The " + this_species + " makes you feel relaxed.",
"You sing a song to your " + this_species + ".",
"You quietly sit with your " + this_species + " for a few minutes.",
"Your " + this_species + " looks pretty good.",
"You play loud techno to your " + this_species + ".",
"You play piano to your " + this_species + ".",
"You play rap music to your " + this_species + ".",
"You whistle a tune to your " + this_species + ".",
"You read a poem to your " + this_species + ".",
"You tell a secret to your " + this_species + ".",
"You play your favorite record for your " + this_species + ".",
],
3: [
"Your " + this_species + " is growing nicely!",
"You're proud of the dedication it took to grow your " + this_species + ".",
"You take a deep breath with your " + this_species + ".",
"You think of all the words that rhyme with " + this_species + ".",
"The " + this_species + " looks full of life.",
"The " + this_species + " inspires you.",
"Your " + this_species + " makes you forget about your problems.",
"Your " + this_species + " gives you a reason to keep going.",
"Looking at your " + this_species + " helps you focus on what matters.",
"You think about how nice this " + this_species + " looks here.",
"The buds of your " + this_species + " might bloom soon.",
],
4: [
"The " + this_color + " flowers look nice on your " + this_species + "!",
"The " + this_color + " flowers have bloomed and fill you with positivity.",
"The " + this_color + " flowers remind you of your childhood.",
"The " + this_color + " flowers remind you of spring mornings.",
"The " + this_color + " flowers remind you of a forgotten memory.",
"The " + this_color + " flowers remind you of your happy place.",
"The aroma of the " + this_color + " flowers energize you.",
"The " + this_species + " has grown beautiful " + this_color + " flowers.",
"The " + this_color + " petals remind you of that favorite shirt you lost.",
"The " + this_color + " flowers remind you of your crush.",
"You smell the " + this_color + " flowers and are filled with peace.",
],
5: [
"You fondly remember the time you spent caring for your " + this_species + ".",
"Seed pods have grown on your " + this_species + ".",
"You feel like your " + this_species + " appreciates your care.",
"The " + this_species + " fills you with love.",
"You're ready for whatever comes after your " + this_species + ".",
"You're excited to start growing your next plant.",
"You reflect on when your " + this_species + " was just a seedling.",
"You grow nostalgic about the early days with your " + this_species + ".",
],
99: [
"You wish you had taken better care of your plant.",
"If only you had watered your plant more often..",
"Your plant is dead, there's always next time.",
"You cry over the withered leaves of your plant.",
"Your plant died. Maybe you need a fresh start.",
],
}
# self.life_stages is tuple containing length of each stage
# (seed, seedling, young, mature, flowering)
if this_plant.dead:
this_stage = 99
this_stage_descriptions = stage_descriptions[this_stage]
description_num = random.randint(0, len(this_stage_descriptions) - 1)
# If not fully grown
if this_stage <= 4:
# Growth hint
if this_stage >= 1:
last_growth_at = this_plant.life_stages[this_stage - 1]
else:
last_growth_at = 0
ticks_since_last = this_plant.ticks - last_growth_at
ticks_between_stage = this_plant.life_stages[this_stage] - last_growth_at
if ticks_since_last >= ticks_between_stage * 0.8:
output_text += "You notice your plant looks different.\n"
output_text += this_stage_descriptions[description_num] + "\n"
# if seedling
if this_stage == 1:
species_options = [this_plant.species_list[this_plant.species],
this_plant.species_list[(this_plant.species + 3) % len(this_plant.species_list)],
this_plant.species_list[(this_plant.species - 3) % len(this_plant.species_list)]]
random.shuffle(species_options)
plant_hint = "It could be a(n) " + species_options[0] + ", " + species_options[1] + ", or " + \
species_options[2]
output_text += plant_hint + ".\n"
# if young plant
if this_stage == 2:
if this_plant.rarity >= 2:
rarity_hint = "You feel like your plant is special."
output_text += rarity_hint + ".\n"
# if mature plant
if this_stage == 3:
color_options = [this_plant.color_list[this_plant.color],
this_plant.color_list[(this_plant.color + 3) % len(this_plant.color_list)],
this_plant.color_list[(this_plant.color - 3) % len(this_plant.color_list)]]
random.shuffle(color_options)
plant_hint = "You can see the first hints of " + color_options[0] + ", " + color_options[1] + ", or " + \
color_options[2]
output_text += plant_hint + ".\n"
return output_text
def draw_plant_description(self, this_plant):
# If menu is currently showing something other than the description
self.clear_info_pane()
if self.infotoggle != 1:
# get plant description before printing
output_string = self.get_plant_description(this_plant)
growth_multiplier = 1 + (0.2 * (this_plant.generation - 1))
output_string += "Generation: {}\nGrowth rate: {}x".format(self.plant.generation, growth_multiplier)
self.draw_info_text(output_string)
self.infotoggle = 1
else:
# otherwise just set toggle
self.infotoggle = 0
def draw_instructions(self):
# Draw instructions on screen
self.clear_info_pane()
if self.infotoggle != 4:
instructions_txt = ("welcome to botany. you've been given a seed\n"
"that will grow into a beautiful plant. check\n"
"in and water your plant every 24h to keep it\n"
"growing. 5 days without water = death. your\n"
"plant depends on you & your friends to live!\n"
"more info is available in the readme :)\n"
"https://github.com/jifunks/botany/blob/master/README.md\n"
" cheers,\n"
" curio\n"
)
self.draw_info_text(instructions_txt)
self.infotoggle = 4
else:
self.infotoggle = 0
def clear_info_pane(self):
# Clears bottom part of screen
self.screen_lock.acquire()
clear_bar = " " * (self.maxx - 3)
this_y = 14
while this_y < self.maxy:
self.screen.addstr(this_y, 2, clear_bar, curses.A_NORMAL)
this_y += 1
self.screen.refresh()
self.screen_lock.release()
def draw_info_text(self, info_text, y_offset=0):
# print lines of text to info pane at bottom of screen
self.screen_lock.acquire()
if type(info_text) is str:
info_text = info_text.splitlines()
for y, line in enumerate(info_text, 2):
this_y = y + 12 + y_offset
if len(line) > self.maxx - 3:
line = line[:self.maxx - 3]
if this_y < self.maxy:
self.screen.addstr(this_y, 2, line, curses.A_NORMAL)
self.screen.refresh()
self.screen_lock.release()
def harvest_confirmation(self):
self.clear_info_pane()
# get plant description before printing
max_stage = len(self.plant.stage_list) - 1
harvest_text = ""
if not self.plant.dead:
if self.plant.stage == max_stage:
harvest_text += "Congratulations! You raised your plant to its final stage of growth.\n"
harvest_text += "Your next plant will grow at a speed of: {}x\n".format(
1 + (0.2 * self.plant.generation))
harvest_text += "If you harvest your plant you'll start over from a seed.\nContinue? (Y/n)"
self.draw_info_text(harvest_text)
try:
user_in = self.screen.getch() # Gets user input
except Exception as e:
self.__exit__()
if user_in == -1: # Input comes from pipe/file and is closed
raise IOError
if user_in in [ord('Y'), ord('y')]:
self.plant.start_over()
else:
pass
self.clear_info_pane()
def build_weekly_visitor_output(self, visitors):
visitor_block = ""
visitor_line = ""
for visitor in visitors:
this_visitor_string = str(visitor) + "({}) ".format(visitors[str(visitor)])
if len(visitor_line + this_visitor_string) > self.maxx - 3:
visitor_block += '\n'
visitor_line = ""
visitor_block += this_visitor_string
visitor_line += this_visitor_string
return visitor_block
def build_latest_visitor_output(self, visitors):
visitor_line = ""
for visitor in visitors:
if len(visitor_line + visitor) > self.maxx - 10:
visitor_line += "and more"
break
visitor_line += visitor + ' '
return [visitor_line]
def get_weekly_visitors(self):
game_dir = os.path.dirname(os.path.realpath(__file__))
garden_db_path = os.path.join(game_dir, 'sqlite/garden_db.sqlite')
conn = sqlite3.connect(garden_db_path)
c = conn.cursor()
c.execute("SELECT * FROM visitors WHERE garden_name = ? ORDER BY weekly_visits", self.plant.owner)
visitor_data = c.fetchall()
conn.close()
visitor_block = ""
visitor_line = ""
if visitor_data:
for visitor in visitor_data:
visitor_name = visitor[2]
weekly_visits = visitor[3]
this_visitor_string = "{}({}) ".format(visitor_name, weekly_visits)
if len(visitor_line + this_visitor_string) > self.maxx - 3:
visitor_block += '\n'
visitor_line = ""
visitor_block += this_visitor_string
visitor_line += this_visitor_string
else:
visitor_block = 'nobody :('
return visitor_block
def get_user_string(self, xpos=3, ypos=15, filterfunc=str.isalnum, completer=None):
# filter allowed characters using filterfunc, alphanumeric by default
user_string = ""
user_input = 0
if completer:
completer = completer(self)
while user_input != 10:
user_input = self.screen.getch()
if user_input == -1: # Input comes from pipe/file and is closed
raise IOError
self.screen_lock.acquire()
# osx and unix backspace chars...
if user_input == 127 or user_input == 263:
if len(user_string) > 0:
user_string = user_string[:-1]
if completer:
completer.update_input(user_string)
self.screen.addstr(ypos, xpos, " " * (self.maxx - xpos - 1))
elif user_input in [ord('\t'), curses.KEY_BTAB] and completer:
direction = 1 if user_input == ord('\t') else -1
user_string = completer.complete(direction)
self.screen.addstr(ypos, xpos, " " * (self.maxx - xpos - 1))
elif user_input < 256 and user_input != 10:
if filterfunc(chr(user_input)) or chr(user_input) == '_':
user_string += chr(user_input)
if completer:
completer.update_input(user_string)
self.screen.addstr(ypos, xpos, str(user_string))
self.screen.refresh()
self.screen_lock.release()
return user_string
def visit_handler(self):
self.clear_info_pane()
self.draw_info_text("whose plant would you like to visit?")
self.screen.addstr(15, 2, '~')
if self.plant.visitors:
latest_visitor_string = self.build_latest_visitor_output(self.plant.visitors)
self.draw_info_text("since last time, you were visited by: ", 3)
self.draw_info_text(latest_visitor_string, 4)
self.plant.visitors = []
weekly_visitor_text = self.get_weekly_visitors()
self.draw_info_text("this week you've been visited by: ", 6)
self.draw_info_text(weekly_visitor_text, 7)
guest_garden = self.get_user_string(completer=completer.LoginCompleter)
if not guest_garden:
self.clear_info_pane()
return None
if guest_garden.lower() == getpass.getuser().lower():
self.screen.addstr(16, 2, "you're already here!")
self.screen.getch()
self.clear_info_pane()
return None
home_folder = os.path.dirname(os.path.expanduser("~"))
guest_json = home_folder + "/{}/.botany/{}_plant_data.json".format(guest_garden, guest_garden)
guest_plant_description = ""
if os.path.isfile(guest_json):
with open(guest_json) as f:
visitor_data = json.load(f)
guest_plant_description = visitor_data['description']
self.visited_plant = self.get_visited_plant(visitor_data)
guest_visitor_file = home_folder + "/{}/.botany/visitors.json".format(guest_garden, guest_garden)
if os.path.isfile(guest_visitor_file):
water_success = self.water_on_visit(guest_visitor_file)
if water_success:
self.screen.addstr(16, 2,
"...you watered ~{}'s {}...".format(str(guest_garden), guest_plant_description))
if self.visited_plant:
self.draw_plant_ascii(self.visited_plant)
else:
self.screen.addstr(16, 2, "{}'s garden is locked, but you can see in...".format(guest_garden))
else:
self.screen.addstr(16, 2, "i can't seem to find directions to {}...".format(guest_garden))
try:
self.screen.getch()
self.clear_info_pane()
self.draw_plant_ascii(self.plant)
finally:
self.visited_plant = None
def water_on_visit(self, guest_visitor_file):
visitor_data = {}
# using -1 here so that old running instances can be watered
guest_data = {'user': getpass.getuser(), 'timestamp': int(time.time()) - 1}
if os.path.isfile(guest_visitor_file):
if not os.access(guest_visitor_file, os.W_OK):
return False
with open(guest_visitor_file) as f:
visitor_data = json.load(f)
visitor_data.append(guest_data)
with open(guest_visitor_file, mode='w') as f:
f.write(json.dumps(visitor_data, indent=2))
return True
def get_visited_plant(self, visitor_data):
""" Returns a drawable pseudo plant object from json data """
class VisitedPlant:
pass
plant = VisitedPlant()
plant.stage = 0
plant.species = 0
if "is_dead" not in visitor_data:
return None
plant.dead = visitor_data["is_dead"]
if plant.dead:
return plant
if "stage" in visitor_data:
stage = visitor_data["stage"]
if stage in self.plant.stage_list:
plant.stage = self.plant.stage_list.index(stage)
if "species" in visitor_data:
species = visitor_data["species"]
if species in self.plant.species_list:
plant.species = self.plant.species_list.index(species)
else:
return None
elif plant.stage > 1:
return None
return plant
def handle_request(self, request):
# Menu options call functions here
if request == None: return
if request == "harvest":
self.harvest_confirmation()
if request == "water":
self.plant.water()
if request == "look":
try:
self.draw_plant_description(self.plant)
except Exception as exception:
self.screen.refresh()
# traceback.print_exc()
if request == "instructions":
try:
self.draw_instructions()
except Exception as exception:
self.screen.refresh()
# traceback.print_exc()
if request == "visit":
try:
self.visit_handler()
except Exception as exception:
self.screen.refresh()
# traceback.print_exc()
if request == "garden":
try:
self.draw_garden()
except Exception as exception:
self.screen.refresh()
# traceback.print_exc()
def __exit__(self):
self.exit = True
cleanup()
def cleanup():

249
plant.py Normal file
View File

@ -0,0 +1,249 @@
import getpass
import json
import os
import random
import sqlite3
import threading
import time
import uuid
import consts
class Plant(object):
# This is your plant!
def __init__(self, this_filename, generation=1):
# Constructor
self.time_delta_watered = 0
self.plant_id = str(uuid.uuid4())
self.life_stages = (3600 * 24, (3600 * 24) * 3, (3600 * 24) * 10, (3600 * 24) * 20, (3600 * 24) * 30)
# self.life_stages = (2, 4, 6, 8, 10) # debug mode
self.stage = 0
self.mutation = 0
self.species = random.randint(0, len(consts.species) - 1)
self.color = random.randint(0, len(consts.colors) - 1)
self.rarity = self.rarity_check()
self.ticks = 0
self.age_formatted = "0"
self.generation = generation
self.dead = False
self.write_lock = False
self.owner = getpass.getuser()
self.file_name = this_filename
self.start_time = int(time.time())
self.last_time = int(time.time())
# must water plant first day
self.watered_timestamp = int(time.time()) - (24 * 3600) - 1
self.watered_24h = False
self.visitors = []
def migrate_properties(self):
# Migrates old data files to new
if not hasattr(self, 'generation'):
self.generation = 1
if not hasattr(self, 'visitors'):
self.visitors = []
def parse_plant(self):
# Converts plant data to human-readable format
output = ""
if self.stage >= 3:
output += consts.rarities[self.rarity] + " "
if self.mutation != 0:
output += consts.mutations[self.mutation] + " "
if self.stage >= 4:
output += consts.colors[self.color] + " "
output += consts.stages[self.stage] + " "
if self.stage >= 2:
output += consts.species[self.species] + " "
return output.strip()
@staticmethod
def rarity_check():
# Generate plant rarity
rare_seed = random.randint(1, int(consts.RARITY_MAX))
common_range = round((2.0 / 3) * consts.RARITY_MAX)
uncommon_range = round((2.0 / 3) * (consts.RARITY_MAX - common_range))
rare_range = round((2.0 / 3) * (consts.RARITY_MAX - common_range - uncommon_range))
legendary_range = round((2.0 / 3) * (consts.RARITY_MAX - common_range - uncommon_range - rare_range))
common_max = common_range
uncommon_max = common_max + uncommon_range
rare_max = uncommon_max + rare_range
legendary_max = rare_max + legendary_range
godly_max = consts.RARITY_MAX
rarity = 0
if 0 <= rare_seed <= common_max:
rarity = 0
elif common_max < rare_seed <= uncommon_max:
rarity = 1
elif uncommon_max < rare_seed <= rare_max:
rarity = 2
elif rare_max < rare_seed <= legendary_max:
rarity = 3
elif legendary_max < rare_seed <= godly_max:
rarity = 4
return rarity
def dead_check(self):
# if it has been >5 days since watering, sorry plant is dead :(
time_delta_watered = int(time.time()) - self.watered_timestamp
if time_delta_watered > (5 * (24 * 3600)):
self.dead = True
return self.dead
def update_visitor_db(self, visitor_names):
game_dir = os.path.dirname(os.path.realpath(__file__))
garden_db_path = os.path.join(game_dir, 'sqlite/garden_db.sqlite')
conn = sqlite3.connect(garden_db_path)
for name in visitor_names:
c = conn.cursor()
c.execute(
"SELECT * FROM visitors WHERE garden_name = ? AND visitor_name = ?", (self.owner, name))
data = c.fetchone()
if data is None:
c.execute(" INSERT INTO visitors (garden_name,visitor_name,weekly_visits) VALUES(?, ?, 1)",
(self.owner, name))
else:
c.execute(
"UPDATE visitors SET weekly_visits = weekly_visits + 1 WHERE garden_name = ? AND visitor_name = ?",
(self.owner, name))
conn.commit()
conn.close()
def guest_check(self):
user_dir = os.path.expanduser("~")
botany_dir = os.path.join(user_dir, '.botany')
visitor_filepath = os.path.join(botany_dir, 'visitors.json')
guest_timestamps = []
visitors_this_check = []
if os.path.isfile(visitor_filepath):
with open(visitor_filepath, 'r') as visit:
data = json.load(visit)
if data:
for element in data:
if element['user'] not in self.visitors:
self.visitors.append(element['user'])
if element['user'] not in visitors_this_check:
visitors_this_check.append(element['user'])
# prevent users from manually setting watered_time in the future
if int(time.time()) >= element['timestamp'] >= self.watered_timestamp:
guest_timestamps.append(element['timestamp'])
try:
self.update_visitor_db(visitors_this_check)
except Exception:
pass
with open(visitor_filepath, 'w') as visitor:
visitor.write('[]')
else:
with open(visitor_filepath, 'w') as f:
json.dump([], f)
os.chmod(visitor_filepath, 0o666)
if not guest_timestamps:
return self.watered_timestamp
all_timestamps = [self.watered_timestamp] + guest_timestamps
all_timestamps.sort()
# calculate # of days between each guest watering
timestamp_diffs = [(j - i) / 86400.0 for i, j in zip(all_timestamps[:-1], all_timestamps[1:])]
# plant's latest timestamp should be set to last timestamp before a
# gap of 5 days
# TODO: this considers a plant watered only on day 1 and day 4 to be
# watered for all 4 days - need to figure out how to only add score
# from 24h after each watered timestamp
last_valid_element = next((x for x in timestamp_diffs if x > 5), None)
if not last_valid_element:
# all timestamps are within a 5-day range, can just use the latest one
return all_timestamps[-1]
last_valid_index = timestamp_diffs.index(last_valid_element)
# slice list to only include up until a >5 day gap
valid_timestamps = all_timestamps[:last_valid_index + 1]
return valid_timestamps[-1]
def water_check(self):
self.watered_timestamp = self.guest_check()
self.time_delta_watered = int(time.time()) - self.watered_timestamp
if self.time_delta_watered <= (24 * 3600):
if not self.watered_24h:
self.watered_24h = True
return True
else:
self.watered_24h = False
return False
def mutate_check(self):
# Create plant mutation
# Increase this # to make mutation rarer (chance 1 out of x each second)
mutation_seed = random.randint(1, consts.MUTATION_RARITY)
if mutation_seed == consts.MUTATION_RARITY:
# mutation gained!
mutation = random.randint(0, len(consts.mutations) - 1)
if self.mutation == 0:
self.mutation = mutation
return True
else:
return False
def growth(self):
# Increase plant growth stage
if self.stage < (len(consts.stages) - 1):
self.stage += 1
def water(self):
# Increase plant growth stage
if not self.dead:
self.watered_timestamp = int(time.time())
self.watered_24h = True
def start_over(self):
# After plant reaches final stage, given option to restart
# increment generation only if previous stage is final stage and plant
# is alive
if not self.dead:
next_generation = self.generation + 1
else:
# Should this reset to 1? Seems unfair… for now generations will
# persist through death.
next_generation = self.generation
self.write_lock = True
self.kill_plant()
while self.write_lock:
# Wait for garden writer to unlock
# garden db needs to update before allowing the user to reset
pass
if not self.write_lock:
self.__init__(self.file_name, next_generation)
def kill_plant(self):
self.dead = True
def unlock_new_creation(self):
self.write_lock = False
def start_life(self):
# runs life on a thread
thread = threading.Thread(target=self.life, args=())
thread.daemon = True
thread.start()
def life(self):
# I've created life :)
while True:
if not self.dead:
if self.watered_24h:
self.ticks += 1
if self.stage < len(consts.stages) - 1:
if self.ticks >= self.life_stages[self.stage]:
self.growth()
if self.mutate_check():
pass
if self.water_check():
# Do something
pass
if self.dead_check():
# Do something else
pass
# TODO: event check
generation_bonus = 0.2 * (self.generation - 1)
adjusted_sleep_time = 1 / (1 + generation_bonus)
time.sleep(adjusted_sleep_time)

View File

@ -1,4 +1,4 @@
create table garden
create table if not exists garden
(
plant_id tinytext
primary key,

View File

@ -1,4 +1,4 @@
create table visitors
create table if not exists visitors
(
id integer
primary key,

View File

@ -1,84 +0,0 @@
import sqlite3
garden_db_path = "sqlite/garden_db.sqlite"
def init_database():
# TODO: does this need permissions?
conn = sqlite3.connect(garden_db_path)
init_table_string = """CREATE TABLE IF NOT EXISTS garden (
plant_id tinytext PRIMARY KEY,
owner text,
description text,
age text,
score integer,
is_dead text
)"""
c = conn.cursor()
c.execute(init_table_string)
conn.close()
def update_garden_db():
# insert or update this plant id's entry in DB (should happen
# regularly)
# TODO: create a second function that is called to retrieve garden
# when called by display controller
conn = sqlite3.connect(garden_db_path)
c = conn.cursor()
##try to insert or replace
update_query = """INSERT OR REPLACE INTO garden (
plant_id, owner, description, age, score, is_dead
) VALUES (
'{pid}', '{pown}', '{pdes}', '{page}', {psco}, '{pdead}'
)
""".format(pid="asdfaseeeedf", pown="jaeeke", pdes="bigger ceeooler plant", page="28dee",
psco=str(244400), pdead=str(False))
# update_query = """INSERT INTO garden (
# plant_id, owner, description, age, score, is_dead
# ) VALUES (
# '{pid}', '{pown}', '{pdes}', '{page}', {psco}, '{pdead}'
# )
# """.format(pid = "asdfasdf", pown = "jake", pdes = "big cool plant", page="25d", psco = str(25), pdead = str(False))
print(c.execute(update_query))
conn.commit()
conn.close()
# print("bigggg booom")
def retrieve_garden_from_db(garden_db_path):
# Builds a dict of dicts from garden sqlite db
garden_dict = {}
conn = sqlite3.connect(garden_db_path)
conn.row_factory = sqlite3.Row
c = conn.cursor()
c.execute('SELECT * FROM garden ORDER BY owner')
tuple_list = c.fetchall()
conn.close()
# Building dict from table rows
for item in tuple_list:
garden_dict[item[0]] = {
"owner": item[1],
"description": item[2],
"age": item[3],
"score": item[4],
"dead": item[5],
}
return garden_dict
# init_database()
# update_garden_db()
results = retrieve_garden_from_db(garden_db_path)
print(results)
# con = sqlite3.connect(garden_db_path) #
# con.row_factory = sqlite3.Row #
# cur = con.cursor() #
# cur.execute("select * from garden ORDER BY score desc") #
# blah = cur.fetchall() #
# con.close()
# print(blah)