Compare commits

..

1 Commits

Author SHA1 Message Date
Jake Funke b0876fb7e5 Tweaked text for menu screen display 2018-06-06 17:45:36 +00:00
10 changed files with 436 additions and 675 deletions

1
.gitignore vendored
View File

@ -3,4 +3,3 @@ garden_db.sqlite
garden_file.dat
garden_file.json
sqlite/
*.swp

View File

@ -1,21 +1,22 @@
# botany
![Screencap](https://tilde.town/~curiouser/botany.png)
[![Screencap](https://asciinema.org/a/bbsqp00meurz3v5ltzc2jywif.png)](https://asciinema.org/a/bbsqp00meurz3v5ltzc2jywif)
by Jake Funke - jifunks@gmail.com - tilde.town/~curiouser - http://jakefunke.online/
A command line, realtime, community plant buddy.
You've been given a seed that will grow into a beautiful plant.
Check in and water your plant every 24h to keep it growing. 5 days without water = death. Your plant depends on you and your friends to live!
Check in and water your plant every 24h to keep it growing. 5 days without water = death. Your plant depends on you to live!
*"We do not 'come into' this world; we come out of it, as leaves from a tree." - Alan Watts*
## getting started
botany is designed for unix-based systems. Clone into a local directory using `$ git clone https://github.com/jifunks/botany.git`.
Run with `$ python3 botany.py`.
Run with `$ python botany.py`.
*Note - botany.py must initially be run by the user who cloned/unzipped botany.py - this initalizes the shared data file permissions.*
*Note - botany.py must initially be run by the user who cloned/unzipped
botany.py - this initalizes the shared data file permissions.*
Water your seed to get started. You can come and go as you please and your plant continues to grow.
@ -23,8 +24,6 @@ Make sure to come back and water every 24 hours or your plant won't grow.
If your plant goes 5 days without water, it will die! Recruit your friends to water your plant for you!
A once-weekly cron on clear_weekly_users.py should be set up to keep weekly visitors tidy.
## features
* Curses-based menu system, optimized for 80x24 terminal
@ -40,18 +39,14 @@ A once-weekly cron on clear_weekly_users.py should be set up to keep weekly visi
```
{
"description":"common screaming mature jade plant",
"generation":1,
"file_name":"/home/curiouser/.botany/curiouser_plant.dat",
"owner":"curiouser",
"species":"jade plant",
"stage":"mature",
"age":"24d:2h:16m:19s",
"rarity":"common",
"score":955337.0,
"mutation":"screaming",
"last_watered":1529007007,
"is_dead":false
"description": "common singing blue seed-bearing poppy",
"file_name": "/Users/jakefunke/.botany/jakefunke_plant.dat",
"age": "0d:2h:3m:16s",
"score": 1730,
"owner": "jakefunke",
"is_dead": false,
"last_watered": 1489113197,
"generation": 2
}
```
@ -64,14 +59,8 @@ A once-weekly cron on clear_weekly_users.py should be set up to keep weekly visi
## requirements
* Unix-based OS (Mac, Linux)
* Python 3.x
* Python 2.x
* Recommended: 80x24 minimum terminal, fixed-width font
## credits
* thank you [tilde.town](http://tilde.town/) for inspiration!
## praise for botany
![Screencap](https://tilde.town/~curiouser/praise1.png)
![Screencap](https://tilde.town/~curiouser/praise2.png)
![Screencap](https://tilde.town/~curiouser/praise3.png)
* thank you tilde.town for inspiration!

View File

@ -1,10 +0,0 @@
/)) HAPPY
__(((__ HALLOWEEN
.' _`""`_`'.
/ /\\ /\\ \
| /)_\\/)_\\ |
| _ _()_ _ |
| \\/\\/\\// |
\ \/\/\/\/ /
. , .'.___..___.' _ ., _ .
^ ' ` '

View File

@ -1,67 +0,0 @@
#!/usr/bin/env python3
from botany import *
def ascii_render(filename):
# 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.read()
this_file.close()
print(this_string)
def draw_plant_ascii(this_plant):
# this list should be somewhere where it could have been inherited, instead
# of hardcoded in more than one place...
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:
ascii_render('rip.txt')
elif datetime.date.today().month == 10 and datetime.date.today().day == 31:
ascii_render('jackolantern.txt')
elif this_plant.stage == 0:
ascii_render('seed.txt')
elif this_plant.stage == 1:
ascii_render('seedling.txt')
elif this_plant.stage == 2:
this_filename = plant_art_list[this_plant.species]+'1.txt'
ascii_render(this_filename)
elif this_plant.stage == 3 or this_plant.stage == 5:
this_filename = plant_art_list[this_plant.species]+'2.txt'
ascii_render(this_filename)
elif this_plant.stage == 4:
this_filename = plant_art_list[this_plant.species]+'3.txt'
ascii_render(this_filename)
if __name__ == '__main__':
my_data = DataManager()
# if plant save file exists
if my_data.check_plant():
my_plant = my_data.load_plant()
# otherwise create new plant
else:
my_plant = Plant(my_data.savefile_path)
my_data.data_write_json(my_plant)
draw_plant_ascii(my_plant)

432
botany.py
View File

@ -1,50 +1,353 @@
#!/usr/bin/env python3
#!/usr/bin/python2
from __future__ import division
import time
import pickle
import json
import os
import random
import getpass
import threading
import errno
import uuid
import sqlite3
import menu_screen as ms
from plant import Plant
from menu_screen import *
# TODO:
# - switch from personal data file to row in DB
# - is threading necessary?
# - use a different curses window for plant, menu, info window, score
# - Switch from personal data file to table in DB
# notes from vilmibm
class Plant(object):
# This is your plant!
stage_list = [
'seed',
'seedling',
'young',
'mature',
'flowering',
'seed-bearing',
]
# there are threads.
# - life thread. sleeps a variable amount of time based on generation bonus. increases tick count (ticks == score).
# - screen: sleeps 1s per loop. draws interface (including plant). for seeing score/plant change without user input.
# meanwhile, the main thread handles input and redraws curses as needed.
color_list = [
'red',
'orange',
'yellow',
'green',
'blue',
'indigo',
'violet',
'white',
'black',
'gold',
'rainbow',
]
# affordance index
# - main screen
# navigable menu, plant, score, etc
# - water
# render a visualization of moistness; allow to water
# - look
# print a description of plant with info below rest of UI
# - garden
# runs a paginated view of every plant on the computer below rest of UI. to return to menu navigation must hit q.
# - visit
# runs a prompt underneath UI where you can see who recently visited you and type in a name to visit. must submit the prompt to get back to menu navigation.
# - instructions
# prints some explanatory text below the UI
# - exit
# quits program
rarity_list = [
'common',
'uncommon',
'rare',
'legendary',
'godly',
]
# part of the complexity of all this is everything takes place in one curses window; thus, updates must be manually synchronized across the various logical parts of the screen.
# ideally, multiple windows would be used:
# - the menu. it doesn't change unless the plant dies OR the plant hits stage 5, then "harvest" is dynamically added.
# - the plant viewer. this is updated in "real time" as the plant grows.
# - the status display: score and plant description
# - the infow window. updated by visit/garden/instructions/look
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',
]
def __init__(self, this_filename, generation=1):
# Constructor
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,CONST_RARITY_MAX)
common_range = round((2/3)*CONST_RARITY_MAX)
uncommon_range = round((2/3)*(CONST_RARITY_MAX-common_range))
rare_range = round((2/3)*(CONST_RARITY_MAX-common_range-uncommon_range))
legendary_range = round((2/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
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 = '{}' ".format(self.owner, name))
data=c.fetchone()
if data is None:
sql = """ INSERT INTO visitors (garden_name,visitor_name,weekly_visits) VALUES('{}', '{}',1)""".format(self.owner, name)
c.execute(sql)
else:
sql = """ UPDATE visitors SET weekly_visits = weekly_visits + 1 WHERE garden_name = '{}' AND visitor_name = '{}'""".format(self.owner, name)
c.execute(sql)
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')
latest_timestamp = 0
visitors_this_check = []
if os.path.isfile(visitor_filepath):
with open(visitor_filepath, 'r') as visitor_file:
data = json.load(visitor_file)
# TODO: this needs to check if the latest timestamp is greater
# than 5 days
# need to check for delta of at minimum 5 days between waters
# to make sure plant is alive
# check each visitor time, calculate delta between this and last
# watering
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 element['timestamp'] < int(time.time()):
# need to check here for delta between this
# element and last element (also json load might
# not be sorted...
if element['timestamp'] > latest_timestamp:
latest_timestamp = element['timestamp']
try:
self.update_visitor_db(visitors_this_check)
except:
pass
with open(visitor_filepath, 'w') as visitor_file:
visitor_file.write('[]')
else:
with open(visitor_filepath, mode='w') as f:
json.dump([], f)
os.chmod(visitor_filepath, 0666)
return latest_timestamp
def water_check(self):
latest_visitor_timestamp = self.guest_check()
if latest_visitor_timestamp > self.watered_timestamp:
visitor_delta_watered = latest_visitor_timestamp - self.watered_timestamp
if visitor_delta_watered <= (5 * (24 * 3600)):
self.watered_timestamp = latest_visitor_timestamp
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)
@ -81,11 +384,47 @@ class DataManager(object):
def check_plant(self):
# check for existing save file
if os.path.isfile(self.savefile_path) and os.path.getsize(self.savefile_path) > 0:
if os.path.isfile(self.savefile_path):
return True
else:
return False
def start_threads(self,this_plant):
# creates 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:
@ -96,8 +435,8 @@ class DataManager(object):
this_plant.migrate_properties()
# get status since last login
is_watered = this_plant.water_check()
is_dead = this_plant.dead_check()
is_watered = this_plant.water_check()
if not is_dead:
if is_watered:
@ -107,7 +446,7 @@ class DataManager(object):
self.last_water_gain = time.time()
else:
ticks_to_add = 0
this_plant.ticks += ticks_to_add * round(0.2 * (this_plant.generation - 1) + 1, 1)
this_plant.ticks += ticks_to_add * (0.2 * (this_plant.generation - 1) + 1)
return this_plant
def plant_age_convert(self,this_plant):
@ -124,7 +463,7 @@ class DataManager(object):
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)
os.chmod(sqlite_dir_path, 0777)
conn = sqlite3.connect(self.garden_db_path)
init_table_string = """CREATE TABLE IF NOT EXISTS garden (
plant_id tinytext PRIMARY KEY,
@ -141,9 +480,9 @@ class DataManager(object):
# 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)
os.chmod(self.garden_db_path, 0666)
open(self.garden_json_path, 'a').close()
os.chmod(self.garden_json_path, 0o666)
os.chmod(self.garden_json_path, 0666)
def migrate_database(self):
conn = sqlite3.connect(self.garden_db_path)
@ -160,6 +499,8 @@ class DataManager(object):
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)
@ -178,13 +519,6 @@ class DataManager(object):
psco = str(this_plant.ticks),
pdead = int(this_plant.dead))
c.execute(update_query)
# clean other instances of user
clean_query = """UPDATE garden set is_dead = 1
where owner = '{pown}'
and plant_id <> '{pid}'
""".format(pown = this_plant.owner,
pid = this_plant.plant_id)
c.execute(clean_query)
conn.commit()
conn.close()
@ -295,12 +629,12 @@ if __name__ == '__main__':
my_plant = Plant(my_data.savefile_path)
my_data.data_write_json(my_plant)
# my_plant is either a fresh plant or an existing plant at this point
my_plant.start_life(my_data)
my_plant.start_life()
my_data.start_threads(my_plant)
try:
botany_menu = ms.CursedMenu(my_plant,my_data)
botany_menu = CursedMenu(my_plant,my_data)
my_data.save_plant(my_plant)
my_data.data_write_json(my_plant)
my_data.update_garden_db(my_plant)
finally:
ms.cleanup()
cleanup()

View File

@ -6,6 +6,6 @@ garden_db_path = os.path.join(game_dir, 'sqlite/garden_db.sqlite')
conn = sqlite3.connect(garden_db_path)
c = conn.cursor()
c.execute("DELETE FROM visitors")
print("Cleared weekly users")
print "Cleared weekly users"
conn.commit()
conn.close()

View File

@ -1,55 +0,0 @@
class LoginCompleter:
""" A loop-based completion system for logins """
def __init__(self, menu):
self.s = ""
self.logins = None
self.completions = []
# completion_id has a value of -1 for the base user input
# and between 0 and len(completions)-1 for completions
self.completion_id = -1
self.completion_base = ""
self.menu = menu
def initialize(self):
""" Initialise the list of completable logins """
garden = self.menu.user_data.retrieve_garden_from_db()
self.logins = set()
for plant_id in garden:
if not garden[plant_id]:
continue
entry = garden[plant_id]
if "owner" in entry:
self.logins.add(entry["owner"])
self.logins = sorted(list(self.logins))
def update_input(self, s):
""" Update the user input and reset completion base """
self.s = s
self.completion_base = self.s
self.completion_id = -1
def complete(self, direction = 1):
"""
Returns the completed string from the user input
Loops forward in the list of logins if direction is positive, and
backwards if direction is negative
"""
def loginFilter(x):
return x.startswith(self.s) & (x != self.s)
# Refresh possible completions after the user edits
if self.completion_id == -1:
if self.logins is None:
self.initialize()
self.completion_base = self.s
self.completions = list(filter(loginFilter, self.logins))
self.completion_id += direction
# Loop from the back
if self.completion_id == -2:
self.completion_id = len(self.completions) - 1
# If we are at the base input, return it
if self.completion_id == -1 or self.completion_id == len(self.completions):
self.completion_id = -1
return self.completion_base
return self.completions[self.completion_id]

View File

@ -10,8 +10,6 @@ import json
import sqlite3
import string
import re
import completer
import datetime
class CursedMenu(object):
#TODO: name your plant
@ -36,7 +34,7 @@ class CursedMenu(object):
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.plant_ticks = str(self.plant.ticks)
self.exit = False
self.infotoggle = 0
self.maxy, self.maxx = self.screen.getmaxyx()
@ -51,13 +49,10 @@ class CursedMenu(object):
screen_thread = threading.Thread(target=self.update_plant_live, args=())
screen_thread.daemon = True
screen_thread.start()
# Recusive 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)
@ -80,23 +75,26 @@ class CursedMenu(object):
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 self.plant.dead:
if "harvest" not in self.options:
self.options.insert(-1,"harvest")
else:
if "harvest" in self.options:
self.options.remove("harvest")
if 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':
if options[-1] is not '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()
@ -108,13 +106,12 @@ class CursedMenu(object):
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":
while request is not "exit":
self.draw()
request = self.get_user_input()
self.handle_request(request)
@ -127,10 +124,6 @@ class CursedMenu(object):
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
@ -139,11 +132,9 @@ class CursedMenu(object):
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
@ -174,8 +165,6 @@ class CursedMenu(object):
]
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:
@ -193,7 +182,6 @@ class CursedMenu(object):
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
@ -228,7 +216,6 @@ class CursedMenu(object):
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
@ -255,10 +242,8 @@ class CursedMenu(object):
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.addstr(1, 1, str(user_in), curses.A_NORMAL)
# self.screen.refresh()
# Resize sends curses.KEY_RESIZE, update display
@ -267,17 +252,14 @@ class CursedMenu(object):
self.screen.clear()
self.screen.refresh()
# enter, exit, and Q Keys are special cases
# enter and exit 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)))):
if user_in >= ord('1') and user_in <= ord(str(min(9,len(self.options)+1))):
self.selected = user_in - ord('0') - 1 # convert keypress back to a number, then subtract 1 to get index
return
@ -308,21 +290,11 @@ class CursedMenu(object):
return plant_table
def format_garden_entry(self, entry):
return "{:14.14} - {:>16} - {:>8}p - {}".format(*entry)
return "{: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)
return table.sort(key=lambda x: x[column], reverse=not ascending)
def filter_garden_table(self, table, pattern):
""" Filter table using a pattern, and return the new table """
@ -356,6 +328,7 @@ class CursedMenu(object):
self.infotoggle = 2
# print garden information OR clear it
# TODO: pagination control with hjkl/arrow keys/esc-or-x to close
index = 0
sort_column, sort_ascending = 0, True
sort_keys = ["n", "a", "s", "d"] # Name, Age, Score, Description
@ -366,16 +339,12 @@ class CursedMenu(object):
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
@ -398,8 +367,6 @@ class CursedMenu(object):
# 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))
@ -564,7 +531,7 @@ class CursedMenu(object):
# 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: {:.1f}x".format(self.plant.generation, growth_multiplier)
output_string += "Generation: {}\nGrowth rate: {}".format(self.plant.generation, growth_multiplier)
self.draw_info_text(output_string)
self.infotoggle = 1
else:
@ -581,7 +548,6 @@ class CursedMenu(object):
"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"
)
@ -592,18 +558,15 @@ class CursedMenu(object):
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.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):
@ -613,7 +576,6 @@ class CursedMenu(object):
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()
@ -630,10 +592,8 @@ class CursedMenu(object):
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'), 10]:
if user_in in [ord('Y'), ord('y')]:
self.plant.start_over()
else:
pass
@ -684,36 +644,22 @@ class CursedMenu(object):
visitor_block = 'nobody :('
return visitor_block
def get_user_string(self, xpos=3, ypos=15, filterfunc=str.isalnum, completer=None):
def get_user_string(self, xpos=3, ypos=15, filterfunc=str.isalnum):
# 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) == '_':
if user_input < 256 and user_input != 10:
if filterfunc(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):
@ -728,7 +674,7 @@ class CursedMenu(object):
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)
guest_garden = self.get_user_string()
if not guest_garden:
self.clear_info_pane()
return None
@ -747,35 +693,26 @@ class CursedMenu(object):
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))
self.water_on_visit(guest_visitor_file)
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, "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
self.screen.getch()
self.clear_info_pane()
self.draw_plant_ascii(self.plant)
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}
guest_data = {'user': getpass.getuser(), 'timestamp': int(time.time())}
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 """

366
plant.py
View File

@ -1,366 +0,0 @@
import random
import os
import json
import threading
import time
import uuid
import getpass
class Plant:
# 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.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
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 self.dead:
return True
# 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 = '{}' ".format(self.owner, name))
data=c.fetchone()
if data is None:
sql = """ INSERT INTO visitors (garden_name,visitor_name,weekly_visits) VALUES('{}', '{}',1)""".format(self.owner, name)
c.execute(sql)
else:
sql = """ UPDATE visitors SET weekly_visits = weekly_visits + 1 WHERE garden_name = '{}' AND visitor_name = '{}'""".format(self.owner, name)
c.execute(sql)
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 visitor_file:
data = json.load(visitor_file)
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 element['timestamp'] <= int(time.time()) and 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_file:
visitor_file.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 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 = 10000
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, dm):
# runs life on a thread
thread = threading.Thread(target=self.life, args=(dm,))
thread.daemon = True
thread.start()
def life(self, dm):
# I've created life :)
counter = 0
generation_bonus = round(0.2 * (self.generation - 1), 1)
score_inc = 1 * (1 + generation_bonus)
while True:
counter += 1
if not self.dead:
if self.watered_24h:
self.ticks += score_inc
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():
dm.harvest_plant(self)
self.unlock_new_creation()
if counter % 3 == 0:
dm.save_plant(self)
dm.data_write_json(self)
dm.update_garden_db(self)
if counter % 30 == 0:
dm.update_garden_json()
counter = 0
# TODO: event check
time.sleep(2)

View File

@ -40,10 +40,10 @@ def update_garden_db():
# )
# """.format(pid = "asdfasdf", pown = "jake", pdes = "big cool plant", page="25d", psco = str(25), pdead = str(False))
print(c.execute(update_query))
print c.execute(update_query)
conn.commit()
conn.close()
#print("bigggg booom")
#print "bigggg booom"
def retrieve_garden_from_db(garden_db_path):
# Builds a dict of dicts from garden sqlite db
@ -68,7 +68,7 @@ def retrieve_garden_from_db(garden_db_path):
#init_database()
#update_garden_db()
results = retrieve_garden_from_db(garden_db_path)
print(results)
print results
# con = sqlite3.connect(garden_db_path) #
@ -77,7 +77,7 @@ print(results)
# cur.execute("select * from garden ORDER BY score desc") #
# blah = cur.fetchall() #
# con.close()
# print(blah)
# print blah