Compare commits

...

16 Commits

Author SHA1 Message Date
Trevor Vallender d8a27f8e41 Show character sheet on table 2024-06-13 13:39:50 +01:00
Trevor Vallender 2aef1b532c Update todos 2024-06-13 13:26:22 +01:00
Trevor Vallender b024ee829b Show dice rolls on table 2024-06-13 13:24:54 +01:00
Trevor Vallender 5f682dff6e Generate a DiceRoll when rolling 2024-06-13 13:03:34 +01:00
Trevor Vallender 0658eca783 Add DiceRoll model 2024-06-13 08:41:45 +01:00
Trevor Vallender c2c733fcec Add tab logic to tables 2024-06-12 18:20:14 +01:00
Trevor Vallender 1b90ed389f CRUD actions for counters 2024-06-12 17:51:57 +01:00
Trevor Vallender 05ffcf0456 Add counters 2024-06-12 16:17:27 +01:00
Trevor Vallender 1c2f8a5576 Move slug logic to concern 2024-06-12 16:13:07 +01:00
Trevor Vallender c01ab549fd DiceRoller service 2024-06-12 15:47:43 +01:00
Trevor Vallender bd21b53b06 Set URL 2024-06-11 11:45:22 +01:00
Trevor Vallender adb472e543 Allow updating of stats on sheet 2024-06-11 11:37:58 +01:00
Trevor Vallender 7ba4ae2b50 Can delete stats 2024-06-10 20:49:16 +01:00
Trevor Vallender 781893f12f Toggle edit links on character sheets 2024-06-10 18:02:09 +01:00
Trevor Vallender 7753e6d268 Can add new stats 2024-06-07 16:59:49 +01:00
Trevor Vallender 8ac1a845f6 Add stats for character sheets 2024-06-07 15:39:30 +01:00
60 changed files with 898 additions and 29 deletions

View File

@ -49,3 +49,29 @@
padding: 1em;
}
}
.stats, .counters {
display: flex;
flex-wrap: wrap;
gap: .2em;
justify-content: center;
}
.stat, .counter {
display: flex;
flex-direction: column;
border-radius: var(--border-radius);
h6 {
font-size: .8em;
text-align: center;
background-color: var(--background-color);
color: var(--header-text-color);
margin: 0;
padding: .5em;
}
input {
text-align: center;
font-size: 3em;
width: 2.5em;
}
}

View File

@ -1,4 +1,5 @@
:root {
--text-color: #000;
--background-color: #333;
--main-background-color:
--header-color: #15345b;

View File

@ -40,3 +40,17 @@ form, fieldset {
background-color: var(--input-background);
}
}
form.stat-form, form.counter-form {
display: flex;
flex-direction: column;
input:disabled {
color: var(--text-color);
background-color: var(--input-background);
}
input[type=number]:disabled {
-moz-appearance: textfield;
}
}

View File

@ -6,6 +6,12 @@ class CharacterSheetSectionsController < ApplicationController
def index
@sections = @character.character_sheet_sections.top_level
@editable = ActiveModel::Type::Boolean.new.cast(params[:editable])
if params[:table_id].present?
@table = Current.user.tables.find(params[:table_id])
@characters = Current.user.characters.where(table: @table)
render layout: "table"
end
end
def new
@ -17,6 +23,7 @@ class CharacterSheetSectionsController < ApplicationController
def create
@section = @character.character_sheet_sections.new(character_sheet_section_params)
@editable = true
unless @section.save
@parent_section = @section.parent_section
render :new, status: :unprocessable_entity

View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
class CountersController < ApplicationController
before_action :set_section, only: [ :new, :create ]
before_action :set_character, only: [ :new, :create ]
before_action :set_counter, only: [ :update, :destroy ]
def new
@counter = @section.counters.new
end
def create
@counter = @section.counters.new(counter_params)
unless @counter.save
render :new, status: :unprocessable_entity
end
end
def update
@counter.update(counter_params)
end
def destroy
@id = helpers.dom_id(@counter)
@counter.destroy
end
private
def set_character
@character = @section.character
end
def set_section
@section = Current.user.character_sheet_sections.find(params[:character_sheet_section_id])
end
def set_counter
@counter = Current.user.counters.find(params[:id])
end
def counter_params
params.require(:counter).permit(
:name,
:value,
:character_sheet_section_id,
)
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class EventsController < ApplicationController
layout "table"
before_action :set_table
def index
@characters = Current.user.characters.where(table: @table)
@events = @table.dice_rolls.order(:created_at)
end
private
def set_table
@table = Current.user.tables.find(params[:table_id])
end
end

View File

@ -0,0 +1,49 @@
# frozen_string_literal: true
class StatsController < ApplicationController
before_action :set_section, only: [ :new, :create ]
before_action :set_character, only: [ :new, :create ]
before_action :set_stat, only: [ :update, :destroy ]
def new
@stat = @section.stats.new
end
def create
@stat = @section.stats.new(stat_params)
unless @stat.save
render :new, status: :unprocessable_entity
end
end
def update
@stat.update(stat_params)
end
def destroy
@id = helpers.dom_id(@stat)
@stat.destroy
end
private
def set_character
@character = @section.character
end
def set_section
@section = Current.user.character_sheet_sections.find(params[:character_sheet_section_id])
end
def set_stat
@stat = Current.user.stats.find(params[:id])
end
def stat_params
params.require(:stat).permit(
:name,
:value,
:roll_command,
:character_sheet_section_id,
)
end
end

View File

@ -2,6 +2,7 @@
class TablesController < ApplicationController
before_action :set_table, only: [ :show, :edit, :update, :destroy ]
before_action :set_characters, only: [ :show ]
def index
@tables = Current.user.tables
@ -9,6 +10,7 @@ class TablesController < ApplicationController
def show
@table_invites = @table.table_invites.not_responded
render layout: "table"
end
def new
@ -52,6 +54,10 @@ class TablesController < ApplicationController
@table = Current.user.tables.find(params[:id])
end
def set_characters
@characters = Current.user.characters.where(table: @table)
end
def table_params
params.require(:table).permit(
:name,

View File

@ -0,0 +1,15 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static values = {
updateUrl: String,
}
connect() {
this.element.addEventListener("change", this.#updateValue.bind(this))
}
#updateValue() {
this.element.requestSubmit()
}
}

View File

@ -5,6 +5,7 @@ class Character < ApplicationRecord
belongs_to :game_system
belongs_to :user
has_many :character_sheet_sections, dependent: :destroy
has_many :stats, through: :character_sheet_sections
validates :name, presence: true,
length: { maximum: 200 }

View File

@ -2,8 +2,13 @@
class CharacterSheetSection < ApplicationRecord
belongs_to :character
belongs_to :parent_section, optional: true, class_name: "CharacterSheetSection"
has_many :character_sheet_subsections, class_name: "CharacterSheetSection", foreign_key: :parent_section_id
belongs_to :parent_section, optional: true,
class_name: "CharacterSheetSection"
has_many :character_sheet_subsections, class_name: "CharacterSheetSection",
foreign_key: :parent_section_id,
dependent: :destroy
has_many :counters, dependent: :destroy
has_many :stats, dependent: :destroy
validates :name, presence: true,
length: { maximum: 255 }

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
module Sluggable
extend ActiveSupport::Concern
included do
validates :slug, presence: true,
length: { maximum: 100 },
uniqueness: { scope: :character_sheet_section_id }
before_validation :set_slug
private
def set_slug
return if slug.present? || name.blank?
slug = if character_sheet_section.parent_section.present?
[
character_sheet_section.parent_section.name.parameterize,
name.parameterize,
].join("-")
else
slug = name.parameterize
end
suffix = 2
while Stat.exists?(slug:)
slug = "#{slug}-#{suffix}"
suffix += 1
end
self.slug = slug
end
end
end

14
app/models/counter.rb Normal file
View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class Counter < ApplicationRecord
include Sluggable
belongs_to :character_sheet_section
validates :name, presence: true,
length: { maximum: 100 },
uniqueness: { scope: :character_sheet_section_id }
validates :value, presence: true,
numericality: true
before_validation :set_slug
end

13
app/models/dice_roll.rb Normal file
View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class DiceRoll < ApplicationRecord
belongs_to :rollable, polymorphic: true
belongs_to :table
validates :result, presence: true,
numericality: { only_integer: true }
def display_text
"#{rollable.character.name} rolled #{rollable.name}: <strong>#{result}</strong>".html_safe
end
end

33
app/models/stat.rb Normal file
View File

@ -0,0 +1,33 @@
# frozen_string_literal: true
class Stat < ApplicationRecord
include Sluggable
belongs_to :character_sheet_section
has_one :character, through: :character_sheet_section
has_many :dice_rolls, as: :rollable, dependent: :destroy
validates :name, presence: true,
length: { maximum: 100 },
uniqueness: { scope: :character_sheet_section_id }
validates :slug, presence: true,
length: { maximum: 100 },
uniqueness: true
validates :value, presence: true,
numericality: true
validate :validate_roll_command
def roll(table)
result = DiceRoller.new(roll_command, stat: self).roll
dice_rolls.create(result:, table:)
result
end
private
def validate_roll_command
return if roll_command.blank?
DiceRoller.new(roll_command).valid?
end
end

View File

@ -4,6 +4,7 @@ class Table < ApplicationRecord
belongs_to :owner, class_name: "User"
belongs_to :game_system
has_many :characters, dependent: :nullify
has_many :dice_rolls, dependent: :destroy
has_many :players, dependent: :destroy
has_many :table_invites, dependent: :destroy
has_many :users, through: :players

View File

@ -9,6 +9,8 @@ class User < ApplicationRecord
has_many :character_sheet_sections, through: :characters
has_many :owned_tables, foreign_key: :owner_id, class_name: "Table"
has_many :players, dependent: :destroy
has_many :counters, through: :character_sheet_sections
has_many :stats, through: :character_sheet_sections
has_many :tables, through: :players
has_rich_text :profile
has_one_attached :avatar do |attachable|

View File

@ -0,0 +1,66 @@
# frozen_string_literal: true
class DiceRoller
def initialize(roll_command, stat: nil)
@roll_command = roll_command
@stat = stat&.value
end
def roll
result = 0
operator = nil
roll_command_parts.each do |part|
case part
when /\A\d*d\d+\z/
operator.nil? ? (result += roll_dice(part)) : (result = result.send(operator, roll_dice(part)))
when /\A\d+\z/
operator.nil? ? (result += part.to_i) : (result = result.send(operator, part.to_i))
when "self"
operator.nil? ? (result += @stat) : (result = result.send(operator, @stat))
when /\A[+\-*\/]\z/
operator = part
end
end
result
end
def valid?
return if @roll_command.blank?
# No repeated math operators
return false if @roll_command.match?(/[+\-*\/]{2,}/)
# No leading or trailing math operators
return false if @roll_command.match?(/\A[+\-*\/]/) || @roll_command.match?(/[+\-*\/]\z/)
@roll_command.match?(
/
\A(
(\d*d\d*) |
([+\-*\/]) |
(\d+) |
(self)
)*\z/xi,
)
end
private
def roll_command_parts
@roll_command.scan(/([+\-*\/])|(\d*d\d+)|(\d+)|(self)/xi)
.flatten
.compact_blank
end
def roll_dice(command)
parts = command.downcase.split("d").compact_blank
die_type = parts.last
dice_number = parts.length > 1 ? parts.first.to_i : 1
result = 0
dice_number.times do
result += rand(1..die_type.to_i)
end
result
end
end

View File

@ -4,12 +4,20 @@
<h4><%= character_sheet_section.name %></h4>
<div class="content">
<div id="<%= dom_id(character_sheet_section) %>_stats" class="stats">
<%= render character_sheet_section.stats %>
</div>
<div id="<%= dom_id(character_sheet_section) %>_counters" class="counters">
<%= render character_sheet_section.counters %>
</div>
<div id="<%= dom_id(character_sheet_section) %>_sections">
<%= render character_sheet_section.character_sheet_subsections %>
</div>
<div id="<%= dom_id(character_sheet_section) %>_add_section">
<%= render partial: "edit_links",
locals: { section: character_sheet_section, parent: character_sheet_section, id: nil } %>
</div>
<% if @editable %>
<div id="<%= dom_id(character_sheet_section) %>_add_section">
<%= render partial: "edit_links",
locals: { section: character_sheet_section, parent: character_sheet_section, id: nil } %>
</div>
<% end %>
</div>
</div>

View File

@ -1,8 +1,14 @@
<%# locals: (section:, parent:, id:) -%>
<%= link_to t(".add_subsection"),
new_character_character_sheet_section_path(@character, parent_section_id: parent&.id),
data: { turbo_stream: true } %>
<% unless id == "character_sheet_add_section" %>
<%= link_to t(".delete_section", name: section.name), character_sheet_section_path(section),
data: { turbo_method: :delete, turbo_confirm: t(".confirm_delete", name: section.name) } %>
new_character_character_sheet_section_path(@character, parent_section_id: parent&.id),
data: { turbo_stream: true } %>
<% if section.present? %>
<%= link_to t(".add_stat"),
new_character_sheet_section_stat_path(section), data: { turbo_stream: true }, class: "add-stat" %>
<%= link_to t(".add_counter"),
new_character_sheet_section_counter_path(section), data: { turbo_stream: true }, class: "add-counter" %>
<% end %>
<% unless id == "character_sheet_add_section" %>
<%= link_to t(".delete_section", name: section.name), character_sheet_section_path(section),
data: { turbo_method: :delete, turbo_confirm: t(".confirm_delete", name: section.name) } %>
<% end %>

View File

@ -2,6 +2,12 @@
<h2><%= @character.name %></h2>
<% if @editable %>
<%= link_to t(".stop_editing"), url_for(editable: false) %>
<% else %>
<%= link_to t(".edit"), url_for(editable: true) %>
<% end %>
<div id="character_sheet">
<% if @sections.any? %>
<%= render @sections %>
@ -10,7 +16,9 @@
<% end %>
</div>
<div id="character_sheet_add_section">
<%= link_to t(".add_section"), new_character_character_sheet_section_path(@character),
data: { turbo_stream: true } %>
</div>
<% if @editable %>
<div id="character_sheet_add_section">
<%= link_to t(".add_section"), new_character_character_sheet_section_path(@character),
data: { turbo_stream: true } %>
</div>
<% end %>

View File

@ -0,0 +1,11 @@
<div class="counter" id="<%= dom_id(counter) %>">
<% if @editable %>
<%= link_to(t(".delete"), counter,
data: { turbo_method: :delete, turbo_confirm: t(".confirm_delete", name: counter.name) }) %>
<% end %>
<h6><%= counter.name %></h6>
<%= form_with model: counter, class: "counter-form",
data: { controller: "auto-update", auto_update_update_url_value: counter_path(counter) } do |f| %>
<%= f.number_field :value %>
<% end %>
</div>

View File

@ -0,0 +1,11 @@
<section class="inset">
<%= form_with model: @counter, url: character_sheet_section_counters_path(@section) do |f| %>
<%= f.hidden_field :character_sheet_section_id, value: @section.id %>
<%= f.label :name %>
<%= f.text_field :name %>
<%= display_form_errors(@section, :name) %>
<%= f.submit button_text %>
<% end %>
</section>

View File

@ -0,0 +1,11 @@
<%= content_target = "#{dom_id(@section)}_counters" %>
<%= turbo_stream.append(content_target) do %>
<%= render @counter %>
<% end %>
<%= form_target = "#{dom_id(@section)}_add_section" %>
<%= turbo_stream.replace(form_target) do %>
<div id="#{dom_id(@section)}_add_section">
<%= render partial: "character_sheet_sections/edit_links", locals: { section: @section, parent: @section.parent_section, id: nil} %>
</div>
<% end %>

View File

@ -0,0 +1 @@
<%= turbo_stream.remove @id %>

View File

@ -0,0 +1,6 @@
<% target = "#{dom_id(@counter.character_sheet_section)}_add_section" %>
<%= turbo_stream.replace(target) do %>
<div id="<%= target %>">
<%= render partial: "form", locals: { button_text: t(".create_counter") } %>
</div>
<% end %>

View File

@ -0,0 +1,3 @@
<%= turbo_stream.replace dom_id(@counter) do %>
<%= render @counter %>
<% end %>

View File

@ -0,0 +1,3 @@
<li class="dice-roll">
<%= dice_roll.display_text %>
</li>

View File

@ -0,0 +1,5 @@
<% content_for :title, @table.name %>
<ul>
<%= render @events %>
</ul>

View File

@ -35,7 +35,8 @@
<main>
<%= render partial: "shared/flash_messages" %>
<%= yield(:submenu) if content_for?(:submenu) %>
<%= yield %>
<%= yield(:main) if content_for?(:main) %>
<%= yield unless content_for?(:main) %>
</main>
</body>
</html>

View File

@ -0,0 +1,20 @@
<% content_for :submenu do %>
<h2><%= @table.name %></h2>
<nav>
<ul>
<li><%= link_to t(".overview"), @table %></li>
<% @characters.each do |character| %>
<li><%= link_to character.name, table_character_character_sheet_sections_path(@table, character) %></li>
<% end %>
<li><%= link_to t(".events"), table_events_path(@table) %></li>
</ul>
</nav>
<% end %>
<% content_for :main do %>
<%= turbo_frame_tag("table-content") do %>
<%= yield %>
<% end %>
<% end %>
<%= render template: "layouts/application" %>

View File

@ -0,0 +1,15 @@
<section class="inset">
<%= form_with model: @stat, url: character_sheet_section_stats_path(@section) do |f| %>
<%= f.hidden_field :character_sheet_section_id, value: @section.id %>
<%= f.label :name %>
<%= f.text_field :name %>
<%= display_form_errors(@section, :name) %>
<%= f.label :roll_command %>
<%= f.text_field :roll_command %>
<%= display_form_errors(@section, :roll_command) %>
<%= f.submit button_text %>
<% end %>
</section>

View File

@ -0,0 +1,11 @@
<div class="stat" id="<%= dom_id(stat) %>">
<% if @editable %>
<%= link_to(t(".delete"), stat,
data: { turbo_method: :delete, turbo_confirm: t(".confirm_delete", name: stat.name) }) %>
<% end %>
<h6><%= stat.name %></h6>
<%= form_with model: stat, class: "stat-form",
data: { controller: "auto-update", auto_update_update_url_value: stat_path(stat) } do |f| %>
<%= f.number_field :value, disabled: !@editable %>
<% end %>
</div>

View File

@ -0,0 +1,11 @@
<%= content_target = "#{dom_id(@section)}_stats" %>
<%= turbo_stream.append(content_target) do %>
<%= render @stat %>
<% end %>
<%= form_target = "#{dom_id(@section)}_add_section" %>
<%= turbo_stream.replace(form_target) do %>
<div id="#{dom_id(@section)}_add_section">
<%= render partial: "character_sheet_sections/edit_links", locals: { section: @section, parent: @section.parent_section, id: nil} %>
</div>
<% end %>

View File

@ -0,0 +1 @@
<%= turbo_stream.remove @id %>

View File

@ -0,0 +1,6 @@
<% target = "#{dom_id(@stat.character_sheet_section)}_add_section" %>
<%= turbo_stream.replace(target) do %>
<div id="<%= target %>">
<%= render partial: "form", locals: { button_text: t(".create_stat") } %>
</div>
<% end %>

View File

@ -0,0 +1,3 @@
<%= turbo_stream.replace dom_id(@stat) do %>
<%= render @stat %>
<% end %>

View File

@ -1,7 +1,5 @@
<% content_for :title, @table.name %>
<h2><%= @table.name %></h2>
<%= link_to t(".invite_user"), new_table_table_invite_path(@table) %>
<%= link_to t(".edit_table"), edit_table_path(@table) %>

View File

@ -7,7 +7,7 @@
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/tables/show.html.erb",
"line": 17,
"line": 15,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(action => Current.user.tables.find(params[:id]).characters, {})",
"render_path": [
@ -15,7 +15,7 @@
"type": "controller",
"class": "TablesController",
"method": "show",
"line": 12,
"line": 13,
"file": "app/controllers/tables_controller.rb",
"rendered": {
"name": "tables/show",
@ -41,7 +41,7 @@
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/character_sheet_sections/index.html.erb",
"line": 7,
"line": 13,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(action => Current.user.characters.find(params[:character_id]).character_sheet_sections.top_level, {})",
"render_path": [
@ -49,7 +49,7 @@
"type": "controller",
"class": "CharacterSheetSectionsController",
"method": "index",
"line": 9,
"line": 10,
"file": "app/controllers/character_sheet_sections_controller.rb",
"rendered": {
"name": "character_sheet_sections/index",
@ -67,8 +67,42 @@
22
],
"note": ""
},
{
"warning_type": "Dynamic Render Path",
"warning_code": 15,
"fingerprint": "8cc5f8414893bc6d142fda5a2a91346bfe171640647dc017315ab21724170912",
"check_name": "Render",
"message": "Render path contains parameter value",
"file": "app/views/events/index.html.erb",
"line": 4,
"link": "https://brakemanscanner.org/docs/warning_types/dynamic_render_path/",
"code": "render(action => Current.user.tables.find(params[:table_id]).dice_rolls.order(:created_at), {})",
"render_path": [
{
"type": "controller",
"class": "EventsController",
"method": "index",
"line": 11,
"file": "app/controllers/events_controller.rb",
"rendered": {
"name": "events/index",
"file": "app/views/events/index.html.erb"
}
}
],
"location": {
"type": "template",
"template": "events/index"
},
"user_input": "params[:table_id]",
"confidence": "Weak",
"cwe_id": [
22
],
"note": ""
}
],
"updated": "2024-06-06 14:18:01 +0100",
"updated": "2024-06-13 13:24:46 +0100",
"brakeman_version": "6.1.2"
}

View File

@ -25,6 +25,9 @@ en:
See you soon,
The Tabletop Companion team
sign_off_html: "<p>See you soon,<br>The Tabletop Companion team</p>"
table:
overview: Overview
events: Events
account_verifications:
show:
success: "Thanks for verifying your email address! You can now log in."
@ -60,7 +63,11 @@ en:
add_subsection: Add subsection
delete_section: Delete %{name}
confirm_delete: Are you sure you want to delete this section?
add_stat: Add stat
add_counter: Add counter
index:
edit: Edit character sheet
stop_editing: Stop editing
character_sheet: "%{name}s character sheet"
no_sections: This character sheet has no content
add_section: Add section
@ -94,6 +101,12 @@ en:
destroy:
success: Deleted “%{name}”
error: Could not delete your character
counters:
counter:
delete: Delete counter
confirm_delete: Are you sure you want to delete %{name}?
new:
create_counter: Create counter
password_resets:
new:
reset_password: Reset your password
@ -129,6 +142,12 @@ en:
destroy:
log_out: Log out
success: "You have signed out."
stats:
stat:
delete: Delete
confirm_delete: Are you sure you want to delete %{name}?
new:
create_stat: Create stat
table_invite_mailer:
invite_new_user:
subject: Youve been invited to join a game on Tabletop Companion!

View File

@ -1,7 +1,7 @@
# frozen_string_literal: true
Rails.application.routes.draw do
default_url_options host: "summonplayer.com"
default_url_options host: "ttcompanion.com"
root "tables#index"
@ -15,12 +15,21 @@ Rails.application.routes.draw do
resources :password_resets, only: [ :new, :create, :edit, :update ]
resources :sessions, only: [ :new, :create, :destroy ]
resources :character_sheet_sections, only: [ :destroy ]
resources :character_sheet_sections, only: [ :destroy ] do
resources :counters, only: [ :new, :create ]
resources :stats, only: [ :new, :create ]
end
resources :characters do
resources :character_sheet_sections, only: [ :index, :new, :create ]
end
resources :counters, only: [ :update, :destroy ]
resources :stats, only: [ :update, :destroy ]
resources :table_invites, only: [ :index, :edit, :update ]
resources :tables do
resources :characters, only: [] do
resources :character_sheet_sections, only: [ :index ]
end
resources :events, only: [ :index ]
resources :table_invites, only: [ :new, :create ]
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class CreateStats < ActiveRecord::Migration[7.1]
def change
create_table :stats do |t|
t.string :name, null: false
t.string :slug, null: false
t.belongs_to :character_sheet_section, null: false, foreign_key: true
t.integer :value, null: false, default: 0
t.string :roll_command
t.timestamps
end
add_check_constraint :stats, "length(name) <= 100", name: "chk_stat_name_max_length"
add_check_constraint :stats, "length(slug) <= 100", name: "chk_stat_slug_max_length"
add_index :stats, :slug, unique: true
add_index :stats, [ :name, :character_sheet_section_id ], unique: true
end
end

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class CreateCounters < ActiveRecord::Migration[7.1]
def change
create_table :counters do |t|
t.string :name, null: false
t.string :slug, null: false
t.integer :value, null: false, default: 0
t.belongs_to :character_sheet_section, null: false, foreign_key: true
t.timestamps
end
add_check_constraint :counters, "length(name) <= 100", name: "chk_counter_name_max_length"
add_check_constraint :counters, "length(slug) <= 100", name: "chk_counter_slug_max_length"
add_index :counters, :slug, unique: true
add_index :counters, [ :name, :character_sheet_section_id ], unique: true
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
class CreateDiceRolls < ActiveRecord::Migration[7.1]
def change
create_table :dice_rolls do |t|
t.references :rollable, polymorphic: true
t.belongs_to :table, null: true, foreign_key: true
t.integer :result, null: false
t.timestamps
end
end
end

45
db/schema.rb generated
View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2024_06_05_175553) do
ActiveRecord::Schema[7.1].define(version: 2024_06_13_072942) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -76,6 +76,31 @@ ActiveRecord::Schema[7.1].define(version: 2024_06_05_175553) do
t.check_constraint "length(name::text) <= 200", name: "chk_character_name_max_length"
end
create_table "counters", force: :cascade do |t|
t.string "name", null: false
t.string "slug", null: false
t.integer "value", default: 0, null: false
t.bigint "character_sheet_section_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["character_sheet_section_id"], name: "index_counters_on_character_sheet_section_id"
t.index ["name", "character_sheet_section_id"], name: "index_counters_on_name_and_character_sheet_section_id", unique: true
t.index ["slug"], name: "index_counters_on_slug", unique: true
t.check_constraint "length(name::text) <= 100", name: "chk_counter_name_max_length"
t.check_constraint "length(slug::text) <= 100", name: "chk_counter_slug_max_length"
end
create_table "dice_rolls", force: :cascade do |t|
t.string "rollable_type"
t.bigint "rollable_id"
t.bigint "table_id"
t.integer "result", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["rollable_type", "rollable_id"], name: "index_dice_rolls_on_rollable"
t.index ["table_id"], name: "index_dice_rolls_on_table_id"
end
create_table "game_systems", force: :cascade do |t|
t.string "name", null: false
t.datetime "created_at", null: false
@ -209,6 +234,21 @@ ActiveRecord::Schema[7.1].define(version: 2024_06_05_175553) do
t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
end
create_table "stats", force: :cascade do |t|
t.string "name", null: false
t.string "slug", null: false
t.bigint "character_sheet_section_id", null: false
t.integer "value", default: 0, null: false
t.string "roll_command"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["character_sheet_section_id"], name: "index_stats_on_character_sheet_section_id"
t.index ["name", "character_sheet_section_id"], name: "index_stats_on_name_and_character_sheet_section_id", unique: true
t.index ["slug"], name: "index_stats_on_slug", unique: true
t.check_constraint "length(name::text) <= 100", name: "chk_stat_name_max_length"
t.check_constraint "length(slug::text) <= 100", name: "chk_stat_slug_max_length"
end
create_table "table_invites", force: :cascade do |t|
t.bigint "table_id", null: false
t.string "email", null: false
@ -261,6 +301,8 @@ ActiveRecord::Schema[7.1].define(version: 2024_06_05_175553) do
add_foreign_key "characters", "game_systems"
add_foreign_key "characters", "tables"
add_foreign_key "characters", "users"
add_foreign_key "counters", "character_sheet_sections"
add_foreign_key "dice_rolls", "tables"
add_foreign_key "players", "tables"
add_foreign_key "players", "users"
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
@ -269,6 +311,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_06_05_175553) do
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
add_foreign_key "stats", "character_sheet_sections"
add_foreign_key "table_invites", "tables"
add_foreign_key "tables", "game_systems"
add_foreign_key "tables", "users", column: "owner_id"

View File

@ -29,7 +29,7 @@ class CharacterSheetSectionsControllerTest < ActionDispatch::IntegrationTest
user = users(:trevor)
sign_in user
assert_difference "CharacterSheetSection.count", -1 do
delete character_sheet_section_url(user.character_sheet_sections.first),
delete character_sheet_section_url(character_sheet_sections(:subsection)),
as: :turbo_stream
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
require "test_helper"
class CountersControllerTest < ActionDispatch::IntegrationTest
test "should render new turbo stream" do
sign_in users(:trevor)
get new_character_sheet_section_counter_url(character_sheet_sections(:counters)), as: :turbo_stream
assert_response :success
end
test "should create counter" do
sign_in users(:trevor)
assert_difference "Counter.count", 1 do
post character_sheet_section_counters_url(character_sheet_sections(:counters)),
params: { counter: { name: "Ammo", character_sheet_section_id: character_sheet_sections(:counters).id } },
as: :turbo_stream
end
end
test "should delete counter" do
sign_in users(:trevor)
assert_difference "Counter.count", -1 do
delete counter_url(Counter.first), as: :turbo_stream
assert_response :success
end
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
require "test_helper"
class StatsControllerTest < ActionDispatch::IntegrationTest
test "should render new turbo stream" do
sign_in users(:trevor)
get new_character_sheet_section_stat_url(character_sheet_sections(:stats)), as: :turbo_stream
assert_response :success
end
test "should create stat" do
sign_in users(:trevor)
assert_difference "Stat.count", 1 do
post character_sheet_section_stats_url(character_sheet_sections(:stats)),
params: { stat: { name: "Wisdom", character_sheet_section_id: character_sheet_sections(:stats).id } },
as: :turbo_stream
end
end
test "should delete stat" do
sign_in users(:trevor)
assert_difference "Stat.count", -1 do
delete stat_url(Stat.first), as: :turbo_stream
assert_response :success
end
end
end

View File

@ -1,3 +1,12 @@
stats:
name: Stats
character: nardren
counters:
name: Status
character: nardren
subsection:
name: Subsection
character: nardren
parent_section: stats

5
test/fixtures/counters.yml vendored Normal file
View File

@ -0,0 +1,5 @@
hp:
name: HP
value: 10
slug: hp
character_sheet_section: counters

4
test/fixtures/dice_rolls.yml vendored Normal file
View File

@ -0,0 +1,4 @@
one:
rollable: strength (Stat)
table: dnd_table
result: 14

6
test/fixtures/stats.yml vendored Normal file
View File

@ -0,0 +1,6 @@
strength:
name: Strength
slug: strength
character_sheet_section: stats
value: 10
roll_command: d20+self

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
require "test_helper"
class DiceRollingTest < ActionDispatch::IntegrationTest
test "rolling a stat should create a dice roll" do
assert_changes("DiceRoll.count", +1) do
stats(:strength).roll(tables(:dnd_table))
end
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
require "test_helper"
class EditCharacterSheetTest < ActionDispatch::IntegrationTest
test "Sheet is only editable when parameter is set" do
user = users(:trevor)
sign_in user
get character_character_sheet_sections_path(characters(:nardren))
assert_response :success
assert_no_match I18n.t("character_sheet_sections.index.add_section"), response.body
get character_character_sheet_sections_path(characters(:nardren), editable: true)
assert_response :success
assert_match I18n.t("character_sheet_sections.index.add_section"), response.body
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
require "test_helper"
class CounterTest < ActiveSupport::TestCase
test "name must exist" do
assert_must_exist(counters(:hp), :name)
end
test "value must exist" do
assert_must_exist(counters(:hp), :value)
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
require "test_helper"
class DiceRollTest < ActiveSupport::TestCase
test "result must exist" do
assert_must_exist dice_rolls(:one), :result
end
end

31
test/models/stat_test.rb Normal file
View File

@ -0,0 +1,31 @@
# frozen_string_literal: true
require "test_helper"
class StatTest < ActiveSupport::TestCase
test "name must exist" do
assert_must_exist(stats(:strength), :name)
end
test "value must exist" do
assert_must_exist(stats(:strength), :value)
end
test "saving generates appropriate slug" do
stat = Stat.create(name: "Foo", character_sheet_section: character_sheet_sections(:subsection))
assert_equal "stats-foo", stat.slug
end
test "generates unique slug always" do
existing_stat = Stat.first
stat = Stat.create(name: existing_stat.name, character_sheet_section: existing_stat.character_sheet_section)
assert_not_equal existing_stat.slug, stat.slug
assert_equal "#{existing_stat.slug}-2", stat.slug
end
test "rolls with roll_command" do
stat = stats(:strength)
stat.roll_command = "1d6"
100.times { assert (1..6).cover?(stat.roll(tables(:dnd_table))) }
end
end

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
require "test_helper"
class DiceRollerTest < ActiveSupport::TestCase
test "correctly validates strings" do
valid_strings = [
"2d6",
"12",
"self",
"d12",
"d20+self",
"8+D8-self",
]
invalid_strings = [
"+",
"sel+10",
"d8++13",
]
valid_strings.each do |roll_command|
assert DiceRoller.new(roll_command).valid?
end
invalid_strings.each do |roll_command|
assert_not DiceRoller.new(roll_command).valid?
end
end
test "rolls appropriate results" do
100.times do
assert (1..6).include? DiceRoller.new("d6").roll
assert (2..12).include? DiceRoller.new("2d6").roll
assert (11..30).include? DiceRoller.new("d20+self", stat: stats(:strength)).roll
end
end
end

View File

@ -11,6 +11,8 @@ class CharacterSheetTest < ApplicationSystemTestCase
click_on I18n.t("characters.show.sheet")
assert_text character.name
click_on I18n.t("character_sheet_sections.index.edit")
click_link(I18n.t("character_sheet_sections.index.add_section"))
fill_in attr_name(CharacterSheetSection, :name), with: "Test Section"
click_button(I18n.t("character_sheet_sections.new.create_section"))
@ -19,5 +21,10 @@ class CharacterSheetTest < ApplicationSystemTestCase
click_link(I18n.t("character_sheet_sections.edit_links.delete_section", name: "Test Section"))
accept_confirm
assert_no_text "Test Section"
first(".add-stat").click
fill_in attr_name(Stat, :name), with: "Test Stat"
click_button I18n.t("stats.new.create_stat")
assert_text "Test Stat"
end
end

13
todo.md
View File

@ -1,8 +1,17 @@
- character sheet on table
- table tabs
- roll on click
- Sheets
- Lists
- Text fields
- Weapons/spells
- Character avatars
- default avatars
- add uuid/slug to characters and any other url-visible ids
- shared/private notes
- Add characters to users/tables
- Character sheets/prototypes
- NPCs
- Character sheets prototypes
- notifications
- chat
- maps
- show errors from invalid sheet bits