diff --git a/pyproject.toml b/pyproject.toml index 1b59fd7..dcbad63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ unicode-slugify = "^0.1.5" nanoid = "^2.0.0" nanoid-dictionary = "^2.4.0" wtforms-alchemy = "^0.18.0" +sqlalchemy-serializer = "^1.4.1" [build-system] diff --git a/ttfrog/assets/static/css/styles.css b/ttfrog/assets/static/css/styles.css index b557066..3e83d4e 100644 --- a/ttfrog/assets/static/css/styles.css +++ b/ttfrog/assets/static/css/styles.css @@ -4,6 +4,19 @@ body { margin: 0; } +.disabled { + position : relative; + opacity: 0.3; +} +.disabled:after { + position :absolute; + left : 0; + top : 0; + width : 100%; + height : 100%; + content :' '; +} + #content { margin: 1rem auto; @@ -35,79 +48,161 @@ ul.nav li { padding: 0 0.5rem; } -#character_sheet { - width:50%; +#sheet_container { display: block; } -#character_sheet .statblock { +#character_sheet { width: 100%; margin-bottom:3rem; display: grid; - grid-template-columns: auto 250px; grid-gap: 1rem; + grid-template-columns: min-content max-content; } -#character_sheet .banner { +#sheet_container .banner { display: grid; grid-template-columns: 64px 1fr; grid-gap: 1rem; } -#character_sheet .banner #portrait { +#sheet_container .banner #portrait { width: 64px; height: 64px; background: #e7e7e7; } +#controls { + float: right; + display: inline-block; +} -#character_sheet h1 { +.temp_hp input { + font-size: 0.75rem !important; +} + +#sheet_container h1 { margin: 0; } -#character_sheet .sidebar { - min-height: 300px; +#sheet_container .sidebar { + grid-column-start: 2; + grid-row-start: 1; } -#character_sheet input, -#character_sheet select, -#character_sheet textarea { +#sheet_container .sidebar .card { + margin-bottom: 1rem; +} + +#sheet_container .sidebar ul { + list-style-type: none; + margin: 0; + padding: 0; +} +#sheet_container .sidebar ul > li { + margin: 0; + padding: 0; +} + +#hp { + grid-row-start: 1; + grid-column: 7; + grid-column-end: 9; +} + +#saves { + grid-row-start: 2; + grid-column: 3; + grid-column-start: span 2; +} + +#proficiency { + grid-row-start: 2; + grid-column: 5; +} + +#initiative { + grid-row-start: 2; + grid-column: 6; +} +#ac { + grid-row-start: 2; + grid-column: 7; +} +#speed { + grid-row-start: 2; + grid-column: 8; +} + +#skills { + grid-row-start: 2; + grid-row-end: 50; + grid-column: 1; + grid-column-start: span 2; + text-align:left; +} + +#actions { + grid-row-start: 3; + grid-column: 4; + grid-column-start: span 6; +} + + +table { + display: grid; + grid-template-columns: minmax(50px, 150px) 1fr; + grid-gap: 0rem; +} +table th { + grid-column-start: span 4; + white-space: nowrap; + padding-right: 1rem; + text-align: left; +} + +table td { + padding-right: 1rem; + white-space: nowrap; +} + +.note { + font-size: 0.75em; + font-style: italic; +} + + + +#sheet_container input, +#sheet_container select, +#sheet_container textarea { font-weight: bold; border: 0; } -#character_sheet input#name { - font-size: 1.5rem; +#sheet_container input#name { + font-size: 2.0rem; font-weight: bold; width: 100%; } -.stats .cards { - margin-top: 1rem; +.stats { display: grid; - grid-template-columns: repeat(7, 1fr); - grid-auto-rows: auto; + grid-template-columns: repeat(8, minmax(6rem, 1fr)); grid-gap: 1rem; } -.sidebar .cards { - margin-top: 1rem; - display: grid; - grid-template-columns: 1fr; - grid-template-rows: auto; - grid-gap: 1rem; - text-align: center; -} - -.cards .card { +.card { border: 2px solid #e7e7e7; border-radius: 4px; padding: .5rem; + text-align: center; } -.card .label { +.label { text-align: center; text-transform: uppercase; + font-size: 0.75rem; } .card input { diff --git a/ttfrog/assets/static/js/character_sheet.js b/ttfrog/assets/static/js/character_sheet.js index 41d3b32..75cb554 100644 --- a/ttfrog/assets/static/js/character_sheet.js +++ b/ttfrog/assets/static/js/character_sheet.js @@ -58,6 +58,6 @@ function setSpellSaveDC() { stats.forEach(applyStatModifiers); stats.forEach(setStatBonus); setProficiencyBonus(); - setSpellSaveDC(); + // setSpellSaveDC(); })(); diff --git a/ttfrog/assets/templates/base.html b/ttfrog/assets/templates/base.html index c267eab..8bc145b 100644 --- a/ttfrog/assets/templates/base.html +++ b/ttfrog/assets/templates/base.html @@ -1,3 +1,4 @@ +{% from "list.html" import build_list %} @@ -14,6 +15,7 @@ {% block headers %}{% endblock %} + {{ build_list(c) }}
{% block content %}{% endblock %}
diff --git a/ttfrog/assets/templates/character_sheet.html b/ttfrog/assets/templates/character_sheet.html index bff7e89..da20632 100644 --- a/ttfrog/assets/templates/character_sheet.html +++ b/ttfrog/assets/templates/character_sheet.html @@ -1,66 +1,153 @@ {% extends "base.html" %} -{% from "list.html" import build_list %} +{% set DISABLED = False if c.record.id else True %} + +{% macro field(name, disabled=False) %} +{% set default_value = c.record[name] if c.record.id else c.form[name].default %} +{{ c.form[name](disabled=disabled, **{'data-initial_value': default_value}) }} +{% endmacro %} {% block content %} -{{ build_list(c) }} - -
+
-
+
+
-
{% for stat in ['str', 'dex', 'con', 'int', 'wis', 'cha'] %} -
-
{{ c.form[stat].label }}
- {{ c.form[stat] }} -
-
+
+
{{ c.form[stat].label }}
+ {{ field(stat, DISABLED) }} +
+
{% endfor %} -
-
AC
- {{ c.form.armor_class }} +
+
HP
+ {{ field('hit_points', DISABLED) }} / {{ field('max_hit_points', DISABLED) }} +
+ TEMP {{ field('temp_hit_points', DISABLED) }}
-
    -
  • Initiative: 3
  • -
  • Proficiency Bonus:
  • -
  • Spell Save DC:
  • -
  • Saving Throws: {{ c.record.saving_throws |join(', ') }}
  • -
  • Skills: {{ c.record.skills |join(', ') }}
  • - {% for field in c.form %} - {% if field.name in ['proficiencies', 'speed', 'passive_perception', 'passive_insight', 'passive_investigation'] %} -
  • {{ field.label }}: {{ field }} {{ field.errors|join(',') }}
  • - {% endif %} - {% endfor %} -
+
+
Skills
+ + {% for skill in c.record.skills %} + + {% endfor %} +
{{ skill }}3
+
+
+
Saving Throws
+ {% for save in c.record.saving_throws %} + {{ save }} 3  + {% endfor %} +
+ +
+
PROF
+
+
BONUS
+
+
+
Armor
+ {{ field('armor_class', DISABLED) }} +
Class
+
+
+
Initiative
+ 3 +
Bonus
+
+
+
Speed
+ {{ field('speed', DISABLED) }} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ActionsTo HitRangeTargetsDamage
AttackDagger+7511d4+3 slashing
AttackSabetha's Fans+7512d6 slashing
SpellEldritch Blast+512011d10 force
Bonus ActionsTo HitRangeTargetsDamage
+

+ + Attack (1 per Action), Cast a Spell, Dash, Disengage, Dodge, Grapple,
Help, Hide, Improvise, Ready, Search, Shove, or Use an Object +
+

+
+ + + +
- {{ c.form.save }}   {{ c.form.delete }} -
{{ c.form.csrf_token }} @@ -70,6 +157,8 @@

Debug

+{{ DISABLED }} + {{ c }} {% endblock %} diff --git a/ttfrog/db/base.py b/ttfrog/db/base.py index ac66fab..55ad7b9 100644 --- a/ttfrog/db/base.py +++ b/ttfrog/db/base.py @@ -1,8 +1,8 @@ +import logging import nanoid from nanoid_dictionary import human_alphabet from sqlalchemy import Column from sqlalchemy import String -from sqlalchemy import String from pyramid_sqlalchemy import BaseObject from slugify import slugify @@ -31,9 +31,23 @@ class IterableMixin: for attr in self.__mapper__.columns.keys(): if attr in values: yield attr, values[attr] + for relname in self.__mapper__.relationships.keys(): + relvals = [] + for rel in self.__getattribute__(relname): + try: + relvals.append({k: v for k, v in vars(rel).items() if not k.startswith('_')}) + except TypeError: + relvals.append(rel) + yield relname, relvals - def __repr__(self): - return f"{self.__class__.__name__}: {str(dict(self))}" + def __json__(self, request): + serialized = dict() + for (key, value) in self: + try: + serialized[key] = getattr(self.value, '__json__')(request) + except AttributeError: + serialized[key] = value + return serialized def multivalue_string_factory(name, column=Column(String), separator=';'): diff --git a/ttfrog/db/bootstrap.py b/ttfrog/db/bootstrap.py index ccc1536..171471e 100644 --- a/ttfrog/db/bootstrap.py +++ b/ttfrog/db/bootstrap.py @@ -62,7 +62,7 @@ data = { 'max_hit_points': 14, 'hit_points': 14, 'temp_hit_points': 0, - 'speed': '30 ft.', + 'speed': 30, 'str': 16, 'dex': 12, 'con': 18, @@ -88,18 +88,32 @@ data = { { 'id': 1, 'ancestry_id': 1, - 'description': '+1 to All Ability Scores', + 'name': '+1 to All Ability Scores', + 'level': 1, + }, + { + 'id': 2, + 'ancestry_id': 2, + 'name': 'Breath Weapon', 'level': 1, }, ], 'Modifier': [ + # Humans {'source_table_name': 'ancestry_trait', 'source_table_id': 1, 'value': '+1', 'type': 'stat', 'target': 'str'}, {'source_table_name': 'ancestry_trait', 'source_table_id': 1, 'value': '+1', 'type': 'stat', 'target': 'dex'}, {'source_table_name': 'ancestry_trait', 'source_table_id': 1, 'value': '+1', 'type': 'stat', 'target': 'con'}, {'source_table_name': 'ancestry_trait', 'source_table_id': 1, 'value': '+1', 'type': 'stat', 'target': 'int'}, {'source_table_name': 'ancestry_trait', 'source_table_id': 1, 'value': '+1', 'type': 'stat', 'target': 'wis'}, {'source_table_name': 'ancestry_trait', 'source_table_id': 1, 'value': '+1', 'type': 'stat', 'target': 'cha'}, + + # Dragonborn + {'source_table_name': 'ancestry_trait', 'source_table_id': 2, 'value': '60', 'type': 'attribute ', 'target': 'Darkvision'}, + {'source_table_name': 'ancestry_trait', 'source_table_id': 2, 'value': '+1', 'type': 'stat', 'target': ''}, + {'source_table_name': 'ancestry_trait', 'source_table_id': 2, 'value': '+1', 'type': 'stat', 'target': ''}, + + # Fighting Style: Archery {'source_table_name': 'class_attribute', 'source_table_id': 1, 'value': '+2', 'type': 'weapon ', 'target': 'ranged'}, ], diff --git a/ttfrog/db/schema.py b/ttfrog/db/schema.py index c82a0f3..20cc847 100644 --- a/ttfrog/db/schema.py +++ b/ttfrog/db/schema.py @@ -7,6 +7,7 @@ from sqlalchemy import ForeignKey from sqlalchemy import Enum from sqlalchemy import Text from sqlalchemy import UniqueConstraint +from sqlalchemy.orm import relationship from ttfrog.db.base import Bases, BaseObject, IterableMixin from ttfrog.db.base import multivalue_string_factory @@ -17,9 +18,18 @@ STATS = ['STR', 'DEX', 'CON', 'INT', 'WIS', 'CHA'] CREATURE_TYPES = ['aberation', 'beast', 'celestial', 'construct', 'dragon', 'elemental', 'fey', 'fiend', 'Giant', 'humanoid', 'monstrosity', 'ooze', 'plant', 'undead'] + +class EnumField(enum.Enum): + """ + A serializable enum. + """ + def __json__(self, request): + return self.value + + # enums for db schemas -StatsEnum = enum.Enum("StatsEnum", ((k, k) for k in STATS)) -CreatureTypesEnum = enum.Enum("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES)) +StatsEnum = EnumField("StatsEnum", ((k, k) for k in STATS)) +CreatureTypesEnum = EnumField("CreatureTypesEnum", ((k, k) for k in CREATURE_TYPES)) CharacterClassMixin = multivalue_string_factory('character_class', Column(String, nullable=False)) SavingThrowsMixin = multivalue_string_factory('saving_throws') @@ -50,6 +60,7 @@ class Ancestry(*Bases): id = Column(Integer, primary_key=True, autoincrement=True) name = Column(String, index=True, unique=True) creature_type = Column(Enum(CreatureTypesEnum)) + traits = relationship("AncestryTrait") def __repr__(self): return str(self.name) @@ -59,13 +70,10 @@ class AncestryTrait(BaseObject, IterableMixin): __tablename__ = "ancestry_trait" id = Column(Integer, primary_key=True, autoincrement=True) ancestry_id = Column(Integer, ForeignKey("ancestry.id"), nullable=False) - name = Column(String, nullable="False") + name = Column(String, nullable=False) description = Column(Text) level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}) - def __repr__(self): - return str(self.name) - class CharacterClass(*Bases, SavingThrowsMixin, SkillsMixin): __tablename__ = "character_class" @@ -83,8 +91,8 @@ class ClassAttribute(BaseObject, IterableMixin): __tablename__ = "class_attribute" id = Column(Integer, primary_key=True, autoincrement=True) character_class_id = Column(Integer, ForeignKey("character_class.id"), nullable=False) - name = Column(String, nullable="False") - value = Column(String, nullable="False") + name = Column(String, nullable=False) + value = Column(String, nullable=False) description = Column(Text) level = Column(Integer, nullable=False, info={'min': 1, 'max': 20}) @@ -95,14 +103,14 @@ class ClassAttribute(BaseObject, IterableMixin): class Character(*Bases, CharacterClassMixin, SavingThrowsMixin, SkillsMixin): __tablename__ = "character" id = Column(Integer, primary_key=True, autoincrement=True) - ancestry = Column(String, ForeignKey("ancestry.name"), nullable=False) + ancestry = Column(String, ForeignKey("ancestry.name"), nullable=False, default='human') name = Column(String, default='New Character', nullable=False) level = Column(Integer, default=1, nullable=False, info={'min': 1, 'max': 20}) armor_class = Column(Integer, default=10, nullable=False, info={'min': 1, 'max': 99}) hit_points = Column(Integer, default=1, nullable=False, info={'min': 0, 'max': 999}) max_hit_points = Column(Integer, default=1, nullable=False, info={'min': 0, 'max': 999}) - temp_hit_points = Column(Integer, default=0, nullable=False, info={'min': 0}) - speed = Column(String, nullable=False, default="30 ft.") + temp_hit_points = Column(Integer, default=0, nullable=False, info={'min': 0, 'max': 999}) + speed = Column(Integer, nullable=False, default=30, info={'min': 0, 'max': 99}) str = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) dex = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) con = Column(Integer, nullable=False, default=10, info={'min': 0, 'max': 30}) diff --git a/ttfrog/webserver/controllers/__init__.py b/ttfrog/webserver/controllers/__init__.py index 4ae38d0..b5cc66d 100644 --- a/ttfrog/webserver/controllers/__init__.py +++ b/ttfrog/webserver/controllers/__init__.py @@ -1,4 +1,5 @@ from .base import BaseController from .character_sheet import CharacterSheet +from .json_data import JsonData -__all__ = [BaseController, CharacterSheet] +__all__ = [BaseController, CharacterSheet, JsonData] diff --git a/ttfrog/webserver/controllers/base.py b/ttfrog/webserver/controllers/base.py index 0eac46a..03f2a35 100644 --- a/ttfrog/webserver/controllers/base.py +++ b/ttfrog/webserver/controllers/base.py @@ -63,6 +63,8 @@ class BaseController: def form(self): if not self.model: return + if not self.model_form: + return if not self._form: if self.request.POST: self._form = self.model_form(self.request.POST, obj=self.record) @@ -106,8 +108,9 @@ class BaseController: db.add(self.record) logging.debug(f"Added {self.record = }") location = self.request.current_route_path() - if self.slug not in location: + if self.record.slug not in location: location = f"{location}/{self.record.uri}" + logging.debug(f"Redirecting to {location}") return HTTPFound(location=location) def delete(self): diff --git a/ttfrog/webserver/controllers/character_sheet.py b/ttfrog/webserver/controllers/character_sheet.py index 79b65db..143d455 100644 --- a/ttfrog/webserver/controllers/character_sheet.py +++ b/ttfrog/webserver/controllers/character_sheet.py @@ -17,7 +17,7 @@ class CharacterForm(ModelForm): save = SubmitField() delete = SubmitField() - ancestry = DeferredSelectField('Ancestry', model=Ancestry, validate_choice=True, widget=Select()) + ancestry = DeferredSelectField('Ancestry', model=Ancestry, default='human', validate_choice=True, widget=Select()) character_class = DeferredSelectMultipleField( 'CharacterClass', diff --git a/ttfrog/webserver/routes.py b/ttfrog/webserver/routes.py index d19df81..4b03c8b 100644 --- a/ttfrog/webserver/routes.py +++ b/ttfrog/webserver/routes.py @@ -1,3 +1,4 @@ def routes(config): config.add_route('index', '/') config.add_route('sheet', '/c{uri:.*}', factory='ttfrog.webserver.controllers.CharacterSheet') + config.add_route('data', '/_/{table_name}{uri:.*}', factory='ttfrog.webserver.controllers.JsonData') diff --git a/ttfrog/webserver/views.py b/ttfrog/webserver/views.py index b77ea1b..6803aa7 100644 --- a/ttfrog/webserver/views.py +++ b/ttfrog/webserver/views.py @@ -17,3 +17,7 @@ def index(request): @view_config(route_name='sheet', renderer='character_sheet.html') def sheet(request): return response_from(request.context) + +@view_config(route_name='data', renderer='json') +def data(request): + return response_from(request.context)