first commit

This commit is contained in:
Auric Vente 2024-08-01 22:11:15 -06:00
commit 3c70f140dd
43 changed files with 4230 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
venv/*
.directory
*.pyc
__pycache__/
.mypy_cache/
cromulant.db

18
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,18 @@
repos:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.0
hooks:
- id: mypy
files: "^cromulant/.*"
args: [--strict, --strict, --strict, cromulant/main.py]
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: v0.5.5
hooks:
- id: ruff
name: ruff check
entry: ruff check
files: ^cromulant/
- id: ruff
name: ruff format
entry: ruff format
files: ^cromulant/

1
LICENSE Normal file
View File

@ -0,0 +1 @@
Copyright madprops - All Rights Reserved

118
README.md Normal file
View File

@ -0,0 +1,118 @@
# Cromulant
<img src="cromulant/img/status.jpg" width="200">
[Click here for screenshots](screenshots.md)
## What is this?
This is a kind of toy you can use for your amusement.
It requires minimal interaction, most things happen automatically.
You might want to keep it running in some tiled layout.
## Usage
You start with a set of `25` to `250` random ants (`100` by default).
You can specify this anytime through `Restart`. When you restart everything resets to zero like triumphs and hits.
There are `1000` names available. This is used as the pool of names to select randomly.
Every x minutes or seconds a new update from a random ant appears.
The content of the update depends on a random number.
It can be a triumph, a hit, travel, thought, sentence.
The ant with the highest score is shown in the footer.
Ants get merged and replaced over time.
All of this happens automatically, though you can manually force actions
by using the mouse on the portraits or main menu. Try click and middle click.
Read [Algorithm](#algorithm) for more information about the mechanics.
## Installation
First make sure you have `qt` installed in your system.
In arch you can do this with: `sudo pacman -S qt6-base`
In ubuntu/debian you can do this with: `sudo apt install qt6-base-dev`
### Quick Installation
If you have `pipx` and `linux` installed you can use the following command:
```sh
pipx install git+https://github.com/madprops/cromulant --force
```
### Advanced Installation
1) Clone this repo.
2) python -m venv venv
3) venv/bin/pip install -r requirements.txt
4) Use `run.sh` or `venv/bin/python -m cromulant.main`
5) (Optional) Manually create desktop entries and icons for the application.
## Algorithm <a name="algorithm"></a>
A random ant is picked based on weights (oldest update date weighs more).
More weight means something is more likely to get picked.
Then a random number between 0 and length-of-methods-1 is picked.
For each number an action happens to produce an update.
Words (sentences) have more weight compared to the rest of the update methods.
Some methods roll another number to pick the outcome like in the case of `think`
where there are 3 `think` types, these can also have custom weights.
The top score is calculated on every new update.
The score is calculated as (Triumph - Hits).
If multiple ants have the same score, the oldest one wins.
The ant with the top score is shown in the footer.
The top ant uses a special portrait on updates.
For merge, the words of each name are used.
They get filled with random words if less than 2 words.
One word from each set is picked randomly.
The triumph and hits from each ant get combined for the new ant.
The original ants get terminated and the merged one hatches.
An extra random ant is hatched to fill the gap.
## Storage
The state of ants is stored in `~/.local/share/cromulant/ants.json`
The settings file is stored in `~/.config/cromulant/settings.json`
Or the equivalents in non-linux systems.
There is a command line argument to define a custom location for the ants state file.
This means you can have multiple states to save/load.
There is a command line argument to define a custom location for the names list.
This means you can use this with another set of names.
If not enough names are provided the remaining ants are created with random words.
## The Name
I read the word [cromulent](https://www.merriam-webster.com/wordplay/what-does-cromulent-mean) being used somewhere which turned out to be invented by The Simpsons.
I created a new programming project to practice/study and tried to use that word for the name but made a typo.
I liked the typo and made a game around it.
---
[Command line arguments](arguments.md)
[Click here for more](more.md)

189
arguments.md Normal file
View File

@ -0,0 +1,189 @@
# Arguments
Here are all the available command line arguments:
---
### version
Check the version of the program
Action: version
---
### names
Path to a JSON file with a list of names. Use these instead of the default ones
Type: str
---
### ants
Path to a JSON file with ants data. Use this instead of the default one
Type: str
---
### no-images
Don't show the images on the left
Action: store_false
---
### no-header
Don't show the header controls
Action: store_false
---
### no-footer
Don't show the footer controls
Action: store_false
---
### no-intro
Don't show the intro message
Action: store_false
---
### title
Custom title for the window
Default: [Empty string]
Type: str
---
### width
The width of the window in pixels
Default: 0
Type: int
---
### height
The height of the window in pixels
Default: 0
Type: int
---
### program
The internal name of the program
Default: [Empty string]
Type: str
---
### speed
Use this update speed
Default: [Empty string]
Choices: "fast", "normal", "slow", "paused"
Type: str
---
### clean
Start with clean ants data
Default: False
Action: store_true
---
### fast-seconds
The number of seconds between fast updates
Default: 0
Type: int
---
### normal-minutes
The number of minutes between normal updates
Default: 0.0
Type: float
---
### slow-minutes
The number of minutes between slow updates
Default: 0.0
Type: float
---
### argdoc
Make the arguments document and exit
Default: False
Action: store_true
---
### score
Show the score on triumph or hits instead of the total of each
Default: False
Action: store_true
---
### mono
Use a monospace font
Default: False
Action: store_true
---
### no-fade
Don't apply a fade-in effect on new updates
Action: store_false

0
cromulant/__init__.py Normal file
View File

324
cromulant/ants.py Normal file
View File

@ -0,0 +1,324 @@
from __future__ import annotations
import re
import random
import itertools
from typing import ClassVar, Any
from .config import Config
from .args import Args
from .utils import Utils
from .storage import Storage
class Ant:
def __init__(self) -> None:
now = Utils.now()
self.created = now
self.updated = now
self.name = ""
self.status = ""
self.method = "hatched"
self.triumph = 0
self.hits = 0
def to_dict(self) -> dict[str, Any]:
return {
"created": self.created,
"updated": self.updated,
"name": self.name,
"status": self.status,
"method": self.method,
"hits": self.hits,
"triumph": self.triumph,
}
def from_dict(self, data: dict[str, Any]) -> None:
self.created = data["created"]
self.updated = data["updated"]
self.name = data["name"]
self.status = data["status"]
self.method = data["method"]
self.hits = data["hits"]
self.triumph = data["triumph"]
def get_name(self) -> str:
return self.name or "Nameless"
def get_age(self) -> str:
now = Utils.now()
return Utils.time_ago(self.created, now)
def describe(self) -> None:
Utils.print(f"Name is {self.get_name()}")
Utils.print(f"It hatched {self.get_age()}")
def get_score(self) -> int:
return self.triumph - self.hits
def tooltip(self) -> str:
tooltip = ""
tooltip += f"Updated: {Utils.to_date(self.updated)}"
tooltip += f"\nCreated: {Utils.to_date(self.created)}"
tooltip += f"\nTriumph: {self.triumph} | Hits: {self.hits}"
tooltip += "\nClick to Terminate"
tooltip += "\nMiddle Click to Merge"
return tooltip
def get_status(self) -> str:
from .game import Method
if (not self.status) and (not self.method):
return "No update yet"
status = self.status
if self.method == Method.triumph:
if Args.score:
total = f"(Score: {self.get_score()})"
else:
total = f"({self.triumph} total)"
status = f"{Config.triumph_icon} {Config.triumph_message} {total}"
elif self.method == Method.hit:
if Args.score:
total = f"(Score: {self.get_score()})"
else:
total = f"({self.hits} total)"
status = f"{Config.hit_icon} {Config.hit_message} {total}"
elif self.method == Method.think:
status = f"Thinking about {status}"
elif self.method == Method.travel:
status = f"Traveling to {status}"
return status
class Ants:
ants: ClassVar[list[Ant]] = []
top: ClassVar[Ant | None] = None
@staticmethod
def prepare() -> None:
Ants.get()
Ants.check()
Ants.get_top()
@staticmethod
def check() -> None:
if not Ants.ants:
Ants.populate(Config.default_population)
@staticmethod
def hatch(num: int = 1, ignore: list[str] | None = None) -> None:
from .game import Game
for _ in range(num):
ant = Ant()
ant.name = Ants.random_name(ignore)
Ants.ants.append(ant)
Game.update(ant)
Ants.on_change()
@staticmethod
def on_change() -> None:
from .game import Game
Ants.get_top()
Game.info()
Ants.save()
@staticmethod
def random_ant(ignore: list[Ant] | None = None) -> Ant | None:
if ignore:
ants = [a for a in Ants.ants if a not in ignore]
else:
ants = Ants.ants
return random.choice(ants)
@staticmethod
def get_names() -> list[str]:
return [ant.name for ant in Ants.ants]
@staticmethod
def save() -> None:
Storage.save_ants(Ants.ants)
@staticmethod
def get_next() -> Ant | None:
now = Utils.now()
ages = [(now - ant.updated) for ant in Ants.ants]
# Normalize ages to create weights
total_age = sum(ages)
if total_age == 0:
weights = [1] * len(Ants.ants) # If all ages are zero, use equal weights
else:
weights = [
int((age / total_age) * 1000) for age in ages
] # Scale and cast to int
# Perform weighted random selection
return random.choices(Ants.ants, weights=weights, k=1)[0]
@staticmethod
def get_current() -> Ant | None:
return max(Ants.ants, key=lambda ant: ant.updated)
@staticmethod
def set_status(ant: Ant, status: str, method: str) -> None:
from .game import Game
status = status.strip()
ant.status = status
ant.method = method
ant.updated = Utils.now()
Game.update(ant)
Ants.on_change()
@staticmethod
def get() -> None:
if Args.clean:
objs = []
else:
objs = Storage.get_ants()
for obj in objs:
ant = Ant()
ant.from_dict(obj)
Ants.ants.append(ant)
@staticmethod
def populate(num: int) -> None:
Ants.clear()
Ants.hatch(num)
@staticmethod
def random_name(ignore: list[str] | None = None) -> str:
names = Ants.get_names()
if ignore:
for name in ignore:
if name not in names:
names.append(name)
return Utils.random_name(names)
@staticmethod
def get_top() -> None:
top: Ant | None = None
top_score = 0
for ant in Ants.ants:
score = ant.get_score()
if (not top) or (score > top_score):
top = ant
top_score = score
elif score == top_score:
if ant.created < top.created:
top = ant
if not top:
return
Ants.top = top
@staticmethod
def merge(ant_1: Ant | None = None) -> bool:
from .game import Game
def split(ant: Ant) -> list[str]:
return re.split(r"[ -]", ant.name)
def remove(words: list[str], ignore: list[str]) -> list[str]:
return [word for word in words if word.lower() not in ignore]
def fill(words: list[str]) -> list[str]:
words = remove(words, ["of", "de", "da", "the"])
if len(words) < 2:
n = random.randint(1, 2)
if n == 1:
words = Utils.random_words(2 - len(words))
else:
words = Utils.make_words(2 - len(words))
words.extend(words)
return [Utils.capitalize(word) for word in words]
if not ant_1:
ant_1 = Ants.random_ant()
if not ant_1:
return False
ant_2 = Ants.random_ant([ant_1])
if not ant_2:
return False
words_1 = split(ant_1)
words_2 = split(ant_2)
words_1 = fill(words_1)
words_2 = fill(words_2)
name = ""
names = Ants.get_names()
combinations = list(itertools.product(words_1, words_2))
random.shuffle(combinations)
for combo in combinations:
possible = f"{combo[0]} {combo[1]}"
if (possible == ant_1.name) or (possible == ant_2.name):
continue
if (possible in names) or (possible in Utils.names):
continue
name = possible
break
if not name:
return False
Ants.set_terminated(ant_1)
Ants.set_terminated(ant_2)
ant = Ant()
ant.name = name
ant.triumph = ant_1.triumph + ant_2.triumph
ant.hits = ant_1.hits + ant_2.hits
Ants.ants.append(ant)
Game.update(ant)
Ants.hatch(ignore=[ant_1.name, ant_2.name])
return True
@staticmethod
def clear() -> None:
Ants.ants = []
@staticmethod
def terminate(ant: Ant) -> None:
Ants.set_terminated(ant)
Ants.hatch(ignore=[ant.name])
@staticmethod
def set_terminated(ant: Ant) -> None:
from .game import Game
ant.method = "terminated"
Game.update(ant)
Ants.ants.remove(ant)

216
cromulant/args.py Normal file
View File

@ -0,0 +1,216 @@
from __future__ import annotations
import argparse
from pathlib import Path
from typing import Any
from .config import Config
from .utils import Utils
from .argspec import ArgSpec
class Args:
names: Path | None = None
ants: Path | None = None
images: bool = True
header: bool = True
footer: bool = True
intro: bool = True
title: str = ""
width: int = 0
height: int = 0
program: str = ""
speed: str = ""
clean: bool = False
fast_seconds: int = 0
normal_minutes: float = 0.0
slow_minutes: float = 0.0
argdoc: bool = False
score: bool = False
mono: bool = False
fade: bool = True
@staticmethod
def prepare() -> None:
ArgSpec.prepare()
ArgParser.prepare(Config.title, ArgSpec.arguments)
for attr_name, attr_value in vars(Args).items():
ArgSpec.defaults[attr_name] = attr_value
other_name = [
("no_images", "images"),
("no_header", "header"),
("no_footer", "footer"),
("no_intro", "intro"),
("no_fade", "fade"),
]
for r_item in other_name:
ArgParser.get_value(*r_item)
normals = [
"title",
"width",
"height",
"program",
"speed",
"clean",
"fast_seconds",
"normal_minutes",
"slow_minutes",
"argdoc",
"score",
"mono",
]
for n_item in normals:
ArgParser.get_value(n_item)
paths = [
"names",
"ants",
]
for p_item in paths:
ArgParser.get_value(p_item, path=True)
@staticmethod
def make_argdoc() -> None:
from .utils import Utils
from .storage import Storage
text = Args.argtext()
Storage.save_arguments(text)
Utils.print("Saved arguments document")
@staticmethod
def argtext(filter_text: str | None = None) -> str:
sep = "\n\n---\n\n"
text = ""
filter_lower = ""
if not filter_text:
text = "# Arguments\n\n"
text += "Here are all the available command line arguments:"
else:
filter_lower = filter_text.lower()
for key in ArgSpec.arguments:
if key == "string_arg":
continue
arg = ArgSpec.arguments[key]
info = arg.get("help", "")
if filter_text:
if filter_lower not in key.lower():
if filter_lower not in info.lower():
continue
text += sep
name = key.replace("_", "-")
text += f"### {name}"
if info:
text += "\n\n"
text += info
defvalue = ArgSpec.defaults.get(key)
if defvalue is not None:
if isinstance(defvalue, str):
if defvalue == "":
defvalue = "[Empty string]"
elif defvalue.strip() == "":
spaces = defvalue.count(" ")
ds = Utils.singular_or_plural(spaces, "space", "spaces")
defvalue = f"[{spaces} {ds}]"
else:
defvalue = f'"{defvalue}"'
text += "\n\n"
text += f"Default: {defvalue}"
choices = arg.get("choices", [])
if choices:
text += "\n\n"
text += "Choices: "
choicestr = [
f'"{choice}"' if isinstance(choice, str) else choice
for choice in choices
]
text += ", ".join(choicestr)
action = arg.get("action", "")
if action:
text += "\n\n"
text += f"Action: {action}"
argtype = arg.get("type", "")
if argtype:
text += "\n\n"
text += f"Type: {argtype.__name__}"
text += "\n"
return text.lstrip()
class ArgParser:
parser: argparse.ArgumentParser
args: argparse.Namespace
@staticmethod
def prepare(title: str, argdefs: dict[str, Any]) -> None:
parser = argparse.ArgumentParser(description=title)
argdefs["string_arg"] = {"nargs": "*"}
for key in argdefs:
item = argdefs[key]
if key == "string_arg":
name = key
else:
name = ArgParser.under_to_dash(key)
name = f"--{name}"
tail = {key: value for key, value in item.items() if value is not None}
parser.add_argument(name, **tail)
ArgParser.parser = parser
ArgParser.args = parser.parse_args()
@staticmethod
def string_arg() -> str:
return " ".join(ArgParser.args.string_arg)
@staticmethod
def get_value(
attr: str, key: str | None = None, no_strip: bool = False, path: bool = False
) -> None:
value = getattr(ArgParser.args, attr)
if value is not None:
if not no_strip:
if isinstance(value, str):
value = value.strip()
obj = key if key else attr
if path:
value = Path(value)
ArgParser.set(obj, value)
@staticmethod
def set(attr: str, value: Any) -> None:
setattr(Args, attr, value)
@staticmethod
def under_to_dash(s: str) -> str:
return s.replace("_", "-")

186
cromulant/argspec.py Normal file
View File

@ -0,0 +1,186 @@
from __future__ import annotations
from typing import Any
from .config import Config
class DuplicateArgumentError(Exception):
def __init__(self, key: str) -> None:
self.message = f"Duplicate argument: {key}"
def __str__(self) -> str:
return self.message
class MissingInfoError(Exception):
def __init__(self, key: str) -> None:
self.message = f"Missing info for argument: {key}"
def __str__(self) -> str:
return self.message
class DuplicateInfoError(Exception):
def __init__(self, key: str) -> None:
self.message = f"Duplicate info for argument: {key}"
def __str__(self) -> str:
return self.message
class ArgSpec:
vinfo: str
defaults: dict[str, Any]
arguments: dict[str, Any]
infos: list[str]
@staticmethod
def prepare() -> None:
ArgSpec.vinfo = f"{Config.title} {Config.version}"
ArgSpec.defaults = {}
ArgSpec.arguments = {}
ArgSpec.infos = []
ArgSpec.add_arguments()
@staticmethod
def add_argument(key: str, info: str, **kwargs: Any) -> None:
if key in ArgSpec.arguments:
raise DuplicateArgumentError(key)
if not info:
raise MissingInfoError(key)
if info in ArgSpec.infos:
raise DuplicateInfoError(key)
ArgSpec.arguments[key] = {
"help": info,
**kwargs,
}
ArgSpec.infos.append(info)
@staticmethod
def add_arguments() -> None:
ArgSpec.add_argument(
"version",
action="version",
info="Check the version of the program",
version=ArgSpec.vinfo,
)
ArgSpec.add_argument(
"names",
type=str,
info="Path to a JSON file with a list of names. Use these instead of the default ones",
)
ArgSpec.add_argument(
"ants",
type=str,
info="Path to a JSON file with ants data. Use this instead of the default one",
)
ArgSpec.add_argument(
"no_images",
action="store_false",
info="Don't show the images on the left",
)
ArgSpec.add_argument(
"no_header",
action="store_false",
info="Don't show the header controls",
)
ArgSpec.add_argument(
"no_footer",
action="store_false",
info="Don't show the footer controls",
)
ArgSpec.add_argument(
"no_intro",
action="store_false",
info="Don't show the intro message",
)
ArgSpec.add_argument(
"title",
type=str,
info="Custom title for the window",
)
ArgSpec.add_argument(
"width",
type=int,
info="The width of the window in pixels",
)
ArgSpec.add_argument(
"height",
type=int,
info="The height of the window in pixels",
)
ArgSpec.add_argument(
"program",
type=str,
info="The internal name of the program",
)
ArgSpec.add_argument(
"speed",
type=str,
choices=["fast", "normal", "slow", "paused"],
info="Use this update speed",
)
ArgSpec.add_argument(
"clean",
action="store_true",
info="Start with clean ants data",
)
ArgSpec.add_argument(
"fast_seconds",
type=int,
info="The number of seconds between fast updates",
)
ArgSpec.add_argument(
"normal_minutes",
type=float,
info="The number of minutes between normal updates",
)
ArgSpec.add_argument(
"slow_minutes",
type=float,
info="The number of minutes between slow updates",
)
ArgSpec.add_argument(
"argdoc",
action="store_true",
info="Make the arguments document and exit",
)
ArgSpec.add_argument(
"score",
action="store_true",
info="Show the score on triumph or hits instead of the total of each",
)
ArgSpec.add_argument(
"mono",
action="store_true",
info="Use a monospace font",
)
ArgSpec.add_argument(
"no_fade",
action="store_false",
info="Don't apply a fade-in effect on new updates",
)

Binary file not shown.

105
cromulant/config.py Normal file
View File

@ -0,0 +1,105 @@
from __future__ import annotations
from pathlib import Path
import appdirs # type: ignore
class Config:
program: str
title: str
version: str
width: int = 820
height: int = 900
here: Path
ants_json: Path
icon_path: Path
status_image_path: Path
hatched_image_path: Path
top_image_path: Path
terminated_image_path: Path
names_json: Path
background_color: str = "rgb(44, 44, 44)"
text_color: str = "#ffffff"
image_size: int = 80
space_1: int = 18
max_updates: int = 300
fast_seconds: int = 5
normal_minutes: float = 1
slow_minutes: float = 5
font_size: int = 20
info_separator: str = " - "
font_path: Path
emoji_font_path: Path
mono_font_path: Path
triumph_color: tuple[int, int, int] = (255, 255, 0)
hit_color: tuple[int, int, int] = (255, 0, 77)
triumph_icon: str = "😀"
hit_icon: str = "🎃"
triumph_message: str = "Scored a triumph"
hit_message: str = "Took a hit"
song_path: Path
logo_path: Path
alt_background_color: str = "rgb(33, 33, 33)"
alt_text_color: str = "white"
alt_hover_background_color: str = "rgb(51, 51, 51)"
alt_hover_text_color: str = "white"
alt_border_color: str = "rgb(88, 88, 88)"
message_box_button_hover_background_color: str = "rgb(66, 66, 66)"
message_box_button_hover_text_color: str = "white"
scrollbar_handle_color: str = "rgb(69, 69, 69)"
input_background_color: str = "rgb(111, 111, 111)"
input_text_color: str = "black"
input_border_color: str = "rgb(120, 120, 120)"
input_caret_color: str = "rgb(18, 18, 18)"
settings_json: Path
countries_json: Path
filter_debouncer_delay: int = 200
default_population: int = 100
merge_goal: int = 10
manifest_path: Path
manifest: dict[str, str]
icon_on: str = ""
icon_off: str = ""
ant: str = "🐜"
arguments_path: Path
fade_duration: int = 500
@staticmethod
def prepare() -> None:
from .storage import Storage
Config.here = Path(__file__).parent
Config.manifest_path = Config.here / "manifest.json"
Config.manifest = Storage.get_manifest()
Config.title = Config.manifest["title"]
Config.program = Config.manifest["program"]
Config.version = Config.manifest["version"]
Config.ants_json = Path(appdirs.user_data_dir()) / Config.program / "ants.json"
if not Config.ants_json.exists():
Config.ants_json.parent.mkdir(parents=True, exist_ok=True)
Config.ants_json.write_text("[]")
Config.settings_json = (
Path(appdirs.user_config_dir()) / Config.program / "settings.json"
)
if not Config.settings_json.exists():
Config.settings_json.parent.mkdir(parents=True, exist_ok=True)
Config.settings_json.write_text("{}")
Config.names_json = Config.here / "data" / "names.json"
Config.countries_json = Config.here / "data" / "countries.json"
Config.icon_path = Config.here / "img" / "icon.jpg"
Config.status_image_path = Config.here / "img" / "status.jpg"
Config.hatched_image_path = Config.here / "img" / "hatched.jpg"
Config.top_image_path = Config.here / "img" / "top.jpg"
Config.terminated_image_path = Config.here / "img" / "terminated.jpg"
Config.font_path = Config.here / "fonts" / "NotoSans-Regular.ttf"
Config.emoji_font_path = Config.here / "fonts" / "NotoEmoji-Regular.ttf"
Config.mono_font_path = Config.here / "fonts" / "NotoSansMono-Regular.ttf"
Config.song_path = Config.here / "audio" / "March of the Cyber Ants.mp3"
Config.logo_path = Config.here / "img" / "logo_3.jpg"
Config.arguments_path = Config.here / ".." / "arguments.md"

View File

@ -0,0 +1,250 @@
[
"Afghanistan",
"Albania",
"Algeria",
"American Samoa",
"Andorra",
"Angola",
"Anguilla",
"Antarctica",
"Antigua and Barbuda",
"Argentina",
"Armenia",
"Aruba",
"Australia",
"Austria",
"Azerbaijan",
"Bahamas",
"Bahrain",
"Bangladesh",
"Barbados",
"Belarus",
"Belgium",
"Belize",
"Benin",
"Bermuda",
"Bhutan",
"Bolivia",
"Bosnia and Herzegovina",
"Botswana",
"Bouvet Island",
"Brazil",
"British Indian Ocean Territory",
"Brunei",
"Bulgaria",
"Burkina Faso",
"Burundi",
"Cambodia",
"Cameroon",
"Canada",
"Cape Verde",
"Cayman Islands",
"Central African Republic",
"Chad",
"Chile",
"China",
"Christmas Island",
"Cocos (Keeling) Islands",
"Colombia",
"Comoros",
"Congo",
"The Democratic Republic of Congo",
"Cook Islands",
"Costa Rica",
"Ivory Coast",
"Croatia",
"Cuba",
"Cyprus",
"Czech Republic",
"Denmark",
"Djibouti",
"Dominica",
"Dominican Republic",
"East Timor",
"Ecuador",
"Egypt",
"England",
"El Salvador",
"Equatorial Guinea",
"Eritrea",
"Estonia",
"Eswatini",
"Ethiopia",
"Falkland Islands",
"Faroe Islands",
"Fiji Islands",
"Finland",
"France",
"French Guiana",
"French Polynesia",
"French Southern territories",
"Gabon",
"Gambia",
"Georgia",
"Germany",
"Ghana",
"Gibraltar",
"Greece",
"Greenland",
"Grenada",
"Guadeloupe",
"Guam",
"Guatemala",
"Guernsey",
"Guinea",
"Guinea-Bissau",
"Guyana",
"Haiti",
"Heard Island and McDonald Islands",
"Holy See (Vatican City State)",
"Honduras",
"Hong Kong",
"Hungary",
"Iceland",
"India",
"Indonesia",
"Iran",
"Iraq",
"Ireland",
"Israel",
"Isle of Man",
"Italy",
"Jamaica",
"Japan",
"Jersey",
"Jordan",
"Kazakhstan",
"Kenya",
"Kiribati",
"Kuwait",
"Kyrgyzstan",
"Laos",
"Latvia",
"Lebanon",
"Lesotho",
"Liberia",
"Libya",
"Liechtenstein",
"Lithuania",
"Luxembourg",
"Macao",
"North Macedonia",
"Madagascar",
"Malawi",
"Malaysia",
"Maldives",
"Mali",
"Malta",
"Marshall Islands",
"Martinique",
"Mauritania",
"Mauritius",
"Mayotte",
"Mexico",
"Micronesia, Federated States of",
"Moldova",
"Monaco",
"Mongolia",
"Montserrat",
"Montenegro",
"Morocco",
"Mozambique",
"Myanmar",
"Namibia",
"Nauru",
"Nepal",
"Netherlands",
"Netherlands Antilles",
"New Caledonia",
"New Zealand",
"Nicaragua",
"Niger",
"Nigeria",
"Niue",
"Norfolk Island",
"North Korea",
"Northern Ireland",
"Northern Mariana Islands",
"Norway",
"Oman",
"Pakistan",
"Palau",
"Palestine",
"Panama",
"Papua New Guinea",
"Paraguay",
"Peru",
"Philippines",
"Pitcairn",
"Poland",
"Portugal",
"Puerto Rico",
"Qatar",
"Reunion",
"Romania",
"Russia",
"Rwanda",
"Saint Helena",
"Saint Kitts and Nevis",
"Saint Lucia",
"Saint Pierre and Miquelon",
"Saint Vincent and the Grenadines",
"Samoa",
"San Marino",
"Sao Tome and Principe",
"Saudi Arabia",
"Scotland",
"Senegal",
"Serbia",
"Seychelles",
"Sierra Leone",
"Singapore",
"Slovakia",
"Slovenia",
"Solomon Islands",
"Somalia",
"South Africa",
"South Georgia and the South Sandwich Islands",
"South Korea",
"South Sudan",
"Spain",
"Sri Lanka",
"Sudan",
"Suriname",
"Svalbard and Jan Mayen",
"Sweden",
"Switzerland",
"Syria",
"Tajikistan",
"Tanzania",
"Thailand",
"Timor-Leste",
"Togo",
"Tokelau",
"Tonga",
"Trinidad and Tobago",
"Tunisia",
"Turkey",
"Turkmenistan",
"Turks and Caicos Islands",
"Tuvalu",
"Uganda",
"Ukraine",
"United Arab Emirates",
"United Kingdom",
"United States",
"United States Minor Outlying Islands",
"Uruguay",
"Uzbekistan",
"Vanuatu",
"Venezuela",
"Vietnam",
"Virgin Islands, British",
"Virgin Islands, U.S.",
"Wales",
"Wallis and Futuna",
"Western Sahara",
"Yemen",
"Zambia",
"Zimbabwe"
]

1002
cromulant/data/names.json Normal file

File diff suppressed because it is too large Load Diff

99
cromulant/filter.py Normal file
View File

@ -0,0 +1,99 @@
from __future__ import annotations
from PySide6.QtWidgets import QWidget # type: ignore
from PySide6.QtGui import QKeyEvent # type: ignore
from PySide6.QtCore import QTimer # type: ignore
from .config import Config
from .window import Window
class Filter:
debouncer: QTimer
@staticmethod
def prepare() -> None:
Filter.debouncer = QTimer()
Filter.debouncer.setSingleShot(True)
Filter.debouncer.setInterval(Config.filter_debouncer_delay)
Filter.debouncer.timeout.connect(Filter.do_filter)
@staticmethod
def get_value() -> str:
return str(Window.filter.text()).lower().strip()
@staticmethod
def set_value(value: str) -> None:
Window.filter.setText(value)
Filter.do_filter()
@staticmethod
def clear() -> None:
Window.filter.clear()
Filter.do_filter()
@staticmethod
def filter(event: QKeyEvent | None = None) -> None:
Filter.debouncer.stop()
Filter.debouncer.start()
@staticmethod
def do_filter() -> None:
Filter.debouncer.stop()
value = Filter.get_value()
for i in range(Window.view.count()):
item = Window.view.itemAt(i)
text = Filter.get_text(item)
hide = True
for txt in text:
if value in txt:
hide = False
break
if hide:
item.widget().hide()
else:
item.widget().show()
@staticmethod
def get_text(item: QWidget) -> list[str]:
text = []
layout = item.widget().layout()
for i in range(layout.count()):
widget = layout.itemAt(i).widget()
if not widget:
continue
name = widget.objectName()
if name != "view_right":
continue
layout2 = widget.layout()
for j in range(layout2.count()):
wid = layout2.itemAt(j).widget()
if not wid:
continue
name = wid.objectName()
if not name:
continue
if (name == "view_title") or (name == "view_message"):
text.append(wid.text().lower())
return text
@staticmethod
def check() -> None:
value = Filter.get_value()
if value:
Filter.filter()

Binary file not shown.

Binary file not shown.

Binary file not shown.

605
cromulant/game.py Normal file
View File

@ -0,0 +1,605 @@
from __future__ import annotations
import random
from typing import Any, ClassVar
from PySide6.QtWidgets import QHBoxLayout # type: ignore
from PySide6.QtWidgets import QVBoxLayout
from PySide6.QtWidgets import QLabel
from PySide6.QtWidgets import QWidget
from PySide6.QtWidgets import QFrame
from PySide6.QtWidgets import QMenu
from PySide6.QtWidgets import QDialog
from PySide6.QtWidgets import QGraphicsOpacityEffect
from PySide6.QtGui import QCursor # type: ignore
from PySide6.QtGui import QMouseEvent
from PySide6.QtGui import QPixmap
from PySide6.QtGui import QAction
from PySide6.QtCore import QPropertyAnimation # type: ignore
from PySide6.QtCore import QEasingCurve
from PySide6.QtCore import QSize
from PySide6.QtCore import QTimer
from PySide6.QtCore import Qt
from .config import Config
from .args import Args
from .utils import Utils
from .ants import Ant
from .ants import Ants
from .window import Window
from .window import RestartDialog
from .settings import Settings
class Method:
merge = "merge"
triumph = "triumph"
hit = "hit"
travel = "travel"
think = "think"
words = "words"
class Opt:
value = 0
def __init__(self, weight: int, method: str) -> None:
self.value = Opt.value
self.weight = weight
self.method = method
Opt.value += 1
class Opts:
merge = Opt(1, Method.merge)
triumph = Opt(2, Method.triumph)
hit = Opt(2, Method.hit)
travel = Opt(2, Method.travel)
think = Opt(2, Method.think)
words = Opt(4, Method.words)
@staticmethod
def opts_score() -> list[Opt]:
return [Opts.triumph, Opts.hit]
@staticmethod
def opts_travel() -> list[Opt]:
return [Opts.travel]
@staticmethod
def opts_think() -> list[Opt]:
return [Opts.think]
@staticmethod
def opts_words() -> list[Opt]:
return [Opts.words]
class Game:
timer: QTimer
playing_song: bool = False
merge_charge: int = 0
speed: str = "paused"
animations: ClassVar[list[QPropertyAnimation]] = []
started: bool = False
@staticmethod
def prepare() -> None:
Game.timer = QTimer()
Game.timer.timeout.connect(Game.get_status)
Game.fill()
Game.info()
if Args.intro:
Game.intro()
@staticmethod
def update(ant: Ant) -> None:
root = QWidget()
container = QHBoxLayout()
root.setContentsMargins(0, 0, 0, 0)
container.setContentsMargins(0, 0, 0, 0)
if Args.images:
image_label = Game.get_image(ant)
container.addWidget(image_label)
right_container = Game.make_right_container(ant)
container.addWidget(right_container)
container.addSpacing(Config.space_1)
root.setLayout(container)
Game.add_item(root)
@staticmethod
def message(text: str) -> None:
root = QWidget()
root.setContentsMargins(0, 10, 0, 10)
container = QHBoxLayout()
container.setAlignment(Qt.AlignCenter)
left_line = QFrame()
left_line.setFrameShape(QFrame.HLine)
left_line.setFrameShadow(QFrame.Sunken)
left_line.setObjectName("horizontal_line")
left_line.setFixedHeight(2)
Window.expand_2(left_line)
label = QLabel(text)
label.setTextInteractionFlags(Qt.TextSelectableByMouse)
right_line = QFrame()
right_line.setFrameShape(QFrame.HLine)
right_line.setFrameShadow(QFrame.Sunken)
right_line.setObjectName("horizontal_line")
right_line.setFixedHeight(2)
Window.expand_2(right_line)
container.addWidget(left_line)
container.addWidget(label)
container.addWidget(right_line)
container.setSpacing(Config.space_1 * 2)
root.setLayout(container)
Game.add_item(root)
@staticmethod
def add_item(item: QWidget) -> None:
from .filter import Filter
animation: QPropertyAnimation | None = None
if Game.started and Args.fade:
animation = Game.add_fade(item)
Window.view.insertWidget(0, item)
if animation:
animation.start()
while Window.view.count() > Config.max_updates:
item = Window.view.takeAt(Window.view.count() - 1)
if item.widget():
item.widget().deleteLater()
elif item.layout():
Window.delete_layout(item.layout())
Filter.check()
@staticmethod
def make_right_container(ant: Ant) -> QWidget:
if ant.method == "hatched":
title = "Hatched"
message = f"{ant.name} is born"
elif ant.method == "terminated":
title = "Terminated"
message = f"{ant.name} is gone"
else:
title = ant.name
message = ant.get_status()
root = QWidget()
root.setObjectName("view_right")
container = QVBoxLayout()
container.setAlignment(Qt.AlignTop)
title_label = QLabel(title)
title_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
title_label.setStyleSheet("font-weight: bold;")
title_label.setWordWrap(True)
title_label.setObjectName("view_title")
Window.expand(title_label)
message_label = QLabel(message)
message_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
message_label.setWordWrap(True)
message_label.setObjectName("view_message")
Window.expand(message_label)
container.addWidget(title_label)
container.addWidget(message_label)
root.setLayout(container)
return root
@staticmethod
def get_image(ant: Ant) -> QLabel:
if ant.method == "hatched":
path = Config.hatched_image_path
elif ant.method == "terminated":
path = Config.terminated_image_path
elif ant == Ants.top:
path = Config.top_image_path
else:
path = Config.status_image_path
if ant.method == "triumph":
color = Config.triumph_color
elif ant.method == "hit":
color = Config.hit_color
else:
color = None
tooltip = ant.tooltip()
image_label = QLabel()
image_label.setObjectName("view_image")
pixmap = QPixmap(str(path))
scaled_pixmap = pixmap.scaled(
Config.image_size,
pixmap.height(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
image_label.setPixmap(scaled_pixmap)
adjusted_size = scaled_pixmap.size() + QSize(4, 4)
image_label.setFixedSize(adjusted_size)
if color:
rgb = Utils.get_rgb(color)
style = f"""
QLabel#view_image {{
border: 2px solid {rgb};
}}
"""
image_label.setStyleSheet(style)
if tooltip:
image_label.setToolTip(tooltip)
image_label.mousePressEvent = lambda event: Game.image_action(event, ant)
return image_label
@staticmethod
def get_status() -> None:
ant = Ants.get_next()
if not ant:
return
opts: list[Opt] = []
if Settings.score_enabled:
opts.extend(Opts.opts_score())
if Settings.travel_enabled:
opts.extend(Opts.opts_travel())
if Settings.think_enabled:
opts.extend(Opts.opts_think())
if Settings.words_enabled:
opts.extend(Opts.opts_words())
if not opts:
return
values = [opt.value for opt in opts]
weights = [opt.weight for opt in opts]
if Game.merge_charge < Config.merge_goal:
Game.merge_charge += 1
if Settings.merge:
if Game.merge_charge >= Config.merge_goal:
opt = Opts.merge
values.insert(0, opt.value)
weights.insert(0, opt.weight)
value = random.choices(values, weights=weights, k=1)[0]
if value == Opts.merge.value:
if Ants.merge():
Game.merge_charge = 0
return
value = Opts.words.value
status = ""
method = ""
if value == Opts.triumph.value:
ant.triumph += 1
method = Opts.triumph.method
elif value == Opts.hit.value:
ant.hits += 1
method = Opts.hit.method
elif value == Opts.travel.value:
status = Utils.random_country([])
method = Opts.travel.method
elif value == Opts.think.value:
method = Opts.think.method
n = random.choices([1, 2, 3], weights=[1, 2, 2])[0]
if n == 1:
status = Utils.random_name([], Ants.get_names())
elif n == 2:
status = Utils.random_emoji(3)
elif n == 3:
status = Utils.random_word(noun=True, adj=False)
elif value == Opts.words.value:
method = Opts.words.method
n = random.randint(1, 4)
if n == 1:
status = Utils.words_1()
elif n == 2:
status = Utils.words_2()
elif n == 3:
status = Utils.words_3()
elif n == 4:
status = Utils.words_4()
else:
status = "???"
method = "unknown"
Ants.set_status(ant, status, method)
@staticmethod
def fill() -> None:
if not len(Ants.ants):
return
ants = sorted(Ants.ants, key=lambda ant: ant.updated)
for ant in ants:
Game.update(ant)
@staticmethod
def start_loop() -> None:
Game.timer.stop()
speed = Settings.speed
if speed == "fast":
minutes = (Args.fast_seconds or Config.fast_seconds) / 60
elif speed == "normal":
minutes = Args.normal_minutes or Config.normal_minutes
elif speed == "slow":
minutes = Args.slow_minutes or Config.slow_minutes
else:
Game.speed = "paused"
return
Game.speed = speed
msecs = minutes * 60 * 1000
if msecs < 1000:
msecs = 1000
Game.timer.setInterval(msecs)
Game.timer.start()
@staticmethod
def update_speed() -> None:
speed = Window.speed.currentText().lower()
if speed == Settings.speed:
return
Settings.set_speed(speed)
Game.start_loop()
@staticmethod
def info() -> None:
text = []
# Non-breaking space
nb = "\u00a0"
if not len(Ants.ants):
text.append("Hatch some ants")
else:
text.append(f"Ants:{nb}{len(Ants.ants)}")
top = Ants.top
if top:
score = top.get_score()
text.append(f"Top:{nb}{top.name} ({score})")
Window.info.setText(Config.info_separator.join(text))
@staticmethod
def toggle_song() -> None:
if Game.playing_song:
Window.stop_audio()
Game.playing_song = False
else:
path = str(Config.song_path)
def on_stop() -> None:
Game.playing_song = False
Window.play_audio(path, on_stop)
Game.playing_song = True
@staticmethod
def restart() -> None:
sizes = ["25", "50", "100", "250"]
defindex = 0
for i, opt in enumerate(sizes):
if int(opt) == Config.default_population:
defindex = i
break
size_opts = [f"{opt} ants" for opt in sizes]
dialog = RestartDialog(size_opts, defindex)
data: dict[str, Any] | None = None
if dialog.exec() == QDialog.Accepted:
data = dialog.get_data()
if not data:
return
size = int(data["size"].split(" ")[0])
Game.started = False
Game.timer.stop()
Window.clear_view()
Ants.populate(size)
Window.to_top()
Game.intro()
Game.start_loop()
Game.started = True
@staticmethod
def update_size() -> None:
pass
@staticmethod
def image_action(event: QMouseEvent, ant: Ant) -> None:
def is_terminated() -> bool:
return ant.method == "terminated"
if event.button() == Qt.LeftButton:
if is_terminated():
return
Ants.terminate(ant)
Game.start_loop()
elif event.button() == Qt.MiddleButton:
if is_terminated():
return
Ants.merge(ant)
Game.start_loop()
else:
Game.toggle_song()
@staticmethod
def intro() -> None:
title = Config.title
version = Config.version
Game.message(f"Welcome to {title} v{version}")
@staticmethod
def menu() -> None:
menu = QMenu(Window.root.widget())
style = f"""
QMenu::separator {{
background-color: {Config.alt_border_color};
}}
"""
menu.setStyleSheet(style)
menu.setObjectName("main_menu")
update = QAction("Update")
restart = QAction("Restart")
enable_all = QAction("Enable All")
disable_all = QAction("Disable All")
about = QAction("About")
def make(text: str, enabled: bool) -> QAction:
if enabled:
icon = Config.icon_on
word = "On"
else:
icon = Config.icon_off
word = "Off"
return QAction(f"{icon} {text} {word}")
merge = make("Merge", Settings.merge)
score = make("Score", Settings.score_enabled)
travel = make("Travel", Settings.travel_enabled)
think = make("Think", Settings.think_enabled)
words = make("Words", Settings.words_enabled)
update.triggered.connect(Game.force_update)
restart.triggered.connect(Game.restart)
merge.triggered.connect(Settings.toggle_merge)
score.triggered.connect(Settings.toggle_score_enabled)
travel.triggered.connect(Settings.toggle_travel_enabled)
think.triggered.connect(Settings.toggle_think_enabled)
words.triggered.connect(Settings.toggle_words_enabled)
enable_all.triggered.connect(Settings.enable_all)
disable_all.triggered.connect(Settings.disable_all)
about.triggered.connect(Game.about)
menu.addAction(update)
menu.addAction(restart)
menu.addSeparator()
menu.addAction(merge)
menu.addAction(score)
menu.addAction(travel)
menu.addAction(think)
menu.addAction(words)
menu.addSeparator()
menu.addAction(enable_all)
menu.addAction(disable_all)
menu.addSeparator()
menu.addAction(about)
menu.exec_(QCursor.pos())
@staticmethod
def force_update() -> None:
Game.get_status()
Game.start_loop()
@staticmethod
def about() -> None:
lines = [
f"{Config.title} v{Config.version} {Config.ant}",
"Listen to the ants and watch them go.",
"Just run it and leave it open on your screen.",
]
Window.alert("\n\n".join(lines))
@staticmethod
def slowdown() -> None:
if Game.speed == "slow":
Game.change_speed("paused")
else:
Game.change_speed("slow")
@staticmethod
def change_speed(speed: str) -> None:
Window.speed.setCurrentText(speed.capitalize())
@staticmethod
def filter_top() -> None:
from .filter import Filter
value = Filter.get_value()
ant = Ants.top
if not ant:
return
if value == ant.name.lower():
Filter.clear()
else:
Filter.set_value(ant.name)
@staticmethod
def add_fade(item: QWidget) -> QPropertyAnimation:
opacity = QGraphicsOpacityEffect(item)
item.setGraphicsEffect(opacity)
animation = QPropertyAnimation(opacity, b"opacity")
animation.setDuration(Config.fade_duration)
animation.setStartValue(0)
animation.setEndValue(1)
animation.setEasingCurve(QEasingCurve.InOutQuad)
def on_finish() -> None:
item.setGraphicsEffect(None)
Game.animations.remove(animation)
animation.finished.connect(on_finish)
Game.animations.append(animation)
return animation

BIN
cromulant/img/change.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

3
cromulant/img/green.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
convert $1 -channel G -evaluate add 16% +channel -quality 85 -strip $1_green

BIN
cromulant/img/hatched.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

BIN
cromulant/img/icon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

BIN
cromulant/img/logo_1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

BIN
cromulant/img/logo_2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

BIN
cromulant/img/logo_3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

BIN
cromulant/img/logo_4.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

3
cromulant/img/red.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
convert $1 -channel R -evaluate add 16% +channel -quality 85 -strip $1_red

BIN
cromulant/img/status.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

BIN
cromulant/img/top.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 415 KiB

56
cromulant/main.py Normal file
View File

@ -0,0 +1,56 @@
from __future__ import annotations
import os
import sys
import fcntl
import tempfile
from pathlib import Path
from .config import Config
from .utils import Utils
from .ants import Ants
from .window import Window
from .game import Game
from .settings import Settings
from .filter import Filter
from .args import Args
def main() -> None:
Config.prepare()
Args.prepare()
if Args.argdoc:
Args.make_argdoc()
sys.exit(0)
program = Config.program
title = Config.title
pid = f"{program}.pid"
pid_file = Path(tempfile.gettempdir(), pid)
fp = pid_file.open("w", encoding="utf-8")
try:
fcntl.lockf(fp, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
Utils.print(f"{title} is already running.")
sys.exit(0)
# Create singleton
fp.write(str(os.getpid()))
fp.flush()
Utils.prepare()
Window.prepare()
Ants.prepare()
Settings.prepare()
Filter.prepare()
Game.prepare()
Game.start_loop()
Game.started = True
Window.start()
if __name__ == "__main__":
main()

8
cromulant/manifest.json Normal file
View File

@ -0,0 +1,8 @@
{
"version": "4.2.0",
"title": "Cromulant",
"program": "cromulant",
"author": "madprops",
"repo": "github.com/madprops/cromulant",
"description": "Toy game about ants"
}

47
cromulant/ruff.toml Normal file
View File

@ -0,0 +1,47 @@
[lint]
select = [
"T",
"Q",
"W",
"B",
"N",
"F",
"FA",
"RET",
"PTH",
"ERA",
"PLW",
"PERF",
"RUF",
"FLY",
"PT",
"PYI",
"PIE",
"ICN",
"UP",
"TRY",
"C4",
"E401",
"E713",
"E721",
"S101",
"S113",
"SIM103",
"SIM114",
"SIM118",
"SIM210",
"PLR5501",
"PLR1711",
]
ignore = [
"W292",
"N802",
"N815",
]
exclude = [
"pyperclip.py",
"tests.py",
]

101
cromulant/settings.py Normal file
View File

@ -0,0 +1,101 @@
from __future__ import annotations
from .args import Args
from .window import Window
from .storage import Storage
class Settings:
speed: str
mode: str
merge: bool
score_enabled: bool
travel_enabled: bool
think_enabled: bool
words_enabled: bool
@staticmethod
def prepare() -> None:
settings = Storage.get_settings()
changed = False
if Args.speed:
Settings.speed = Args.speed
changed = True
else:
Settings.speed = settings.get("speed", "normal")
speed = Settings.speed.capitalize()
Window.speed.setCurrentText(speed)
Settings.score_enabled = settings.get("score_enabled", True)
Settings.travel_enabled = settings.get("travel_enabled", True)
Settings.think_enabled = settings.get("think_enabled", True)
Settings.words_enabled = settings.get("words_enabled", True)
Settings.merge = settings.get("merge", True)
if changed:
Settings.save()
@staticmethod
def save() -> None:
settings = {
"speed": Settings.speed,
"merge": Settings.merge,
"score_enabled": Settings.score_enabled,
"travel_enabled": Settings.travel_enabled,
"think_enabled": Settings.think_enabled,
"words_enabled": Settings.words_enabled,
}
Storage.save_settings(settings)
@staticmethod
def set_speed(speed: str) -> None:
Settings.speed = speed
Settings.save()
@staticmethod
def toggle_merge() -> None:
Settings.merge = not Settings.merge
Settings.save()
@staticmethod
def toggle_score_enabled() -> None:
Settings.score_enabled = not Settings.score_enabled
Settings.save()
@staticmethod
def toggle_travel_enabled() -> None:
Settings.travel_enabled = not Settings.travel_enabled
Settings.save()
@staticmethod
def toggle_think_enabled() -> None:
Settings.think_enabled = not Settings.think_enabled
Settings.save()
@staticmethod
def toggle_words_enabled() -> None:
Settings.words_enabled = not Settings.words_enabled
Settings.save()
@staticmethod
def enable_all() -> None:
Settings.merge = True
Settings.score_enabled = True
Settings.travel_enabled = True
Settings.think_enabled = True
Settings.words_enabled = True
Settings.save()
@staticmethod
def disable_all() -> None:
Settings.merge = False
Settings.score_enabled = False
Settings.travel_enabled = False
Settings.think_enabled = False
Settings.words_enabled = False
Settings.save()

90
cromulant/storage.py Normal file
View File

@ -0,0 +1,90 @@
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from pathlib import Path
from .config import Config
if TYPE_CHECKING:
from .ants import Ant
from .args import Args
from .utils import Utils
class Storage:
@staticmethod
def get_names_path() -> Path:
path = Config.names_json
if Args.names:
if Args.names.exists():
path = Args.names
return path
@staticmethod
def get_ants_path() -> Path:
path = Config.ants_json
if Args.ants:
if Args.ants.exists():
path = Args.ants
return path
@staticmethod
def get_ants() -> Any:
try:
path = Storage.get_ants_path()
with path.open() as file:
return json.load(file)
except Exception as e:
Utils.print(str(e))
return []
@staticmethod
def save_ants(ants: list[Ant]) -> None:
objs = [ant.to_dict() for ant in ants]
path = Storage.get_ants_path()
with path.open("w") as file:
json.dump(objs, file)
@staticmethod
def get_names() -> Any:
path = Storage.get_names_path()
with path.open() as file:
return json.load(file)
@staticmethod
def get_settings() -> Any:
try:
with Config.settings_json.open() as file:
return json.load(file)
except Exception as e:
Utils.print(str(e))
return {}
@staticmethod
def save_settings(settings: dict[str, Any]) -> None:
with Config.settings_json.open("w") as file:
json.dump(settings, file)
@staticmethod
def get_countries() -> Any:
with Config.countries_json.open() as file:
return json.load(file)
@staticmethod
def get_manifest() -> Any:
with Config.manifest_path.open() as file:
return json.load(file)
@staticmethod
def save_arguments(text: str) -> None:
with Config.arguments_path.open("w") as file:
file.write(text)

221
cromulant/utils.py Normal file
View File

@ -0,0 +1,221 @@
from __future__ import annotations
import random
import colorsys
import time
from datetime import datetime
from typing import ClassVar
from wonderwords import RandomWord, RandomSentence # type: ignore
from fontTools.ttLib import TTFont # type: ignore
from .config import Config
class Utils:
names: ClassVar[list[str]] = []
countries: ClassVar[list[str]] = []
rand_word: RandomWord
rand_sentence: RandomSentence
vowels = "aeiou"
consonants = "bcdfghjklmnpqrstvwxyz"
@staticmethod
def prepare() -> None:
from .storage import Storage
Utils.names = Storage.get_names()
Utils.countries = Storage.get_countries()
Utils.rand_word = RandomWord()
Utils.rand_sentence = RandomSentence()
@staticmethod
def now() -> int:
return int(time.time())
@staticmethod
def singular_or_plural(num: float, singular: str, plural: str) -> str:
if num == 1:
return singular
return plural
@staticmethod
def time_ago(start_time: float, end_time: float) -> str:
diff = end_time - start_time
seconds = int(diff)
if seconds < 60:
word = Utils.singular_or_plural(seconds, "second", "seconds")
return f"{seconds} {word} ago"
minutes = seconds // 60
if minutes < 60:
word = Utils.singular_or_plural(minutes, "minute", "minutes")
return f"{minutes} {word} ago"
hours = minutes / 60
if hours < 24:
word = Utils.singular_or_plural(hours, "hour", "hours")
return f"{hours:.1f} {word} ago"
days = hours / 24
if days < 30:
word = Utils.singular_or_plural(days, "day", "days")
return f"{days:.1f} {word} ago"
months = days / 30
if months < 12:
word = Utils.singular_or_plural(months, "month", "months")
return f"{months:.1f} {word} ago"
years = months / 12
word = Utils.singular_or_plural(years, "year", "years")
return f"{years:.1f} {word} ago"
@staticmethod
def print(text: str) -> None:
print(text) # noqa: T201
@staticmethod
def random_color(seed: str) -> tuple[int, int, int]:
seed_int = hash(seed)
random.seed(seed_int)
h, s, l = (
random.random(),
0.5 + random.random() / 2.0,
0.4 + random.random() / 5.0,
)
r, g, b = (int(256 * i) for i in colorsys.hls_to_rgb(h, l, s))
return r, g, b
@staticmethod
def random_name(ignore: list[str], include: list[str] | None = None) -> str:
names = Utils.names
if include:
for name in include:
if name not in names:
names.append(name)
filtered = [name for name in Utils.names if name not in ignore]
if not filtered:
return Utils.make_name()
return random.choice(filtered)
@staticmethod
def get_rgb(color: tuple[int, int, int]) -> str:
return f"rgb{color}"
@staticmethod
def random_character(font_path: str, num: int) -> str:
font = TTFont(font_path)
cmap = font["cmap"]
unicode_map = cmap.getBestCmap()
characters = [chr(code_point) for code_point in unicode_map]
for _ in range(10): # Try up to 10 times
selected = random.sample(characters, num)
if all((char.isprintable() and not char.isspace()) for char in selected):
return " ".join(selected)
return ""
@staticmethod
def random_emoji(num: int) -> str:
return Utils.random_character(str(Config.emoji_font_path), num)
@staticmethod
def to_date(timestamp: float) -> str:
dt_object = datetime.fromtimestamp(timestamp)
hour = dt_object.strftime("%I").lstrip("0")
return dt_object.strftime(f"%b %d %Y - {hour}:%M %p")
@staticmethod
def get_timeword(minutes: float) -> str:
if minutes < 1:
seconds = round(minutes * 60)
if seconds == 1:
return "1 second"
if seconds < 60:
return f"{seconds} seconds"
if minutes == 1:
return "1 minute"
return f"{round(minutes)} minutes"
@staticmethod
def random_country(ignore: list[str]) -> str:
filtered = [country for country in Utils.countries if country not in ignore]
return random.choice(filtered)
@staticmethod
def random_word(noun: bool = True, adj: bool = True) -> str:
opts = []
if noun:
opts.append("noun")
if adj:
opts.append("adjective")
if not len(opts):
return ""
word = Utils.rand_word.word(include_parts_of_speech=opts, word_max_length=8)
return str(word)
@staticmethod
def random_words(num: int = 1, noun: bool = True, adj: bool = True) -> list[str]:
return [Utils.random_word(noun=noun) for _ in range(num)]
@staticmethod
def capitalize(word: str) -> str:
return word[0].upper() + word[1:]
@staticmethod
def words_1() -> str:
return str(Utils.rand_sentence.simple_sentence())
@staticmethod
def words_2() -> str:
return str(Utils.rand_sentence.bare_bone_sentence())
@staticmethod
def words_3() -> str:
return str(Utils.rand_sentence.bare_bone_with_adjective())
@staticmethod
def words_4() -> str:
return str(Utils.rand_sentence.sentence())
@staticmethod
def make_word() -> str:
name = ""
name += random.choice(Utils.consonants)
name += random.choice(Utils.vowels)
name += random.choice(Utils.consonants)
name += random.choice(Utils.vowels)
return name
@staticmethod
def make_words(num: int = 1) -> list[str]:
return [Utils.make_word() for _ in range(num)]
@staticmethod
def make_name() -> str:
words = Utils.make_words(2)
words = [word.capitalize() for word in words]
return " ".join(words)

445
cromulant/window.py Normal file
View File

@ -0,0 +1,445 @@
from __future__ import annotations
from typing import Any
from collections.abc import Callable
import signal
from PySide6.QtWidgets import QApplication # type: ignore
from PySide6.QtWidgets import QDialog
from PySide6.QtWidgets import QMainWindow
from PySide6.QtWidgets import QWidget
from PySide6.QtWidgets import QGraphicsScene
from PySide6.QtWidgets import QVBoxLayout
from PySide6.QtWidgets import QLabel
from PySide6.QtWidgets import QPushButton
from PySide6.QtWidgets import QHBoxLayout
from PySide6.QtWidgets import QScrollArea
from PySide6.QtWidgets import QComboBox
from PySide6.QtWidgets import QLayout
from PySide6.QtWidgets import QSizePolicy
from PySide6.QtWidgets import QMessageBox
from PySide6.QtWidgets import QLineEdit
from PySide6.QtGui import QShortcut # type: ignore
from PySide6.QtGui import QKeySequence
from PySide6.QtGui import QFontDatabase
from PySide6.QtGui import QIcon
from PySide6.QtGui import QKeyEvent
from PySide6.QtGui import QMouseEvent
from PySide6.QtCore import Qt # type: ignore
from PySide6.QtCore import QUrl
from PySide6.QtCore import Signal
from PySide6.QtMultimedia import QMediaPlayer # type: ignore
from PySide6.QtMultimedia import QAudioOutput
from .config import Config
from .args import Args
from .utils import Utils
class SpecialButton(QPushButton): # type: ignore
middleClicked = Signal()
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
def mousePressEvent(self, e: QMouseEvent) -> None:
if e.button() == Qt.MiddleButton:
self.middleClicked.emit()
else:
super().mousePressEvent(e)
class SpecialComboBox(QComboBox): # type: ignore
middleClicked = Signal()
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
def mousePressEvent(self, e: QMouseEvent) -> None:
if e.button() == Qt.MiddleButton:
self.middleClicked.emit()
else:
super().mousePressEvent(e)
class FilterLineEdit(QLineEdit): # type: ignore
def keyPressEvent(self, e: QKeyEvent) -> None:
if e.key() == Qt.Key_Escape:
self.clear()
else:
super().keyPressEvent(e)
class RestartDialog(QDialog): # type: ignore
def __init__(self, sizes: list[str], defindex: int) -> None:
super().__init__()
self.setWindowTitle("Select Option")
self.setFixedSize(300, 150)
self.layout = QVBoxLayout()
self.label = QLabel("Size of the population")
self.layout.addWidget(self.label)
self.size_combo = QComboBox()
self.size_combo.addItems(sizes)
self.size_combo.setCurrentIndex(defindex)
self.layout.addWidget(self.size_combo)
self.button_layout = QHBoxLayout()
self.cancel_button = QPushButton("Cancel")
self.cancel_button.clicked.connect(self.reject)
self.button_layout.addWidget(self.cancel_button)
self.ok_button = QPushButton("OK")
self.ok_button.clicked.connect(self.accept)
self.ok_button.setDefault(True)
self.button_layout.addWidget(self.ok_button)
self.layout.addLayout(self.button_layout)
self.setLayout(self.layout)
self.setWindowFlags(Qt.Popup)
def get_data(self) -> dict[str, Any]:
return {
"size": str(self.size_combo.currentText()),
}
class Window:
app: QApplication
window: QMainWindow
root: QVBoxLayout
view: QVBoxLayout
view_scene: QGraphicsScene
speed: QComboBox
scroll_area: QScrollArea
info: QPushButton
font: str
emoji_font: str
mono_font: str
player: QMediaPlayer
audio: QAudioOutput
filter: QLineEdit
@staticmethod
def prepare() -> None:
Window.make()
Window.add_buttons()
Window.add_view()
Window.add_footer()
Window.setup_keyboard()
@staticmethod
def make() -> None:
Window.app = QApplication([])
program = Args.program or Config.program
Window.app.setApplicationName(program)
Window.window = QMainWindow()
title = Args.title or Config.title
Window.window.setWindowTitle(title)
width = Args.width or Config.width
height = Args.height or Config.height
Window.window.resize(width, height)
central_widget = QWidget()
Window.root = QVBoxLayout()
central_widget.setLayout(Window.root)
Window.root.setAlignment(Qt.AlignTop)
Window.window.setCentralWidget(central_widget)
Window.window.setWindowIcon(QIcon(str(Config.icon_path)))
Window.root.setContentsMargins(0, 0, 0, 0)
Window.set_style()
@staticmethod
def set_style() -> None:
font_id = QFontDatabase.addApplicationFont(str(Config.font_path))
emoji_font_id = QFontDatabase.addApplicationFont(str(Config.emoji_font_path))
mono_font_id = QFontDatabase.addApplicationFont(str(Config.mono_font_path))
if font_id != -1:
Window.font = QFontDatabase.applicationFontFamilies(font_id)[0]
if emoji_font_id != -1:
Window.emoji_font = QFontDatabase.applicationFontFamilies(emoji_font_id)[0]
if mono_font_id != -1:
Window.mono_font = QFontDatabase.applicationFontFamilies(mono_font_id)[0]
style = f"""
QWidget {{
background-color: {Config.background_color};
color: {Config.text_color};
font-size: {Config.font_size}px;
}}
QMenu {{
background-color: {Config.alt_background_color};
color: {Config.alt_text_color};
border: 2px solid {Config.alt_border_color};
}}
QMenu::item:selected {{
background-color: {Config.alt_hover_background_color};
color: {Config.alt_hover_text_color};
}}
QDialog {{
background-color: {Config.alt_background_color};
color: {Config.alt_text_color};
border: 2px solid {Config.alt_border_color};
}}
QDialog QLabel {{
background-color: {Config.alt_background_color};
color: {Config.alt_text_color};
}}
QDialog QPushButton {{
background-color: {Config.alt_background_color};
color: {Config.alt_text_color};
}}
QDialog QPushButton:hover {{
background-color: {Config.message_box_button_hover_background_color};
color: {Config.message_box_button_hover_text_color};
}}
QComboBox {{
selection-background-color: {Config.alt_hover_background_color};
selection-color: {Config.alt_hover_text_color};
}}
QComboBox QAbstractItemView {{
background-color: {Config.alt_background_color};
color: {Config.alt_text_color};
border: 2px solid {Config.alt_border_color};
padding: 6px;
}}
QScrollBar:vertical {{
border: 0px solid transparent;
background: {Config.background_color};
width: 15px;
margin: 0px 0px 0px 0px;
}}
QScrollBar::handle:vertical {{
background: {Config.scrollbar_handle_color};
min-height: 20px;
}}
QScrollBar::add-line:vertical {{
border: none;
background: none;
}}
QScrollBar::sub-line:vertical {{
border: none;
background: none;
}}
QLineEdit {{
background-color: {Config.input_background_color};
color: {Config.input_text_color};
border: 1px solid {Config.input_border_color};
}}
QFrame#horizontal_line {{
background-color: white;
color: white;
}}
QLabel#menu_label:hover {{
background-color: {Config.alt_hover_background_color};
}}
""".strip()
Window.app.setStyleSheet(style)
if Args.mono:
Window.app.setFont(Window.mono_font)
else:
Window.app.setFont(Window.font)
@staticmethod
def add_buttons() -> None:
from .game import Game
from .filter import Filter
root = QWidget()
container = QHBoxLayout()
btn_menu = SpecialButton("Menu")
btn_menu.setToolTip("The main menu\nMiddle Click: Update")
btn_menu.clicked.connect(Game.menu)
btn_menu.middleClicked.connect(Game.force_update)
Window.speed = SpecialComboBox()
tooltip = "The speed of the updates\n"
fast = (Args.fast_seconds or Config.fast_seconds) / 60
tooltip += f"Fast: {Utils.get_timeword(fast)}\n"
normal = Args.normal_minutes or Config.normal_minutes
tooltip += f"Normal: {Utils.get_timeword(normal)}\n"
slow = Args.slow_minutes or Config.slow_minutes
tooltip += f"Slow: {Utils.get_timeword(slow)}\n"
tooltip += "Middle Click: Slow"
Window.speed.setToolTip(tooltip)
Window.speed.addItems(["Fast", "Normal", "Slow", "Paused"])
Window.speed.setCurrentIndex(1)
Window.speed.currentIndexChanged.connect(Game.update_speed)
Window.speed.middleClicked.connect(Game.slowdown)
Window.filter = FilterLineEdit()
Window.filter.setPlaceholderText("Filter")
Window.filter.mousePressEvent = lambda e: Window.to_top()
Window.filter.keyReleaseEvent = lambda e: Filter.filter(e)
container.addWidget(btn_menu, 1)
container.addWidget(Window.speed, 1)
container.addWidget(Window.filter, 1)
root.setLayout(container)
if not Args.header:
root.setVisible(False)
Window.root.addWidget(root)
@staticmethod
def add_view() -> None:
Window.scroll_area = QScrollArea()
Window.scroll_area.setWidgetResizable(True)
container = QWidget()
parent = QVBoxLayout(container)
Window.view = QVBoxLayout()
parent.addLayout(Window.view)
Window.view.setAlignment(Qt.AlignTop)
Window.scroll_area.setWidget(container)
Window.root.addWidget(Window.scroll_area)
@staticmethod
def start() -> None:
signal.signal(signal.SIGINT, signal.SIG_DFL)
Window.window.show()
Window.app.exec()
@staticmethod
def close() -> None:
Window.app.quit()
@staticmethod
def delete_layout(layout: QLayout) -> None:
while layout.count():
item = layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
elif item.layout():
Window.delete_layout(item.layout())
layout.deleteLater()
@staticmethod
def expand(widget: QWidget) -> None:
widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
@staticmethod
def expand_2(widget: QWidget) -> None:
widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
@staticmethod
def clear_view() -> None:
while Window.view.count():
item = Window.view.takeAt(0)
if item.widget():
item.widget().deleteLater()
elif item.layout():
Window.delete_layout(item.layout())
@staticmethod
def to_top() -> None:
Window.scroll_area.verticalScrollBar().setValue(0)
@staticmethod
def to_bottom() -> None:
Window.scroll_area.verticalScrollBar().setValue(
Window.scroll_area.verticalScrollBar().maximum()
)
@staticmethod
def toggle_scroll() -> None:
maxim = Window.scroll_area.verticalScrollBar().maximum()
if Window.scroll_area.verticalScrollBar().value() == maxim:
Window.to_top()
else:
Window.to_bottom()
@staticmethod
def add_footer() -> None:
from .game import Game
root = QWidget()
root.setContentsMargins(0, 0, 0, 0)
container = QHBoxLayout()
Window.info = SpecialButton("---")
Window.info.setToolTip(
"Click to scroll to the bottom or top\nMiddle Click: Filter Top"
)
Window.info.clicked.connect(Window.toggle_scroll)
Window.info.middleClicked.connect(Game.filter_top)
Window.info.setMinimumSize(35, 35)
container.addWidget(Window.info)
root.setLayout(container)
if not Args.footer:
root.setVisible(False)
Window.root.addWidget(root)
@staticmethod
def play_audio(path: str, on_stop: Callable[..., Any] | None = None) -> None:
Window.player = QMediaPlayer()
Window.audio = QAudioOutput()
Window.player.setAudioOutput(Window.audio)
Window.player.setSource(QUrl.fromLocalFile(path))
Window.audio.setVolume(100)
def handle_state_change(state: QMediaPlayer.State) -> None:
if state == QMediaPlayer.StoppedState:
if on_stop:
on_stop()
Window.player.playbackStateChanged.connect(handle_state_change)
Window.player.play()
@staticmethod
def stop_audio() -> None:
Window.player.stop()
@staticmethod
def alert(message: str) -> None:
msg_box = QMessageBox()
msg_box.setWindowFlags(Qt.Popup)
msg_box.setIcon(QMessageBox.Information)
msg_box.setText(message)
msg_box.setWindowTitle("Information")
msg_box.setStandardButtons(QMessageBox.Ok)
msg_box.exec()
@staticmethod
def setup_keyboard() -> None:
on_enter = QShortcut(QKeySequence(Qt.Key_Return), Window.window)
on_enter.activated.connect(Window.toggle_scroll)

25
more.md Normal file
View File

@ -0,0 +1,25 @@
## Propaganda
![](cromulant/img/logo_1.jpg)
![](cromulant/img/logo_2.jpg)
![](cromulant/img/logo_3.jpg)
![](cromulant/img/logo_4.jpg)
## Assets
![](cromulant/img/status.jpg)
![](cromulant/img/hatched.jpg)
![](cromulant/img/terminated.jpg)
![](cromulant/img/change.jpg)
![](cromulant/img/top.jpg)
## Soundtrack
[March of The Cyber Ants](cromulant/audio/March%20of%20the%20Cyber%20Ants.mp3)

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
PySide6 == 6.7.2
appdirs == 1.4.4
wonderwords == 2.2.0
fonttools == 4.53.1
pre-commit == 3.7.1

6
run.sh Executable file
View File

@ -0,0 +1,6 @@
#!/usr/bin/env bash
root="$(dirname "$(readlink -f "$0")")"
cd "$root"
venv/bin/python -m cromulant.main "$@"

9
screenshots.md Normal file
View File

@ -0,0 +1,9 @@
![](https://i.imgur.com/5yIX4hl.jpeg)
![](https://i.imgur.com/e48HxaC.jpeg)
![](https://i.imgur.com/Y66isBl.jpeg)
![](https://i.imgur.com/qZnnxoB.jpeg)
![](https://i.imgur.com/7O5BP5K.jpeg)

69
setup.py Normal file
View File

@ -0,0 +1,69 @@
from setuptools import setup, find_packages
from pathlib import Path
import shutil
import json
import platform
with open("cromulant/manifest.json", "r") as file:
manifest = json.load(file)
title = manifest["title"]
program = manifest["program"]
version = manifest["version"]
def _post_install():
system = platform.system()
if system == "Linux":
try:
_copy_icon_file()
_create_desktop_file()
except Exception as e:
print(f"Error during post install: {e}")
def _copy_icon_file():
source = Path(f"{program}/img/icon_1.jpg").expanduser().resolve()
destination = Path(f"~/.local/share/icons/{program}.png").expanduser().resolve()
shutil.copy2(source, destination)
def _create_desktop_file():
content = f"""[Desktop Entry]
Version=1.0
Name={title}
Exec={Path(f"~/.local/bin/{program}").expanduser().resolve()}
Icon={Path(f"~/.local/share/icons/{program}.png").expanduser().resolve()}
Terminal=false
Type=Application
Categories=Utility;
"""
file_path = Path(f"~/.local/share/applications/{program}.desktop").expanduser().resolve()
with open(file_path, 'w') as f:
f.write(content)
with open("requirements.txt") as f:
requirements = f.read().splitlines()
package_data = {}
package_data[program] = ["**/*.png", "**/*.jpg", "**/*.json", "**/*.mp3", "**/*.ttf"]
setup(
name=title,
version=version,
install_requires=requirements,
packages=find_packages(where="."),
package_dir={"": "."},
package_data=package_data,
entry_points={
"console_scripts": [
f"{program}={program}.main:main",
],
},
)
_post_install()

23
utils/tag.py Executable file
View File

@ -0,0 +1,23 @@
#!/usr/bin/env python
# This is used to create a tag in the git repo
# You probably don't want to run this
# pacman: python-gitpython
import os
import git
import json
from pathlib import Path
here = Path(__file__).resolve()
parent = here.parent.parent
os.chdir(parent)
with open("cromulant/manifest.json") as f:
manifest = json.loads(f.read())
version = manifest["version"]
repo = git.Repo(".")
repo.create_tag(version)
repo.remotes.origin.push(version)
print(f"Created tag: {version}")