Compare commits
9 Commits
Author | SHA1 | Date |
---|---|---|
Nate Smith | 32d2e76198 | |
Nate Smith | 2138a8ffe2 | |
Nate Smith | beafc128e3 | |
Nate Smith | dcab278223 | |
Nate Smith | 6811deaedc | |
Nate Smith | e17c782737 | |
Nate Smith | 67676dda8d | |
Nate Smith | 673232919b | |
Nate Smith | bc843b27cc |
42
botany.py
42
botany.py
|
@ -46,6 +46,46 @@ from plant import Plant
|
||||||
# - the status display: score and plant description
|
# - the status display: score and plant description
|
||||||
# - the infow window. updated by visit/garden/instructions/look
|
# - the infow window. updated by visit/garden/instructions/look
|
||||||
|
|
||||||
|
# thoughts on storage
|
||||||
|
|
||||||
|
# Right now, plants are stored in a few ways:
|
||||||
|
# - as a row in the garden db for displaying when users choose to view a garden. this can be thought of as a cache over the plant files in individual user's home .botany dirs. this is a sqlite3 file kept in the source directory for botany's code.
|
||||||
|
# - as a json file local to ~/.botany; this is, afaict, for people to use as a kind of API?
|
||||||
|
# - as a .dat file--a pickled representation of that plant--in ~/.botany. this is the actual savefile for a plant.
|
||||||
|
#
|
||||||
|
# the fundmental pieces of data for a plant are:
|
||||||
|
# - birth timestamp
|
||||||
|
# - last water timestamp
|
||||||
|
# - generation
|
||||||
|
# - mutation
|
||||||
|
#
|
||||||
|
# everything else is either flavor (art, adjectives, rarity) or can be derived
|
||||||
|
# (alive/dead, stage, growth rate, water level)
|
||||||
|
#
|
||||||
|
# the visitor system allows for other people to touch the last water timestamp
|
||||||
|
# which is an interesting permissions problem.
|
||||||
|
#
|
||||||
|
# it's tempting to re-imagine all of this as something centralized: a shared
|
||||||
|
# sqlite3 db tracks 100% of information on all plants and on visitation. This
|
||||||
|
# would simplify a lot about botany. I think there is something romantic about
|
||||||
|
# having a garden/plant file in one's home directory. This also reduces the
|
||||||
|
# likelihood of a centralized error wiping out plant data.
|
||||||
|
#
|
||||||
|
# So can a new botany be designed that
|
||||||
|
# - keeps plant files distributed
|
||||||
|
# - allows for visitation
|
||||||
|
# - can render an overview of a system's garden
|
||||||
|
# - can handle changes to a plant when botany isn't itself running
|
||||||
|
# - can handle changes to a plant from out-of-band (eg visitor) when botany is running
|
||||||
|
#
|
||||||
|
# that minimizes the chances for race conditions and reduces the redundancy of
|
||||||
|
# plant serialization?
|
||||||
|
#
|
||||||
|
# reduce down to a single game loop
|
||||||
|
# start by filling up .botany (json or sqlite3?) and consider garden generalization as an out of band task.
|
||||||
|
# create a world-writable sqlite3 db for visitors to write to
|
||||||
|
|
||||||
|
|
||||||
class DataManager(object):
|
class DataManager(object):
|
||||||
# handles user data, puts a .botany dir in user's home dir (OSX/Linux)
|
# handles user data, puts a .botany dir in user's home dir (OSX/Linux)
|
||||||
# handles shared data with sqlite db
|
# handles shared data with sqlite db
|
||||||
|
@ -81,7 +121,7 @@ class DataManager(object):
|
||||||
|
|
||||||
def check_plant(self):
|
def check_plant(self):
|
||||||
# check for existing save file
|
# 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
|
return True
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
|
|
|
@ -0,0 +1,404 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
from enum import StrEnum
|
||||||
|
import random
|
||||||
|
import curses
|
||||||
|
import datetime
|
||||||
|
from os import path
|
||||||
|
from getpass import getuser
|
||||||
|
import os
|
||||||
|
from datetime import timezone
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from time import sleep
|
||||||
|
from typing import Optional, Tuple, TypeVar
|
||||||
|
|
||||||
|
BOTANY_DIR = ".botany"
|
||||||
|
MIN_SCREEN_WIDTH = 70
|
||||||
|
MIN_SCREEN_HEIGHT = 20
|
||||||
|
INTERVAL = 1
|
||||||
|
|
||||||
|
dt = datetime.datetime
|
||||||
|
|
||||||
|
def now() -> dt:
|
||||||
|
return dt.now(timezone.utc)
|
||||||
|
|
||||||
|
# flavor dict keys
|
||||||
|
# - color
|
||||||
|
# - rarity
|
||||||
|
# - species
|
||||||
|
# - mutation
|
||||||
|
PLOT_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS plot (
|
||||||
|
-- Each row is a plant
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
created TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M', 'now', 'localtime')),
|
||||||
|
watered TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M', 'now', 'localtime')),
|
||||||
|
generation INTEGER,
|
||||||
|
dead INTEGER,
|
||||||
|
flavor JSON
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
VISITORS_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS visitors (
|
||||||
|
-- Each row is a visit from another user
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT,
|
||||||
|
at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M', 'now', 'localtime'))
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# TODO code for generating a global garden database (finds all most recent plants for users on system and fills a sqlite3 db with computed values)
|
||||||
|
|
||||||
|
def mkdir(p: str) -> Optional[Exception]:
|
||||||
|
if not path.isdir(p):
|
||||||
|
try:
|
||||||
|
os.makedirs(p)
|
||||||
|
except Exception as e:
|
||||||
|
return Exception(f"failed to create {p}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def mkdb(p: str, sql: str) -> Optional[Exception]:
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(p)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute(sql)
|
||||||
|
conn.close()
|
||||||
|
except Exception as e:
|
||||||
|
return Exception(f"failed to initialize {p}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def conndb(p: str) -> Tuple[Optional[sqlite3.Connection], Optional[Exception]]:
|
||||||
|
try:
|
||||||
|
return sqlite3.connect(p), None
|
||||||
|
except Exception as e:
|
||||||
|
return None, Exception(f"could not connect to {p}: {e}")
|
||||||
|
|
||||||
|
def setup(bdir: str, plotdb_path: str, visitordb_path: str) -> Optional[Exception]:
|
||||||
|
e = mkdir(bdir)
|
||||||
|
if e is not None:
|
||||||
|
return e
|
||||||
|
|
||||||
|
dbdir = path.join(bdir, "db")
|
||||||
|
e = mkdir(dbdir)
|
||||||
|
if e is not None:
|
||||||
|
return e
|
||||||
|
|
||||||
|
e = mkdb(plotdb_path, PLOT_SCHEMA)
|
||||||
|
if e is not None:
|
||||||
|
return e
|
||||||
|
|
||||||
|
e = mkdb(visitordb_path, VISITORS_SCHEMA)
|
||||||
|
if e is not None:
|
||||||
|
return e
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
class UI:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.quitting = False
|
||||||
|
self.pwin = curses.initscr()
|
||||||
|
self.menuwin = curses.newwin(10, 30, 0, 0)
|
||||||
|
self.plantwin = curses.newwin(30, 40, 0, 31)
|
||||||
|
self.scorewin = curses.newwin(2, 30, 15, 2)
|
||||||
|
# TODO info area (is this where prompt is rendered?)
|
||||||
|
|
||||||
|
self.menu_opts = [
|
||||||
|
"water",
|
||||||
|
"look",
|
||||||
|
"garden",
|
||||||
|
"visit",
|
||||||
|
"instructions",
|
||||||
|
"quit"]
|
||||||
|
|
||||||
|
self.selected_opt = 0
|
||||||
|
|
||||||
|
self.pwin.keypad(True)
|
||||||
|
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 be slightly uglier but functional
|
||||||
|
# so we ignore this error for terminal compatibility.
|
||||||
|
pass
|
||||||
|
|
||||||
|
if curses.COLS < MIN_SCREEN_WIDTH:
|
||||||
|
raise Exception("the terminal window is too narrow")
|
||||||
|
if curses.LINES < MIN_SCREEN_HEIGHT:
|
||||||
|
raise Exception("the terminal window is too short")
|
||||||
|
|
||||||
|
def quit(self) -> None:
|
||||||
|
self.quitting = True
|
||||||
|
|
||||||
|
def handle_input(self) -> None:
|
||||||
|
while True:
|
||||||
|
c = self.pwin.getch()
|
||||||
|
if c == -1 or c == ord("q") or c == ord("x") or c == 27:
|
||||||
|
self.quit()
|
||||||
|
break
|
||||||
|
if c == curses.KEY_DOWN or c == ord("j"):
|
||||||
|
self.selected_opt += 1
|
||||||
|
self.selected_opt %= len(self.menu_opts)
|
||||||
|
self.draw_menu()
|
||||||
|
if c == curses.KEY_UP or c == ord("k"):
|
||||||
|
self.selected_opt -= 1
|
||||||
|
if self.selected_opt < 0:
|
||||||
|
self.selected_opt = 0
|
||||||
|
self.draw_menu()
|
||||||
|
|
||||||
|
def draw_menu(self) -> None:
|
||||||
|
# TODO water gauge
|
||||||
|
self.menuwin.addstr(1, 2, " botany ", curses.A_STANDOUT)
|
||||||
|
self.menuwin.addstr(3, 2, "options", curses.A_BOLD)
|
||||||
|
x = 0
|
||||||
|
for o in self.menu_opts:
|
||||||
|
style = curses.A_NORMAL
|
||||||
|
|
||||||
|
if x == self.selected_opt:
|
||||||
|
style = curses.A_STANDOUT
|
||||||
|
|
||||||
|
self.menuwin.addstr(4 + x, 4, f"{x+1} - {o}", style)
|
||||||
|
|
||||||
|
x += 1
|
||||||
|
self.menuwin.refresh()
|
||||||
|
|
||||||
|
def draw(self) -> None:
|
||||||
|
self.draw_menu()
|
||||||
|
# TODO draw score
|
||||||
|
# TODO info window
|
||||||
|
# Draw plant
|
||||||
|
# TODO actually do
|
||||||
|
# TODO make conditional on plant ascii actually changing
|
||||||
|
lol = "TODO plant ascii"
|
||||||
|
plant = ""
|
||||||
|
x = 0
|
||||||
|
while x < len(lol):
|
||||||
|
flip = random.randint(0, 1)
|
||||||
|
if flip == 0:
|
||||||
|
plant += lol[x]
|
||||||
|
else:
|
||||||
|
plant += lol[x].upper()
|
||||||
|
x += 1
|
||||||
|
|
||||||
|
self.plantwin.addstr(0,0, plant, curses.A_STANDOUT)
|
||||||
|
self.plantwin.refresh()
|
||||||
|
|
||||||
|
class PlantStage(StrEnum):
|
||||||
|
SEED = "seed"
|
||||||
|
SEEDLING = "seedling"
|
||||||
|
YOUNG = "young"
|
||||||
|
MATURE = "mature"
|
||||||
|
FLOWERING = "flowering"
|
||||||
|
SEEDBEARING = "seed-bearing"
|
||||||
|
|
||||||
|
class PlantColor(StrEnum):
|
||||||
|
RED = "red"
|
||||||
|
ORANGE = "orange"
|
||||||
|
YELLOW = "yellow"
|
||||||
|
GREEN = "green"
|
||||||
|
BLUE = "blue"
|
||||||
|
INDIGO = "indigo"
|
||||||
|
VIOLET = "violet"
|
||||||
|
WHITE = "white"
|
||||||
|
BLACK = "black"
|
||||||
|
GOLD = "gold"
|
||||||
|
RAINBOW = "rainbow"
|
||||||
|
|
||||||
|
class PlantRarity(StrEnum):
|
||||||
|
COMMON = "common"
|
||||||
|
UNCOMMON = "uncommon"
|
||||||
|
RARE = "rare"
|
||||||
|
LEGENDARY = "legendary"
|
||||||
|
GODLY = "godly"
|
||||||
|
|
||||||
|
class PlantSpecies(StrEnum):
|
||||||
|
POPPY = "poppy"
|
||||||
|
CACTUS = "cactus"
|
||||||
|
ALOE = "aloe"
|
||||||
|
FLYTRAP = "venus flytrap"
|
||||||
|
JADE = "jade plant"
|
||||||
|
FERN = "fern"
|
||||||
|
DAFFODIL = "daffodil"
|
||||||
|
SUNFLOWER = "sunflower"
|
||||||
|
BAOBAB = "baobab"
|
||||||
|
LITHOPS = "lithops"
|
||||||
|
HEMP = "hemp"
|
||||||
|
PANSY = "pansy"
|
||||||
|
IRIS = "iris"
|
||||||
|
AGAVE = "agave"
|
||||||
|
FICUS = "ficus",
|
||||||
|
MOSS = "moss",
|
||||||
|
SAGE = "sage",
|
||||||
|
SNAPDRAGON = "snapdragon"
|
||||||
|
COLUMBINE = "columbine"
|
||||||
|
BRUGMANSIA = "brugmansia"
|
||||||
|
PALM = "palm"
|
||||||
|
PACHYPODIUM = "pachypodium"
|
||||||
|
|
||||||
|
class PlantMutation(StrEnum):
|
||||||
|
HUMMING = 'humming',
|
||||||
|
NOXIOUS = 'noxious',
|
||||||
|
VORPAL = 'vorpal',
|
||||||
|
GLOWING = 'glowing',
|
||||||
|
ELECTRIC = 'electric',
|
||||||
|
ICY = 'icy',
|
||||||
|
FLAMING = 'flaming',
|
||||||
|
PSYCHIC = 'psychic',
|
||||||
|
SCREAMING = 'screaming',
|
||||||
|
CHAOTIC = 'chaotic',
|
||||||
|
HISSING = 'hissing',
|
||||||
|
GELATINOUS = 'gelatinous',
|
||||||
|
DEFORMED = 'deformed',
|
||||||
|
SHAGGY = 'shaggy',
|
||||||
|
SCALY = 'scaly',
|
||||||
|
DEPRESSED = 'depressed',
|
||||||
|
ANXIOUS = 'anxious',
|
||||||
|
METALLIC = 'metallic',
|
||||||
|
GLOSSY = 'glossy',
|
||||||
|
PSYCHEDELIC = 'psychedelic',
|
||||||
|
BONSAI = 'bonsai',
|
||||||
|
FOAMY = 'foamy',
|
||||||
|
SINGING = 'singing',
|
||||||
|
FRACTAL = 'fractal',
|
||||||
|
CRUNCHY = 'crunchy',
|
||||||
|
GOTH = 'goth',
|
||||||
|
OOZING = 'oozing',
|
||||||
|
STINKY = 'stinky',
|
||||||
|
AROMATIC = 'aromatic',
|
||||||
|
JUICY = 'juicy',
|
||||||
|
SMUG = 'smug',
|
||||||
|
VIBRATING = 'vibrating',
|
||||||
|
LITHE = 'lithe',
|
||||||
|
CHALKY = 'chalky',
|
||||||
|
NAIVE = 'naive',
|
||||||
|
ERSATZ = 'ersatz',
|
||||||
|
DISCO = 'disco',
|
||||||
|
LEVITATING = 'levitating',
|
||||||
|
COLOSSAL = 'colossal',
|
||||||
|
LUMINOUS = 'luminous',
|
||||||
|
COSMIC = 'cosmic',
|
||||||
|
ETHEREAL = 'ethereal',
|
||||||
|
CURSED = 'cursed',
|
||||||
|
BUFF = 'buff',
|
||||||
|
NARCOTIC = 'narcotic',
|
||||||
|
GNULINUX = 'gnu/linux',
|
||||||
|
ABRAXAN = 'abraxan', # rip dear friend
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Plant:
|
||||||
|
created: dt
|
||||||
|
watered: dt
|
||||||
|
color: PlantColor
|
||||||
|
rarity: PlantRarity
|
||||||
|
species: PlantSpecies
|
||||||
|
mutation: PlantMutation
|
||||||
|
generation: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stage(self) -> PlantStage:
|
||||||
|
# TODO calculate based on aged
|
||||||
|
return PlantStage.SEED
|
||||||
|
|
||||||
|
@property
|
||||||
|
def score(self) -> int:
|
||||||
|
# TODO calculate based on things
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dead(self) -> bool:
|
||||||
|
# TODO calculate based on watered
|
||||||
|
return False
|
||||||
|
|
||||||
|
def mkplant() -> Plant:
|
||||||
|
# TODO randomize properties as needed
|
||||||
|
return Plant()
|
||||||
|
|
||||||
|
def get_or_create_plant(conn: sqlite3.Connection) -> Tuple[Plant, Optional[Exception]]:
|
||||||
|
p = Plant()
|
||||||
|
err = None
|
||||||
|
try:
|
||||||
|
c = conn.cursor()
|
||||||
|
sql = "SELECT created, watered, generation, dead, flavor FROM plot WHERE dead = 0"
|
||||||
|
rows = cur.execute(sql).fetchall()
|
||||||
|
if len(rows) > 1:
|
||||||
|
return p, Exception("that's my purse. i don't know you")
|
||||||
|
if len(rows) == 0:
|
||||||
|
p = mkplant()
|
||||||
|
sql = """
|
||||||
|
INSERT INTO plot (watered, generation, dead, flavor) VALUES (
|
||||||
|
?, 1, 0, ?
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
err = e
|
||||||
|
finally:
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
return p, err
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> Optional[Exception]:
|
||||||
|
username = getuser() # TODO unused
|
||||||
|
|
||||||
|
bdir = path.expanduser(path.join("~", BOTANY_DIR))
|
||||||
|
plotdb_path = path.join(bdir, "db/plot.db")
|
||||||
|
visitordb_path = path.join(bdir, "db/visitors.db")
|
||||||
|
|
||||||
|
e = setup(bdir, plotdb_path, visitordb_path)
|
||||||
|
if e is not None:
|
||||||
|
return e
|
||||||
|
|
||||||
|
# time to think about db connections. I don't think there is much harm in
|
||||||
|
# keeping them open, so let's open them.
|
||||||
|
|
||||||
|
plotdb, e = conndb(plotdb_path)
|
||||||
|
if e is not None:
|
||||||
|
return e
|
||||||
|
|
||||||
|
plant, e = get_or_create_plant(plotdb)
|
||||||
|
if e is not None:
|
||||||
|
return Exception(f"could not find or make a plant: {e}")
|
||||||
|
# If the latest plant is dead, the user can choose to create a new plant with harvest option.
|
||||||
|
# TODO
|
||||||
|
|
||||||
|
try:
|
||||||
|
ui = UI()
|
||||||
|
except Exception as e:
|
||||||
|
return Exception(f"could not initialize UI: {e}")
|
||||||
|
|
||||||
|
ithread = threading.Thread(target=ui.handle_input, args=())
|
||||||
|
ithread.start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if ui.quitting:
|
||||||
|
break
|
||||||
|
|
||||||
|
# TODO get plant info from db
|
||||||
|
# TODO update in-memory representation of derived characteristics / plant info
|
||||||
|
ui.draw()
|
||||||
|
sleep(INTERVAL)
|
||||||
|
|
||||||
|
try:
|
||||||
|
curses.curs_set(2)
|
||||||
|
except curses.error:
|
||||||
|
# cursor not supported; just ignore
|
||||||
|
pass
|
||||||
|
curses.endwin()
|
||||||
|
os.system('clear')
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ret = 0
|
||||||
|
e = main()
|
||||||
|
if e is not None:
|
||||||
|
print(e, file=sys.stderr)
|
||||||
|
ret = 1
|
||||||
|
sys.exit(ret)
|
|
@ -564,7 +564,7 @@ class CursedMenu(object):
|
||||||
# get plant description before printing
|
# get plant description before printing
|
||||||
output_string = self.get_plant_description(this_plant)
|
output_string = self.get_plant_description(this_plant)
|
||||||
growth_multiplier = 1 + (0.2 * (this_plant.generation-1))
|
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: {}x".format(self.plant.generation, growth_multiplier)
|
||||||
self.draw_info_text(output_string)
|
self.draw_info_text(output_string)
|
||||||
self.infotoggle = 1
|
self.infotoggle = 1
|
||||||
else:
|
else:
|
||||||
|
|
3
plant.py
3
plant.py
|
@ -164,7 +164,7 @@ class Plant:
|
||||||
def rarity_check(self):
|
def rarity_check(self):
|
||||||
# Generate plant rarity
|
# Generate plant rarity
|
||||||
CONST_RARITY_MAX = 256.0
|
CONST_RARITY_MAX = 256.0
|
||||||
rare_seed = random.randint(1,int(CONST_RARITY_MAX))
|
rare_seed = random.randint(1,CONST_RARITY_MAX)
|
||||||
common_range = round((2.0/3)*CONST_RARITY_MAX)
|
common_range = round((2.0/3)*CONST_RARITY_MAX)
|
||||||
uncommon_range = round((2.0/3)*(CONST_RARITY_MAX-common_range))
|
uncommon_range = round((2.0/3)*(CONST_RARITY_MAX-common_range))
|
||||||
rare_range = round((2.0/3)*(CONST_RARITY_MAX-common_range-uncommon_range))
|
rare_range = round((2.0/3)*(CONST_RARITY_MAX-common_range-uncommon_range))
|
||||||
|
@ -311,6 +311,7 @@ class Plant:
|
||||||
self.write_lock = True
|
self.write_lock = True
|
||||||
self.kill_plant()
|
self.kill_plant()
|
||||||
while self.write_lock:
|
while self.write_lock:
|
||||||
|
# TODO is this the culprit for the intermittent full-runaway botany?
|
||||||
# Wait for garden writer to unlock
|
# Wait for garden writer to unlock
|
||||||
# garden db needs to update before allowing the user to reset
|
# garden db needs to update before allowing the user to reset
|
||||||
pass
|
pass
|
||||||
|
|
Loading…
Reference in New Issue