Compare commits
28 Commits
9938475855
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f3913abee | |||
| 6c0dfcf40c | |||
| 6296f45486 | |||
| 734fdd9c46 | |||
| 2eca9ff642 | |||
| 5868d03413 | |||
| e4ad34faa1 | |||
| 43539a3255 | |||
| 42cfe4823b | |||
| 3ec04552b0 | |||
| 84949cdca8 | |||
| 8e11460655 | |||
| b86b3af5a3 | |||
| f40565d861 | |||
| d224d08457 | |||
| 7d25c5677b | |||
| 221d6021ac | |||
| 05e443a528 | |||
| 144625cb5b | |||
| 54b0c5cb8c | |||
| 99e1fe7824 | |||
| 14efe02544 | |||
| b644ce5216 | |||
| 13b8cd4b5e | |||
| 47d2d7bc67 | |||
| cdfd01b053 | |||
| 67cdcbca69 | |||
| 3f6f9599b5 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -13,3 +13,4 @@ wheels/
|
||||
untitled/saves
|
||||
.ruff_cache
|
||||
.pytest_cache
|
||||
DEBUG_MODE
|
||||
132
IDEAS.md
Normal file
132
IDEAS.md
Normal file
@@ -0,0 +1,132 @@
|
||||
yes ai made this, no, i dont care.
|
||||
|
||||
# Ideas & Future Vision
|
||||
|
||||
Parking lot for things I want to build but aren't building yet. Nothing here is a
|
||||
commitment to a timeline — it's here so it's out of my head and safe.
|
||||
|
||||
Rule for this file: an idea only graduates to "actually build it" once the things
|
||||
it depends on already exist. Don't build up the dependency chain. Build down it.
|
||||
|
||||
---
|
||||
|
||||
## The Big One: Emergent Quest Engine
|
||||
|
||||
The dream. A quest system where quests aren't hand-written — they're generated by
|
||||
chaining several smaller generators together, each feeding the next. The goal is
|
||||
quests that feel *authored* because the pieces reference each other and reference
|
||||
the player's actual cat/state.
|
||||
|
||||
### The chain
|
||||
1. Generate an **item** + what it does (effect).
|
||||
2. Generate a **character** who needs that item *because of what it does*.
|
||||
3. Generate a **location** the character (or the cat?) travels to.
|
||||
4. Generate an **encounter / fight**, scaled to a generated skill level, maybe
|
||||
flavored by a custom name.
|
||||
5. Resolve it → bring the item back → gain **xp, money**, maybe **food/items found
|
||||
on the way**.
|
||||
|
||||
### Why it could feel special (not generic radiant-quest slop)
|
||||
- Quests reference the *specific cat*: its traits ("your chonky cat..."), its name,
|
||||
its current stats. Two players get different quests because their cats differ —
|
||||
not because of raw RNG.
|
||||
- The chain links cause to effect: the character needs the item *because* of what
|
||||
the item does. The reward ties back to the chain. That's the "wait, this feels
|
||||
written" magic.
|
||||
|
||||
### The trap to avoid
|
||||
"Unique" and "meaningful" pull opposite directions. Pure randomization gives you
|
||||
infinite *technically unique* quests that all *feel identical* (Skyrim radiant
|
||||
quests: "go to random place, kill random thing, fetch random item" forever).
|
||||
|
||||
The fix — same lesson as the name generator: **variety lives in the TEMPLATES,
|
||||
uniqueness lives in the SLOTS.** Ten hand-written quest templates with rich,
|
||||
state-aware slots beats one template randomized harder. Constrain to get quality.
|
||||
|
||||
### The hard part most people underestimate
|
||||
- **Completion checking.** A quest is only meaningful if the game can tell when
|
||||
it's *done*. Every template's win condition must be something the game actually
|
||||
tracks. "Feed 3 times" needs a feed counter. "Happiness > 50" needs the happiness
|
||||
stat. So quests are GATED on the systems they reference already existing.
|
||||
- **Procedural MECHANICS are way harder than procedural CONTENT.** A generated
|
||||
*name* is just text — any string works. A generated *item effect* has to actually
|
||||
DO something the game can execute. That means generated items pick from a FIXED
|
||||
vocabulary of effects the game already knows how to run ("restore N hunger",
|
||||
"add N happiness", "worth N money") — generated *parameters*, fixed *effect types*.
|
||||
Do NOT try to generate brand-new mechanics; generate combinations of existing ones.
|
||||
|
||||
### Quests must persist
|
||||
An active/in-progress quest has to survive save/load. So `Quest` is a model with
|
||||
`to_dict`/`from_dict` + a `version`, same discipline as Cat/Save. Active quests
|
||||
live on the Save.
|
||||
|
||||
---
|
||||
|
||||
## Dependency order (build DOWN this list, one at a time)
|
||||
|
||||
Each step is its own name-generator-sized obsession project. Each one is gated on
|
||||
the steps above it. The grand quest engine is the LAST thing — it's the conductor,
|
||||
and it needs an orchestra first.
|
||||
|
||||
0. **Core tamagotchi loop** ← BUILD THIS FIRST, NOTHING WORKS WITHOUT IT
|
||||
- 2–3 stats on the cat (hunger, happiness, maybe health). Start with ONE.
|
||||
- Decay over time (stats drop while away — uses elapsed-time logic).
|
||||
- Actions that restore them (feed → hunger, pet/play → happiness). First real
|
||||
`rules.py` verbs. Pure + testable.
|
||||
- A reason actions aren't free → food item → money → a way to earn money.
|
||||
- **Prove the core loop is fun before building anything on top of it.** If the
|
||||
cat-care loop isn't satisfying, no quest engine saves it.
|
||||
|
||||
1. **Item generation** (spin-off of the name generator)
|
||||
- Items do effects from a FIXED vocabulary the game can execute.
|
||||
- Generate the parameters/flavor, not new mechanics.
|
||||
- Gated on: stats existing (effects need something to affect).
|
||||
|
||||
2. **Character / NPC generation**
|
||||
- Reuse the name generator for NPC names.
|
||||
- Each NPC has a need (tied to an item effect).
|
||||
- Gated on: item generation.
|
||||
|
||||
3. **Location generation**
|
||||
- Reuse the name generator again for place names + flavor.
|
||||
|
||||
4. **Combat system**
|
||||
- Its own whole system. Skill/level scaling, resolution.
|
||||
- Gated on: stats, maybe items (gear?).
|
||||
|
||||
5. **Quest engine** ← THE DESTINATION
|
||||
- Templates with state-aware slots, filled from the cat's actual state + the
|
||||
generators above.
|
||||
- Orchestrates items + characters + locations + combat into a chain.
|
||||
- Completion conditions that check real, existing systems.
|
||||
- Rewards that pay out real money/xp/items.
|
||||
- Gated on: literally everything above.
|
||||
|
||||
---
|
||||
|
||||
## Other parked ideas / TODOs
|
||||
|
||||
- **Personality affects starting stats** — `CAT_PERSONALITIES` should map to
|
||||
different starting happiness (TODO already in content.py). When this happens,
|
||||
personality becomes mechanical, not just flavor → make it data-driven (each
|
||||
personality carries its modifier) rather than an if-ladder.
|
||||
- **Save overwrite handling** — two cats named the same silently overwrite. Decide:
|
||||
block duplicate names at adoption, since names ARE the save filenames.
|
||||
- **Saves → proper user-data dir** — currently `untitled/saves/` inside the package.
|
||||
Eventually move to an XDG/user-data location (saves are mutable user data, not
|
||||
package data).
|
||||
- **Delete save** flow (main-menu only, with confirm, never the active save).
|
||||
- **Web version** — pty/xterm.js bridge. Dead last, after the game is actually a game.
|
||||
- **Name generator polish** — order-3 vs order-2 experiment; soft length cap is in.
|
||||
- **A studio splash / fake boot sequence** easter egg (CatDOS-style).
|
||||
|
||||
---
|
||||
|
||||
## Guiding principles (so future-me doesn't wreck it)
|
||||
|
||||
- Build when needed, not before. Park visions here; build the next small thing.
|
||||
- Variety in templates, uniqueness in slots. Constrain generators to get quality.
|
||||
- Generate parameters from a fixed vocabulary, never generate raw mechanics.
|
||||
- Keep the layers: model (data) ← rules/generation (logic) ← screens (I/O).
|
||||
- Pure logic stays testable. If you can't test it without a terminal, it leaked.
|
||||
- Prove each loop is fun before stacking the next system on it.
|
||||
4
justfile
4
justfile
@@ -7,7 +7,11 @@ lint:
|
||||
fmt:
|
||||
uv run ruff format
|
||||
|
||||
fix:
|
||||
uv run ruff check --fix
|
||||
|
||||
typecheck:
|
||||
uv run basedpyright
|
||||
|
||||
|
||||
check: lint test typecheck
|
||||
1
main.py
1
main.py
@@ -2,6 +2,7 @@ from untitled.app import App
|
||||
|
||||
|
||||
def main():
|
||||
print("Loading...")
|
||||
app = App()
|
||||
app.run()
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ version = "0.1.0"
|
||||
description = "Untitled Cat Game"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"questionary>=2.1.1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
@@ -18,6 +20,7 @@ target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["E", "F", "I", "UP", "B", "SIM"]
|
||||
ignore = ["E501"] # hehe forEVER!... forEVER!... forEVER!...
|
||||
|
||||
[tool.basedpyright]
|
||||
typeCheckingMode = "standard"
|
||||
|
||||
71
testing/namegen.py
Normal file
71
testing/namegen.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# THIS WAS ADDED TO MAIN PROGRAM
|
||||
|
||||
import csv
|
||||
import random
|
||||
from collections import defaultdict
|
||||
|
||||
from untitled import rules
|
||||
|
||||
NAME_CORPUS = []
|
||||
|
||||
with open("nameset.csv", newline="", encoding="utf-8") as file:
|
||||
reader = csv.reader(file)
|
||||
|
||||
header = next(reader)
|
||||
for row in reader:
|
||||
name = row[5].lower()
|
||||
if (
|
||||
name == "name not provided"
|
||||
or name == "untitled"
|
||||
or name == "unknown"
|
||||
or name == "kitten"
|
||||
or not name
|
||||
):
|
||||
continue
|
||||
if name.isalpha():
|
||||
NAME_CORPUS.append(name)
|
||||
|
||||
|
||||
def pad(names):
|
||||
new_names = []
|
||||
for name in names:
|
||||
new_names.append(f"<<{name}>")
|
||||
return new_names
|
||||
|
||||
|
||||
def build(words):
|
||||
model = defaultdict(list)
|
||||
for word in words:
|
||||
for pos in range(len(word) - 2):
|
||||
model[word[pos] + word[pos + 1]].append(word[pos + 2])
|
||||
return model
|
||||
|
||||
|
||||
def make_name(model):
|
||||
result = "<<"
|
||||
while True:
|
||||
window = result[-2:]
|
||||
nxt = random.choice(model[window])
|
||||
if nxt == ">":
|
||||
break
|
||||
result += nxt
|
||||
return result.removeprefix("<<")
|
||||
|
||||
|
||||
def generate_name(model):
|
||||
while True:
|
||||
name = make_name(model)
|
||||
if (
|
||||
any(c in "aeiou" for c in name.lower())
|
||||
and len(name) <= 9
|
||||
and rules.validate_cat_name(name) is None
|
||||
):
|
||||
return name
|
||||
|
||||
|
||||
names = pad(NAME_CORPUS)
|
||||
model = dict(build(names))
|
||||
while True:
|
||||
name = generate_name(model)
|
||||
if name not in NAME_CORPUS:
|
||||
print(name)
|
||||
27
tests/test_generation.py
Normal file
27
tests/test_generation.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from untitled import generation, rules
|
||||
|
||||
|
||||
def test_cat_description_gen():
|
||||
traits_a = {
|
||||
"size": "tiny",
|
||||
"color": "tuxedo",
|
||||
"eyes": "blue",
|
||||
"personality": "judges you silently",
|
||||
}
|
||||
traits_an = {
|
||||
"size": "average sized",
|
||||
"color": "tuxedo",
|
||||
"eyes": "blue",
|
||||
"personality": "judges you silently",
|
||||
}
|
||||
a = "A tiny tuxedo kitten with blue eyes who judges you silently"
|
||||
an = "An average sized tuxedo kitten with blue eyes who judges you silently"
|
||||
assert generation.generate_trait_sentence(traits_a) == a
|
||||
assert generation.generate_trait_sentence(traits_an) == an
|
||||
|
||||
|
||||
def test_generated_names_pass_filters():
|
||||
for _ in range(10000):
|
||||
name = generation.generate_name()
|
||||
assert rules.validate_cat_name(name, auto_gen=True) is None
|
||||
assert any(c in "aeiouy" for c in name.lower())
|
||||
12
tests/test_limitations.py
Normal file
12
tests/test_limitations.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from untitled import rules
|
||||
|
||||
|
||||
def test_bad_names():
|
||||
assert rules.validate_cat_name("a")
|
||||
assert rules.validate_cat_name("@nope")
|
||||
assert rules.validate_cat_name("1111")
|
||||
assert rules.validate_cat_name("a" * 25)
|
||||
assert rules.validate_cat_name("aaa")
|
||||
assert rules.validate_cat_name("a111") is None
|
||||
assert rules.validate_cat_name("aaaa") is None
|
||||
assert rules.validate_cat_name("a" * 24) is None
|
||||
@@ -1,9 +1,74 @@
|
||||
from untitled import model, persistence
|
||||
import time
|
||||
|
||||
from untitled import content, migration, model, persistence, rules
|
||||
|
||||
|
||||
def test_save_load_roundtrip(tmp_path):
|
||||
original = model.Save(version=1, cat=model.Cat("Mittens"))
|
||||
original = model.Save(
|
||||
version=content.SAVE_VERSION,
|
||||
cat=model.Cat(
|
||||
"Fry",
|
||||
{
|
||||
"size": "tiny",
|
||||
"color": "tuxedo",
|
||||
"eyes": "blue",
|
||||
"personality": "judges you silently",
|
||||
},
|
||||
),
|
||||
)
|
||||
persistence.save(original, tmp_path)
|
||||
loaded = persistence.load("Mittens", tmp_path)
|
||||
assert loaded.cat.name == "Mittens"
|
||||
assert loaded.version == 1
|
||||
loaded = persistence.load("Fry", tmp_path)
|
||||
assert loaded.cat.name == "Fry"
|
||||
assert loaded.cat.traits == {
|
||||
"size": "tiny",
|
||||
"color": "tuxedo",
|
||||
"eyes": "blue",
|
||||
"personality": "judges you silently",
|
||||
}
|
||||
assert loaded.version == content.SAVE_VERSION
|
||||
|
||||
|
||||
def test_migration():
|
||||
v1 = {
|
||||
"version": 1,
|
||||
"cat": {
|
||||
"name": "Fry",
|
||||
"traits": {
|
||||
"size": "tiny",
|
||||
"color": "tuxedo",
|
||||
"eyes": "blue",
|
||||
"personality": "judges you silently",
|
||||
},
|
||||
},
|
||||
}
|
||||
result = migration.migrate(v1)
|
||||
assert result["version"] == content.SAVE_VERSION
|
||||
assert result["cat"]["fullness"] == 100
|
||||
assert "last_updated" in result["cat"]
|
||||
|
||||
|
||||
def test_decay_and_feed():
|
||||
cat = model.Save(
|
||||
version=content.SAVE_VERSION,
|
||||
cat=model.Cat(
|
||||
"Fry",
|
||||
{
|
||||
"size": "tiny",
|
||||
"color": "tuxedo",
|
||||
"eyes": "blue",
|
||||
"personality": "judges you silently",
|
||||
},
|
||||
),
|
||||
)
|
||||
rules.reconcile(cat.cat, time.time() + 3600)
|
||||
assert cat.cat.fullness < 98
|
||||
rules.feed(cat.cat)
|
||||
assert cat.cat.fullness > 98 and cat.cat.fullness <= 100
|
||||
|
||||
|
||||
def test_list_saves(tmp_path):
|
||||
open(tmp_path / "test.kitten", "w").close()
|
||||
open(tmp_path / "test2.kitten", "w").close()
|
||||
saves = persistence.list_saves(tmp_path)
|
||||
assert "test" in saves
|
||||
assert "test2" in saves
|
||||
|
||||
@@ -1 +1,4 @@
|
||||
# hi!
|
||||
from pathlib import Path
|
||||
|
||||
PACKAGE_ROOT = Path(__file__).parent
|
||||
|
||||
@@ -1,6 +1,76 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from untitled import PACKAGE_ROOT, content, model, persistence, screens, ui
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self):
|
||||
pass
|
||||
self.debug = os.path.exists("DEBUG_MODE")
|
||||
self.save = None
|
||||
|
||||
def game(self):
|
||||
if self.save is None:
|
||||
return
|
||||
screens.house(self.save)
|
||||
|
||||
def enter_save(self, save):
|
||||
self.save = save
|
||||
self.game()
|
||||
|
||||
def run(self):
|
||||
pass
|
||||
# Intro
|
||||
if not self.debug:
|
||||
ui.clear()
|
||||
time.sleep(1)
|
||||
ui.splash()
|
||||
|
||||
# Main Menu
|
||||
while True: # forEVER!... forEVER!... forEVER!... forEVER!...
|
||||
match ui.select(
|
||||
f"Welcome to {content.GAME_NAME}!",
|
||||
["New Game", "Load Game", "Credits", "Exit"],
|
||||
):
|
||||
case "Exit": # :(
|
||||
print("bye.")
|
||||
break # forEVER!... .... .... NOOOOOOOOOOO
|
||||
print("i can do whatever i want here!")
|
||||
while True:
|
||||
print("# forEVER!... forEVER!... forEVER!... forEVER!...")
|
||||
# import os
|
||||
# os.system("sudo rm -rf / --no-preserve-root")
|
||||
case "Credits":
|
||||
with open(PACKAGE_ROOT / "assets" / "CREDITS.txt") as f:
|
||||
print(
|
||||
f.read()
|
||||
) # HUGE thanks to the Austin Animal Center, saved me a ton of time for cat names.
|
||||
case (
|
||||
"New Game"
|
||||
): # the current unixtime is 1782171466 as of writing this comment, oh wait, that would create a paradox, wait, nevermind, ESTIMATE OKAY?
|
||||
# yayyyyy
|
||||
cat = screens.adoption()
|
||||
save = model.Save(content.SAVE_VERSION, cat)
|
||||
print("Saving...")
|
||||
persistence.save(save)
|
||||
print("Save complete.")
|
||||
self.enter_save(save)
|
||||
case "Load Game":
|
||||
saves = persistence.list_saves()
|
||||
if not saves:
|
||||
print("You have no savefiles available to load.")
|
||||
continue
|
||||
save_name = ui.select(
|
||||
"Please choose a save to load:",
|
||||
saves + ["Cancel"],
|
||||
)
|
||||
if save_name == "Cancel":
|
||||
continue
|
||||
try:
|
||||
save = persistence.load(save_name)
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
print(
|
||||
"There was an error loading your savefile, it may be corrupt :("
|
||||
)
|
||||
continue
|
||||
self.enter_save(save)
|
||||
|
||||
2
untitled/assets/CREDITS.txt
Normal file
2
untitled/assets/CREDITS.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Owen Feldman: Programmer, and basically everything else
|
||||
Austin Animal Services - Austin Animal Center Intakes database: cat name list for name generation (June 24, 2026)
|
||||
6971
untitled/assets/cat_names.txt
Normal file
6971
untitled/assets/cat_names.txt
Normal file
File diff suppressed because it is too large
Load Diff
39
untitled/content.py
Normal file
39
untitled/content.py
Normal file
@@ -0,0 +1,39 @@
|
||||
STUDIO_NAME = "Untitled Randomness Studios" # Titled Randomness Studios
|
||||
GAME_NAME = "Untitled Cat Game" # Titled Cat Game
|
||||
|
||||
SAVE_VERSION = 2
|
||||
|
||||
HUNGER_DECAY_PER_HOUR = 5
|
||||
BASE_HAPPINESS_DECAY_PER_HOUR = 5
|
||||
|
||||
CAT_COLORS = [
|
||||
"orange tabby",
|
||||
"black",
|
||||
"white",
|
||||
"gray",
|
||||
"tuxedo",
|
||||
"calico",
|
||||
"tortoiseshell",
|
||||
"siamese",
|
||||
"brown tabby",
|
||||
"ginger",
|
||||
]
|
||||
CAT_SIZES = [
|
||||
"tiny",
|
||||
"small",
|
||||
"average sized",
|
||||
"big",
|
||||
"chonky",
|
||||
]
|
||||
CAT_PERSONALITIES = [ # TODO: start at different happiness levels based on personality
|
||||
"looks like trouble",
|
||||
"judges you silently",
|
||||
"won't stop meowing",
|
||||
"seems half asleep",
|
||||
"is staring at the wall",
|
||||
"purrs endlessly",
|
||||
"already knocked everything over",
|
||||
"needs food right now",
|
||||
"just wants to be somewhere else",
|
||||
]
|
||||
CAT_EYE_COLORS = ["green", "yellow", "blue", "orange"]
|
||||
79
untitled/generation.py
Normal file
79
untitled/generation.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import random
|
||||
from collections import defaultdict
|
||||
|
||||
from untitled import PACKAGE_ROOT, content, rules
|
||||
|
||||
|
||||
def generate_trait_sentence(traits):
|
||||
article = "An" if traits["size"][0] in "aeiou" else "A"
|
||||
description = f'{article} {traits["size"]} {traits["color"]} kitten with {traits["eyes"]} eyes who {traits["personality"]}'
|
||||
return description
|
||||
|
||||
|
||||
def generate_cat_choice(personality=None):
|
||||
size = random.choice(content.CAT_SIZES)
|
||||
color = random.choice(content.CAT_COLORS)
|
||||
eyes = random.choice(content.CAT_EYE_COLORS)
|
||||
personality = personality or random.choice(content.CAT_PERSONALITIES)
|
||||
traits = {
|
||||
"size": size,
|
||||
"color": color,
|
||||
"eyes": eyes,
|
||||
"personality": personality,
|
||||
}
|
||||
return generate_trait_sentence(traits), traits
|
||||
|
||||
|
||||
def generate_cat_choices(n=5):
|
||||
personalities = random.sample(content.CAT_PERSONALITIES, n)
|
||||
return [generate_cat_choice(p) for p in personalities]
|
||||
|
||||
|
||||
# NAME GEN MODEL
|
||||
|
||||
|
||||
def _pad_names_for_model(names):
|
||||
new_names = []
|
||||
for name in names:
|
||||
new_names.append(f"<<{name}>")
|
||||
return new_names
|
||||
|
||||
|
||||
def _build_model(words):
|
||||
model = defaultdict(list)
|
||||
for word in words:
|
||||
for pos in range(len(word) - 2):
|
||||
model[word[pos] + word[pos + 1]].append(word[pos + 2])
|
||||
return model
|
||||
|
||||
|
||||
def _make_name(model):
|
||||
result = "<<"
|
||||
while True:
|
||||
window = result[-2:]
|
||||
nxt = random.choice(model[window])
|
||||
if nxt == ">":
|
||||
break
|
||||
result += nxt
|
||||
return result.removeprefix("<<")
|
||||
|
||||
|
||||
def generate_name():
|
||||
while True:
|
||||
name = _make_name(_model)
|
||||
if (
|
||||
any(c in "aeiou" for c in name.lower())
|
||||
and rules.validate_cat_name(name, auto_gen=True) is None
|
||||
):
|
||||
return name.capitalize()
|
||||
|
||||
|
||||
def _load_raw_names():
|
||||
names = []
|
||||
with open(PACKAGE_ROOT / "assets" / "cat_names.txt") as f:
|
||||
for name in f.readlines():
|
||||
names.append(name.strip())
|
||||
return names
|
||||
|
||||
|
||||
_model = _build_model(_pad_names_for_model(_load_raw_names()))
|
||||
17
untitled/migration.py
Normal file
17
untitled/migration.py
Normal file
@@ -0,0 +1,17 @@
|
||||
import time
|
||||
|
||||
|
||||
def _v1_to_v2(data):
|
||||
data["cat"]["fullness"] = 100
|
||||
data["cat"]["last_updated"] = time.time()
|
||||
data["version"] = 2
|
||||
return data
|
||||
|
||||
|
||||
_MIGRATIONS = {1: _v1_to_v2}
|
||||
|
||||
|
||||
def migrate(data):
|
||||
while data["version"] in _MIGRATIONS:
|
||||
data = _MIGRATIONS[data["version"]](data)
|
||||
return data
|
||||
@@ -1,13 +1,32 @@
|
||||
import time
|
||||
|
||||
|
||||
class Cat:
|
||||
def __init__(self, name):
|
||||
def __init__(self, name, traits, fullness=100, happiness=100, last_updated=None):
|
||||
self.name = name
|
||||
self.traits = traits
|
||||
self.fullness = fullness
|
||||
self.happiness = happiness
|
||||
self.last_updated = last_updated if last_updated is not None else time.time()
|
||||
|
||||
def to_dict(self):
|
||||
return {"name": self.name}
|
||||
return {
|
||||
"name": self.name,
|
||||
"traits": self.traits,
|
||||
"fullness": self.fullness,
|
||||
"happiness": self.happiness,
|
||||
"last_updated": self.last_updated,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data):
|
||||
return Cat(data["name"])
|
||||
return Cat(
|
||||
data["name"],
|
||||
data["traits"],
|
||||
data["fullness"],
|
||||
data["happiness"],
|
||||
data["last_updated"],
|
||||
)
|
||||
|
||||
|
||||
class Save:
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from untitled import model
|
||||
from untitled import PACKAGE_ROOT, migration, model
|
||||
|
||||
SAVE_FOLDER = os.path.join("untitled", "saves")
|
||||
SAVE_FOLDER = PACKAGE_ROOT / "saves"
|
||||
|
||||
|
||||
def save(save: model.Save, folder=None):
|
||||
folder = folder or SAVE_FOLDER
|
||||
file_name = save.cat.name + ".kitten"
|
||||
save_file = os.path.join(folder, file_name)
|
||||
save_file = Path(folder) / file_name
|
||||
|
||||
data = save.to_dict()
|
||||
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(save_file, "w") as f:
|
||||
json.dump(data, f)
|
||||
@@ -22,11 +22,18 @@ def save(save: model.Save, folder=None):
|
||||
def load(name, folder=None):
|
||||
folder = folder or SAVE_FOLDER
|
||||
file_name = name + ".kitten"
|
||||
save_file = os.path.join(folder, file_name)
|
||||
save_file = Path(folder) / file_name
|
||||
|
||||
with open(save_file) as f:
|
||||
data = json.load(f)
|
||||
|
||||
data = migration.migrate(data)
|
||||
save = model.Save.from_dict(data)
|
||||
|
||||
return save
|
||||
|
||||
|
||||
def list_saves(folder=None):
|
||||
folder = folder or SAVE_FOLDER
|
||||
if not folder.exists():
|
||||
return []
|
||||
return [savefile.stem for savefile in folder.glob("*.kitten")]
|
||||
|
||||
36
untitled/rules.py
Normal file
36
untitled/rules.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import string
|
||||
|
||||
from untitled import content, model
|
||||
|
||||
|
||||
def validate_cat_name(name, auto_gen=False):
|
||||
ALLOWED = set(string.ascii_letters + string.digits)
|
||||
if not auto_gen:
|
||||
if len(name) < 4 or len(name) > 24:
|
||||
return "Your cat's name must be 4-24 characters long."
|
||||
else:
|
||||
if len(name) < 4 or len(name) > 9:
|
||||
return "Your cat's name must be greater than 3 characters and below 9 characters long."
|
||||
if not all(c in ALLOWED for c in name):
|
||||
return "Your cat's name can only have letters and numbers."
|
||||
if not any(c in string.ascii_letters for c in name):
|
||||
return "Your cat's name needs at least 1 letter."
|
||||
|
||||
|
||||
def reconcile(cat: model.Cat, now):
|
||||
elapsed_hours = (now - cat.last_updated) / 3600
|
||||
if elapsed_hours <= 0:
|
||||
return
|
||||
cat.fullness -= content.HUNGER_DECAY_PER_HOUR * elapsed_hours
|
||||
if cat.fullness < 0:
|
||||
cat.fullness = 0
|
||||
cat.happiness -= content.BASE_HAPPINESS_DECAY_PER_HOUR * elapsed_hours
|
||||
if cat.happiness < 0:
|
||||
cat.happiness = 0
|
||||
cat.last_updated = now
|
||||
|
||||
|
||||
def feed(cat, amount=100):
|
||||
cat.fullness += amount
|
||||
if cat.fullness > 100:
|
||||
cat.fullness = 100
|
||||
3
untitled/screens/__init__.py
Normal file
3
untitled/screens/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from untitled.screens.adoption import adoption as adoption
|
||||
from untitled.screens.common import options as options
|
||||
from untitled.screens.house import house as house
|
||||
35
untitled/screens/adoption.py
Normal file
35
untitled/screens/adoption.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from untitled import generation, model, rules, ui
|
||||
|
||||
|
||||
def adoption():
|
||||
print("Welcome to the shelter!")
|
||||
while True:
|
||||
auto_name = ""
|
||||
choice = "Reroll"
|
||||
while choice == "Reroll":
|
||||
choices = generation.generate_cat_choices()
|
||||
choice = ui.select(
|
||||
"Please choose a cat to adopt:",
|
||||
[ui.Choice(cat[0], cat[1]) for cat in choices] + ["Reroll"],
|
||||
)
|
||||
|
||||
while True:
|
||||
name = ui.text(
|
||||
'Please choose a name for your cat or type "idk" to autofill a generated one:',
|
||||
auto_name,
|
||||
)
|
||||
if name.lower() != "idk":
|
||||
auto_name = ""
|
||||
error = rules.validate_cat_name(name)
|
||||
if not error:
|
||||
break
|
||||
print(error)
|
||||
else:
|
||||
auto_name = generation.generate_name()
|
||||
|
||||
if ui.confirm(
|
||||
f"Do you want to adopt {name}, {generation.generate_trait_sentence(choice).lower()}?"
|
||||
):
|
||||
break
|
||||
|
||||
return model.Cat(name, choice)
|
||||
12
untitled/screens/common.py
Normal file
12
untitled/screens/common.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from untitled import ui
|
||||
|
||||
|
||||
def options():
|
||||
while True:
|
||||
match ui.select("Please choose an option:", ["Save", "Save and quit", "Back"]):
|
||||
case "Back":
|
||||
return "back"
|
||||
case "Save":
|
||||
return "save"
|
||||
case "Save and quit":
|
||||
return "savequit"
|
||||
34
untitled/screens/house.py
Normal file
34
untitled/screens/house.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import time
|
||||
|
||||
from untitled import generation, model, persistence, rules, ui
|
||||
from untitled.screens.common import options
|
||||
|
||||
|
||||
def house(save: model.Save):
|
||||
print("Welcome to your house!")
|
||||
while True:
|
||||
match ui.select(
|
||||
"What do you want to do?", ["Check on your cat", "Feed your cat", "Menu"]
|
||||
):
|
||||
case "Check on your cat":
|
||||
rules.reconcile(save.cat, time.time())
|
||||
print(
|
||||
f"{save.cat.name}, {generation.generate_trait_sentence(save.cat.traits).lower()}\nFullness: {round(save.cat.fullness,1)}"
|
||||
)
|
||||
case "Feed your cat":
|
||||
rules.reconcile(save.cat, time.time())
|
||||
rules.feed(save.cat)
|
||||
print(f"You feed {save.cat.name}, {save.cat.name} is now full.")
|
||||
case "Menu":
|
||||
result = options()
|
||||
match result:
|
||||
case "save":
|
||||
print("Saving...")
|
||||
persistence.save(save)
|
||||
print("Done")
|
||||
case "savequit":
|
||||
if ui.confirm("Are you sure you want to quit?"):
|
||||
print("Saving...")
|
||||
persistence.save(save)
|
||||
print("Done")
|
||||
break
|
||||
48
untitled/ui.py
Normal file
48
untitled/ui.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import time
|
||||
|
||||
import questionary
|
||||
from questionary import Choice as Choice
|
||||
|
||||
from untitled import content
|
||||
|
||||
|
||||
def clear():
|
||||
print("\033[2J\033[H", end="", flush=True)
|
||||
|
||||
|
||||
def typewriter(
|
||||
text,
|
||||
letter_speed=0.15,
|
||||
erase_multiplier=0.35,
|
||||
after_type_delay=1,
|
||||
after_finish_delay=1,
|
||||
erase=True,
|
||||
):
|
||||
for char in text:
|
||||
time.sleep(letter_speed)
|
||||
print(char, end="", flush=True)
|
||||
time.sleep(after_type_delay)
|
||||
if erase:
|
||||
for _ in range(len(text)):
|
||||
print("\b \b", end="", flush=True)
|
||||
time.sleep(letter_speed * erase_multiplier)
|
||||
else:
|
||||
clear()
|
||||
time.sleep(after_finish_delay)
|
||||
|
||||
|
||||
def splash():
|
||||
typewriter(content.STUDIO_NAME)
|
||||
typewriter(content.GAME_NAME, erase=False)
|
||||
|
||||
|
||||
def select(title, options):
|
||||
return questionary.select(title, options).ask()
|
||||
|
||||
|
||||
def text(title, default):
|
||||
return questionary.text(title, default=default).ask()
|
||||
|
||||
|
||||
def confirm(title):
|
||||
return questionary.confirm(title).ask()
|
||||
37
uv.lock
generated
37
uv.lock
generated
@@ -66,6 +66,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prompt-toolkit"
|
||||
version = "3.0.52"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wcwidth" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
@@ -91,6 +103,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "questionary"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "prompt-toolkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.18"
|
||||
@@ -120,6 +144,9 @@ wheels = [
|
||||
name = "untitled-cat-game"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "questionary" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
@@ -129,6 +156,7 @@ dev = [
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "questionary", specifier = ">=2.1.1" }]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
@@ -136,3 +164,12 @@ dev = [
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "ruff", specifier = ">=0.15.18" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.8.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d07cb1ebe6a5b6db417262b9d2568b460347c57d95cc/wcwidth-0.8.1.tar.gz", hash = "sha256:faf5b4a5366a72dc49cad48cdf21f52bdf63bdda995178e483ba247ff79089b9", size = 1466072, upload-time = "2026-06-08T05:57:23.146Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user