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 |
40
botany.py
40
botany.py
|
@ -46,6 +46,46 @@ from plant import Plant
|
|||
# - the status display: score and plant description
|
||||
# - 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):
|
||||
# handles user data, puts a .botany dir in user's home dir (OSX/Linux)
|
||||
# handles shared data with sqlite db
|
||||
|
|
|
@ -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)
|
Loading…
Reference in New Issue