A platform is named once: WarpEngine::Platform

The list of platforms lived on `PlatformLink::SUPPORTED_PLATFORMS` — a model
about links to a platform's website — and was copied around it four times in
the admin (`%w[tic80 ebitengine love c64 godot bevy phaser]`), spelled out in
three apipie descriptions, and turned into a class name by string
interpolation in three services:

    "WarpEngine::Platforms::#{platform.camelize}::Service".constantize

Seven places that had to agree, and nothing that made them.

`WarpEngine::Platform` is now that one place. `Platform.names` is the list,
`Platform.find!("godot")` answers with a value object that knows its `label`,
its `expected_kinds` and its updater `service`, and the constantize is gone —
the registry holds the reference. `PublishService` and `BuildsService` ask it,
the admin selects read `Platform.names`, and
`PlatformLink::SUPPORTED_PLATFORMS` stays as an alias of `Platform::NAMES` so
a host pinned to 0.7 keeps working.

`Software` also defends its own value sets now. It validated neither `status`
nor `platform`, so a mistyped platform only surfaced later, at publish time,
as "Unsupported platform" — after the row existed. And `status` had three
different answers depending on where you looked: the admin offered
development/demo/released/archived, the API documentation claimed
"active, inactive", and the database holds the first four. `Software::STATUSES`
is the list, the inclusion validations enforce both, and the documentation
names the values the database actually has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 09:00:55 +02:00
co-authored by Claude Opus 5
parent 19d142ef64
commit f1ad80ed0c
10 changed files with 86 additions and 33 deletions
+9
View File
@@ -12,6 +12,11 @@ Repository: `https://git.teletypegames.org/engines/warp_engine`
- **Catalog domain**: `Software`, `Release`, `ReleaseAsset`, `ExternalLink`,
`PlatformLink`, `SoftwareImage`, `Download` models with soft-delete
semantics and download statistics.
- **Platform registry**: `WarpEngine::Platform` is the one place a platform is
named. `Platform.names` lists them, `Platform.find!("godot")` answers with a
value object that knows its `label`, its `expected_kinds` and its updater
`service`. Reference it instead of writing a platform list of your own —
`PlatformLink::SUPPORTED_PLATFORMS` is kept as an alias of `Platform::NAMES`.
- **CI-callable updater**: your build pipeline uploads artifacts over HTTP
and calls one endpoint — WarpEngine extracts archives, parses metadata
and upserts the catalog records. Supported platforms out of the
@@ -203,6 +208,10 @@ Rails.application.config.to_prepare do
# Where CI drops build artifacts
c.file_container_path = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
# Where the site this catalog belongs to lives. The admin links a software
# to its public page from here; left nil, the link is left out.
c.site_url = ENV["SITE_URL"]
# Your image model (see "Images" below). "Image" is the default.
# c.image_class_name = "Media::Picture"
# c.image_adapter = MyImageLibrary.new
+2 -2
View File
@@ -25,12 +25,12 @@ ActiveAdmin.register WarpEngine::PlatformLink, as: "Platform Link" do
actions
end
filter :platform, as: :select, collection: %w[tic80 ebitengine love c64 godot bevy phaser]
filter :platform, as: :select, collection: WarpEngine::Platform.names
filter :name
form do |f|
f.inputs do
f.input :platform, as: :select, collection: %w[tic80 ebitengine love c64 godot bevy phaser]
f.input :platform, as: :select, collection: WarpEngine::Platform.names
f.input :name
f.input :url
f.input :position, as: :number, input_html: { min: 0 }
+7 -6
View File
@@ -38,13 +38,13 @@ ActiveAdmin.register WarpEngine::Software, as: "Software" do
filter :name
filter :title
filter :author
filter :platform, as: :select, collection: %w[tic80 ebitengine love c64 godot bevy phaser]
filter :status, as: :select, collection: %w[development demo released archived]
filter :platform, as: :select, collection: WarpEngine::Platform.names
filter :status, as: :select, collection: WarpEngine::Software::STATUSES
filter :highlighted
sidebar "Quick Links", only: :show, priority: 0 do
site_url = ENV.fetch("SITE_URL", "https://teletypegames.org")
catalog_url = "#{site_url}/catalog/#{resource.name}"
if WarpEngine.config.site_url.present?
catalog_url = "#{WarpEngine.config.site_url.chomp('/')}/catalog/#{resource.name}"
div style: "margin-bottom:8px;" do
a href: catalog_url, target: "_blank", style: "display:inline-flex;align-items:center;gap:6px;font-weight:bold;color:#5850ec;" do
@@ -52,6 +52,7 @@ ActiveAdmin.register WarpEngine::Software, as: "Software" do
text_node "View on site"
end
end
end
if resource.pipeline
div style: "margin-bottom:8px;" do
@@ -103,8 +104,8 @@ ActiveAdmin.register WarpEngine::Software, as: "Software" do
f.input :name
f.input :title
f.input :author
f.input :platform, as: :select, collection: %w[tic80 ebitengine love c64 godot bevy phaser]
f.input :status, as: :select, collection: %w[development demo released archived]
f.input :platform, as: :select, collection: WarpEngine::Platform.names
f.input :status, as: :select, collection: WarpEngine::Software::STATUSES
f.input :highlighted
f.input :license
f.input :desc, as: :text, input_html: { rows: 4 }
@@ -39,8 +39,8 @@ module WarpEngine
property :desc, String, desc: "Short description"
property :story, String, desc: "Long description / story"
property :license, String, desc: "License type"
property :platform, String, desc: "Platform (tic80, love, ebitengine, c64, godot, bevy, phaser)"
property :status, String, desc: "Status (active, inactive)"
property :platform, String, desc: "Platform (one of WarpEngine::Platform::NAMES)"
property :status, String, desc: "Status (development, demo, released, archived)"
property :highlighted, :boolean, desc: "Currently highlighted"
property :imageUrl, String, desc: "Default image URL"
property :externalLinks, Array, desc: "External links" do
+4 -3
View File
@@ -2,13 +2,14 @@ module WarpEngine
class PlatformLink < ApplicationRecord
self.table_name = "platform_links"
SUPPORTED_PLATFORMS = %w[tic80 ebitengine love c64 godot bevy phaser].freeze
SUPPORTED_PLATFORMS = WarpEngine::Platform::NAMES
validates :name, presence: true
validates :url, presence: true
validates :platform, presence: true, inclusion: { in: SUPPORTED_PLATFORMS }
default_scope { where(deleted_at: nil).order(:position) }
default_scope { where(deleted_at: nil) }
scope :ordered, -> { order(:position) }
def self.ransackable_attributes(auth_object = nil)
%w[created_at deleted_at id name platform position updated_at url]
@@ -19,7 +20,7 @@ module WarpEngine
end
def self.for_platform(platform)
where(platform: platform).to_a
ordered.where(platform: platform).to_a
end
ActiveSupport.run_load_hooks(:warp_engine_platform_link, self)
+5 -2
View File
@@ -2,9 +2,11 @@ module WarpEngine
class Software < ApplicationRecord
self.table_name = "softwares"
STATUSES = %w[development demo released archived].freeze
belongs_to :owner, polymorphic: true, optional: true
has_many :software_images, foreign_key: :software_id, dependent: :destroy
has_many :software_images, -> { ordered }, foreign_key: :software_id, dependent: :destroy
has_many :images, through: :software_images
has_many :releases, foreign_key: :software_id
has_many :downloads, through: :releases
@@ -17,7 +19,8 @@ module WarpEngine
validates :name, presence: true, uniqueness: true
validates :title, presence: true
validates :platform, presence: true
validates :platform, presence: true, inclusion: { in: WarpEngine::Platform::NAMES }
validates :status, inclusion: { in: STATUSES }, allow_blank: true
default_scope { where(deleted_at: nil) }
+3 -8
View File
@@ -1,12 +1,8 @@
module WarpEngine
class BuildsService
def index
platforms = WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.each_with_object({}) do |platform, hash|
service_class = "WarpEngine::Platforms::#{platform.camelize}::Service".constantize
hash[platform] = {
label: service_class.label,
kinds: service_class.expected_kinds
}
platforms = WarpEngine::Platform.all.each_with_object({}) do |platform, hash|
hash[platform.name] = { label: platform.label, kinds: platform.expected_kinds }
end
all_kinds = WarpEngine::ReleaseAsset::KINDS
@@ -16,8 +12,7 @@ module WarpEngine
def show(name)
software = WarpEngine::Software.find_by!(name: name)
service_class = "WarpEngine::Platforms::#{software.platform.camelize}::Service".constantize
expected = service_class.expected_kinds
expected = WarpEngine::Platform.find!(software.platform).expected_kinds
releases = software.releases.includes(:release_assets).order(updated_at: :desc)
+1 -6
View File
@@ -4,12 +4,7 @@ module WarpEngine
NOTIFICATION = "warp_engine.publish".freeze
def publish(input)
unless WarpEngine::PlatformLink::SUPPORTED_PLATFORMS.include?(input.platform)
raise ArgumentError, "Unsupported platform: #{input.platform}"
end
release = "WarpEngine::Platforms::#{input.platform.camelize}::Service".constantize
.new.update(input.name, input.version)
release = WarpEngine::Platform.find!(input.platform).service.update(input.name, input.version)
ActiveSupport::Notifications.instrument(
NOTIFICATION,
+1
View File
@@ -5,6 +5,7 @@ require "apipie-rails"
require "warp_engine/version"
require "warp_engine/configuration"
require "warp_engine/platform"
require "warp_engine/storage"
require "warp_engine/access"
require "warp_engine/images"
+48
View File
@@ -0,0 +1,48 @@
module WarpEngine
class Platform
NAMES = %w[tic80 ebitengine love c64 godot bevy phaser].freeze
class << self
def names = NAMES
def all = NAMES.map { |name| new(name) }
def exists?(name) = NAMES.include?(name.to_s)
def find(name)
return nil unless exists?(name)
new(name.to_s)
end
def find!(name)
find(name) || raise(ArgumentError, "Unsupported platform: #{name}")
end
end
attr_reader :name
def initialize(name)
@name = name.to_s
end
def service_class
"WarpEngine::Platforms::#{name.camelize}::Service".constantize
end
def service = service_class.new
def label = service_class.label
def expected_kinds = service_class.expected_kinds
def to_s = name
def ==(other)
other.is_a?(Platform) ? name == other.name : name == other.to_s
end
alias eql? ==
def hash = name.hash
end
end