Phase 0: in-place decoupling before WarpEngine extraction

- FileService/DownloadService: lazy container-path resolution instead of
  class-load-time realpath (boot no longer requires /softwares to exist)
- downloadCount/totalDownloads: single grouped query passed as Blueprinter
  option instead of per-release association counts (fixes N+1)
- admin Images: Member coupling replaced with ImageUsage owner registry
  (precursor of the WarpEngine.config.image_owners hook)
- Image: UPLOAD_PATH constant replaced with call-time upload_path accessor
- proper test environment (config/environments/test.rb, softwares_test DB,
  hosts.clear) — suite previously ran against the development DB
- spec fixes: case-insensitive uniqueness matchers (MySQL ai_ci collation),
  DB-cascade has_many expectations, PlatformLink::SUPPORTED_PLATFORMS
- JSON baselines of /api/software and /api/builds for post-extraction diffing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 18:39:20 +02:00
co-authored by Claude Fable 5
parent 0f13c19fc8
commit bf921c589d
19 changed files with 851 additions and 32 deletions
+6 -5
View File
@@ -3,13 +3,14 @@ ActiveAdmin.register Image do
menu priority: 5, label: "🖼️ Images"
used_ids = -> { SoftwareImage.distinct.pluck(:image_id) + ImageUsage.used_image_ids }
scope :all, default: true
scope("In use") { |scope| scope.where(id: SoftwareImage.select(:image_id)).or(scope.where(id: Member.where.not(image_id: nil).select(:image_id))) }
scope("Orphan") { |scope| scope.where.not(id: SoftwareImage.select(:image_id)).where.not(id: Member.where.not(image_id: nil).select(:image_id)) }
scope("In use") { |scope| scope.where(id: used_ids.call) }
scope("Orphan") { |scope| scope.where.not(id: used_ids.call) }
batch_action :delete_orphans, confirm: "Delete all selected orphan images and their files?" do |ids|
in_use_ids = SoftwareImage.where(image_id: ids).pluck(:image_id) + Member.where(image_id: ids).pluck(:image_id)
orphan_ids = ids.map(&:to_i) - in_use_ids
orphan_ids = ids.map(&:to_i) - used_ids.call
Image.where(id: orphan_ids).find_each do |img|
File.delete(img.file_path) if File.exist?(img.file_path)
img.destroy
@@ -30,7 +31,7 @@ ActiveAdmin.register Image do
column(:usage) do |img|
uses = []
uses << "#{img.software_images.size} software(s)" if img.software_images.any?
uses << "member" if Member.where(image_id: img.id).exists?
uses.concat(ImageUsage.usage_labels_for(img))
uses.any? ? uses.join(", ") : status_tag("orphan", class: "warning")
end
column :created_at
+6 -3
View File
@@ -1,5 +1,8 @@
class Image < ApplicationRecord
UPLOAD_PATH = ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
# Híváskor kiértékelve, hogy a path ne class-load-kor rögzüljön.
def self.upload_path
ENV.fetch("IMAGE_CONTAINER_PATH", "/images")
end
has_many :software_images, dependent: :restrict_with_error
@@ -14,13 +17,13 @@ class Image < ApplicationRecord
end
def file_path
File.join(UPLOAD_PATH, filename.to_s)
File.join(self.class.upload_path, filename.to_s)
end
private
def process_upload
FileUtils.mkdir_p(UPLOAD_PATH)
FileUtils.mkdir_p(self.class.upload_path)
self.original_filename = file_upload.original_filename
self.content_type = file_upload.content_type.presence || "application/octet-stream"
ext = File.extname(file_upload.original_filename)
@@ -26,5 +26,8 @@ class ReleaseSerializer < Blueprinter::Base
field(:assets) do |r|
assets_of(r).map { |a| { kind: a.kind, path: a.path.gsub(FILE_PATH_FROM, FILE_PATH_TO) } }
end
field(:downloadCount) { |r| r.association(:downloads).loaded? ? r.downloads.size : r.downloads.count }
field(:downloadCount) do |r, opts|
counts = opts[:download_counts]
counts ? counts.fetch(r.id, 0) : r.downloads.count
end
end
@@ -1,7 +1,7 @@
class SoftwareDetailSerializer < Blueprinter::Base
field(:software) { |sw, _| SoftwareSerializer.render_as_hash(sw) }
field(:releases) { |_, opts| ReleaseSerializer.render_as_hash(opts[:releases]) }
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest]) : nil }
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable]) : nil }
field(:releases) { |_, opts| ReleaseSerializer.render_as_hash(opts[:releases], download_counts: opts[:download_counts]) }
field(:latestRelease) { |_, opts| opts[:latest] ? ReleaseSerializer.render_as_hash(opts[:latest], download_counts: opts[:download_counts]) : nil }
field(:webPlayableRelease) { |_, opts| opts[:web_playable] ? ReleaseSerializer.render_as_hash(opts[:web_playable], download_counts: opts[:download_counts]) : nil }
field(:totalDownloads) { |_, opts| opts[:total_downloads] || 0 }
end
+12 -5
View File
@@ -1,15 +1,22 @@
class DownloadService
CONTAINER_BASE = ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
BASE_PATH = Pathname.new(CONTAINER_BASE).realpath
def self.container_base
ENV.fetch("FILE_CONTAINER_PATH", "/softwares")
end
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
def self.base_path
Pathname.new(container_base).realpath
end
def create(path:, ip:, user_agent:, referer:)
sanitized = path.to_s
full_path = BASE_PATH.join(sanitized).realpath
return nil unless full_path.to_s.start_with?(BASE_PATH.to_s)
base_path = self.class.base_path
full_path = base_path.join(sanitized).realpath
return nil unless full_path.to_s.start_with?(base_path.to_s)
return nil unless File.file?(full_path)
escaped = sanitized.gsub("%", "\\%").gsub("_", "\\_")
asset = ReleaseAsset.find_by(path: File.join(CONTAINER_BASE, sanitized)) ||
asset = ReleaseAsset.find_by(path: File.join(self.class.container_base, sanitized)) ||
ReleaseAsset.where("path LIKE ?", "%#{escaped}%").first
Download.create!(
+10 -3
View File
@@ -1,8 +1,11 @@
class FileService
BASE_PATH = Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares")).realpath
# Lazy: a container path csak az első használatkor kötelező, boot/teszt közben nem.
def self.base_path
Pathname.new(ENV.fetch("FILE_CONTAINER_PATH", "/softwares")).realpath
end
def show(input)
full_path = BASE_PATH.join(input.path.to_s)
full_path = base_path.join(input.path.to_s)
return FileResultDto.not_found unless safe_path?(full_path)
if File.directory?(full_path)
@@ -20,7 +23,11 @@ class FileService
private
def base_path
@base_path ||= self.class.base_path
end
def safe_path?(path)
File.exist?(path) && Pathname.new(path).realpath.to_s.start_with?(BASE_PATH.to_s)
File.exist?(path) && Pathname.new(path).realpath.to_s.start_with?(base_path.to_s)
end
end
+30
View File
@@ -0,0 +1,30 @@
# A katalóguson kívüli modellek regisztrálják ide, hogy mely Image rekordokat
# használják. A leendő WarpEngine.config.image_owners hook elődje: az admin
# Images oldal In use/Orphan logikája ezen keresztül marad domain-független.
#
# Owner kontraktus:
# image_ids: -> { Array<Integer> } — az owner által használt image id-k
# usage_label: ->(image) { String vagy nil } — megjelenítendő címke, ha használja
module ImageUsage
Owner = Struct.new(:label, :image_ids, :usage_label, keyword_init: true)
def self.owners
@owners ||= []
end
def self.reset!
@owners = []
end
def self.register(label:, image_ids:, usage_label:)
owners << Owner.new(label:, image_ids:, usage_label:)
end
def self.used_image_ids
owners.flat_map { |o| o.image_ids.call }
end
def self.usage_labels_for(image)
owners.filter_map { |o| o.usage_label.call(image) }
end
end
@@ -8,7 +8,7 @@ class SoftwareHighlightedService
.first
return nil unless software
releases = Release.includes(:downloads, :release_assets).where(software_id: software.id).to_a
build_response(software, releases)
releases = Release.includes(:release_assets).where(software_id: software.id).to_a
build_response(software, releases, download_counts_for(releases.map(&:id)))
end
end
@@ -1,18 +1,25 @@
module SoftwareResponseBuilder
private
def build_response(software, releases)
def build_response(software, releases, download_counts)
sorted = releases.sort_by { |r| r.created_at || Time.at(0) }.reverse
latest = sorted.reject { |r| r.version.to_s.start_with?("dev-") }.first
# web-playable, ha az utolsó (stabil) release-nek van webes assetje
web_playable = latest if latest&.release_assets&.any? { |a| a.kind == "html" }
total_downloads = releases.sum { |r| r.association(:downloads).loaded? ? r.downloads.size : 0 }
total_downloads = releases.sum { |r| download_counts.fetch(r.id, 0) }
SoftwareDetailSerializer.render_as_hash(software,
releases: sorted,
latest: latest,
web_playable: web_playable,
total_downloads: total_downloads
total_downloads: total_downloads,
download_counts: download_counts
)
end
# Egyetlen csoportosított lekérdezés release-enkénti letöltésszámokhoz (N+1 helyett).
def download_counts_for(release_ids)
return {} if release_ids.empty?
Download.where(release_id: release_ids).group(:release_id).count
end
end
+3 -2
View File
@@ -2,7 +2,8 @@ class SoftwareService
include SoftwareResponseBuilder
def index
softwares = Software.includes(releases: [ :downloads, :release_assets ]).includes(:external_links, :software_images).all
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a) } }
softwares = Software.includes(releases: [ :release_assets ]).includes(:external_links, :software_images).all
counts = download_counts_for(softwares.flat_map { |sw| sw.releases.map(&:id) })
{ softwares: softwares.map { |sw| build_response(sw, sw.releases.to_a, counts) } }
end
end
+4
View File
@@ -11,6 +11,10 @@ development:
<<: *default
database: <%= ENV.fetch("DB_NAME", "softwares") %>
test:
<<: *default
database: <%= ENV.fetch("DB_NAME", "softwares") %>_test
production:
<<: *default
database: <%= ENV.fetch("DB_NAME", "softwares") %>
+15
View File
@@ -0,0 +1,15 @@
Rails.application.configure do
config.eager_load = false
config.consider_all_requests_local = true
config.cache_classes = true
config.action_controller.perform_caching = false
config.action_dispatch.show_exceptions = :rescuable
config.active_support.deprecation = :stderr
config.log_level = :warn
# Rack::Test example.org hostját ne blokkolja a host authorization
config.hosts.clear
end
@@ -0,0 +1,9 @@
# TTG-specifikus image-használók regisztrációja. to_prepare: reload után is újrafut.
Rails.application.config.to_prepare do
ImageUsage.reset!
ImageUsage.register(
label: "member",
image_ids: -> { Member.where.not(image_id: nil).distinct.pluck(:image_id) },
usage_label: ->(image) { "member" if Member.where(image_id: image.id).exists? }
)
end
@@ -13,6 +13,23 @@ RSpec.describe Api::SoftwareController, type: :request do
expect(json["softwares"].length).to eq(1)
end
it "returns per-release and total download counts" do
software = create(:software)
release = create(:release, software: software)
other = create(:release, software: software)
create_list(:download, 3, release: release)
create(:download, release: other)
get "/api/software"
json = JSON.parse(response.body)
sw = json["softwares"].first
expect(sw["totalDownloads"]).to eq(4)
counts = sw["releases"].to_h { |r| [ r["id"], r["downloadCount"] ] }
expect(counts[release.id]).to eq(3)
expect(counts[other.id]).to eq(1)
end
it "excludes soft-deleted software" do
create(:software, deleted_at: Time.current)
+4 -1
View File
@@ -1,6 +1,9 @@
require "rails_helper"
RSpec.describe Member, type: :model do
subject { build(:member) }
it { should validate_presence_of(:nick) }
it { should validate_uniqueness_of(:nick) }
# MySQL utf8mb4_0900_ai_ci collation: az egyediség DB-szinten case-insensitive
it { should validate_uniqueness_of(:nick).case_insensitive }
end
+7 -3
View File
@@ -1,13 +1,17 @@
require "rails_helper"
RSpec.describe Software, type: :model do
subject { build(:software) }
it { should validate_presence_of(:name) }
it { should validate_presence_of(:title) }
it { should validate_presence_of(:platform) }
it { should validate_uniqueness_of(:name) }
# MySQL utf8mb4_0900_ai_ci collation: az egyediség DB-szinten case-insensitive
it { should validate_uniqueness_of(:name).case_insensitive }
it { should have_many(:releases).dependent(:destroy) }
it { should have_many(:external_links).dependent(:destroy) }
# a törlést a DB-szintű ON DELETE CASCADE végzi, a modellen nincs dependent opció
it { should have_many(:releases) }
it { should have_many(:external_links) }
it { should have_many(:software_images).dependent(:destroy) }
describe "default scope" do
@@ -19,7 +19,7 @@ RSpec.describe BuildsService do
end
it "includes all supported platforms" do
UpdateService::SUPPORTED_PLATFORMS.each do |platform|
PlatformLink::SUPPORTED_PLATFORMS.each do |platform|
expect(result[:platforms]).to have_key(platform)
end
end
@@ -0,0 +1,79 @@
{
"platforms": {
"tic80": {
"label": "TIC-80",
"kinds": [
"cartridge",
"source",
"html",
"docs",
"win_x64",
"linux_x64",
"mac_x64"
]
},
"ebitengine": {
"label": "Ebitengine",
"kinds": [
"html",
"win_x86",
"win_x64",
"linux_x64",
"mac_x64",
"mac_arm64"
]
},
"love": {
"label": "L\u00d6VE",
"kinds": [
"html",
"win_x64",
"linux_x64",
"mac_universal"
]
},
"c64": {
"label": "C64",
"kinds": [
"cartridge"
]
},
"godot": {
"label": "Godot",
"kinds": [
"html",
"win_x86",
"win_x64",
"linux_x64",
"mac_universal"
]
},
"bevy": {
"label": "Bevy",
"kinds": [
"html",
"win_x64",
"linux_x64"
]
},
"phaser": {
"label": "Phaser",
"kinds": [
"html"
]
}
},
"allKinds": [
"cartridge",
"source",
"html",
"docs",
"win_x86",
"win_x64",
"linux_x86",
"linux_x64",
"mac_x64",
"mac_arm64",
"mac_universal"
]
}
@@ -0,0 +1,629 @@
{
"softwares": [
{
"latestRelease": {
"assets": [
{
"kind": "cartridge",
"path": "/file/impostor-1.0.tic"
},
{
"kind": "source",
"path": "/file/impostor-1.0.lua"
},
{
"kind": "html",
"path": "/file/impostor-1.0"
},
{
"kind": "docs",
"path": "/file/impostor-1.0-docs"
}
],
"cartridgePath": "/file/impostor-1.0.tic",
"createdAt": "2026-04-30T15:56:12.781Z",
"deletedAt": null,
"docsFolderPath": "/file/impostor-1.0-docs",
"downloadCount": 0,
"htmlFolderPath": "/file/impostor-1.0",
"id": 96,
"softwareId": 19,
"sourcePath": "/file/impostor-1.0.lua",
"updatedAt": "2026-04-30T15:56:12.781Z",
"version": "1.0"
},
"releases": [
{
"assets": [
{
"kind": "cartridge",
"path": "/file/impostor-1.0.tic"
},
{
"kind": "source",
"path": "/file/impostor-1.0.lua"
},
{
"kind": "html",
"path": "/file/impostor-1.0"
},
{
"kind": "docs",
"path": "/file/impostor-1.0-docs"
}
],
"cartridgePath": "/file/impostor-1.0.tic",
"createdAt": "2026-04-30T15:56:12.781Z",
"deletedAt": null,
"docsFolderPath": "/file/impostor-1.0-docs",
"downloadCount": 0,
"htmlFolderPath": "/file/impostor-1.0",
"id": 96,
"softwareId": 19,
"sourcePath": "/file/impostor-1.0.lua",
"updatedAt": "2026-04-30T15:56:12.781Z",
"version": "1.0"
},
{
"assets": [
{
"kind": "cartridge",
"path": "/file/impostor-1.0-beta2.tic"
},
{
"kind": "source",
"path": "/file/impostor-1.0-beta2.lua"
},
{
"kind": "html",
"path": "/file/impostor-1.0-beta2"
},
{
"kind": "docs",
"path": "/file/impostor-1.0-beta2-docs"
}
],
"cartridgePath": "/file/impostor-1.0-beta2.tic",
"createdAt": "2026-04-09T19:55:42.764Z",
"deletedAt": null,
"docsFolderPath": "/file/impostor-1.0-beta2-docs",
"downloadCount": 0,
"htmlFolderPath": "/file/impostor-1.0-beta2",
"id": 85,
"softwareId": 19,
"sourcePath": "/file/impostor-1.0-beta2.lua",
"updatedAt": "2026-04-09T19:55:42.764Z",
"version": "1.0-beta2"
},
{
"assets": [
{
"kind": "cartridge",
"path": "/file/impostor-1.0-beta1.tic"
},
{
"kind": "source",
"path": "/file/impostor-1.0-beta1.lua"
},
{
"kind": "html",
"path": "/file/impostor-1.0-beta1"
},
{
"kind": "docs",
"path": "/file/impostor-1.0-beta1-docs"
}
],
"cartridgePath": "/file/impostor-1.0-beta1.tic",
"createdAt": "2026-03-22T22:49:54.926Z",
"deletedAt": null,
"docsFolderPath": "/file/impostor-1.0-beta1-docs",
"downloadCount": 0,
"htmlFolderPath": "/file/impostor-1.0-beta1",
"id": 77,
"softwareId": 19,
"sourcePath": "/file/impostor-1.0-beta1.lua",
"updatedAt": "2026-03-22T22:49:54.926Z",
"version": "1.0-beta1"
},
{
"assets": [
{
"kind": "cartridge",
"path": "/file/impostor-1.0-alpha.tic"
},
{
"kind": "source",
"path": "/file/impostor-1.0-alpha.lua"
},
{
"kind": "html",
"path": "/file/impostor-1.0-alpha"
},
{
"kind": "docs",
"path": "/file/impostor-1.0-alpha-docs"
}
],
"cartridgePath": "/file/impostor-1.0-alpha.tic",
"createdAt": "2026-03-20T14:31:12.319Z",
"deletedAt": null,
"docsFolderPath": "/file/impostor-1.0-alpha-docs",
"downloadCount": 0,
"htmlFolderPath": "/file/impostor-1.0-alpha",
"id": 71,
"softwareId": 19,
"sourcePath": "/file/impostor-1.0-alpha.lua",
"updatedAt": "2026-03-20T14:31:12.319Z",
"version": "1.0-alpha"
}
],
"software": {
"author": "Teletype Games",
"createdAt": "2026-03-04T14:04:42.676Z",
"deletedAt": null,
"desc": "Life of a programmer",
"externalLinks": [
{
"createdAt": "0001-01-01T00:00:00Z",
"deletedAt": null,
"id": 1,
"label": "Source Code",
"softwareId": 19,
"updatedAt": "0001-01-01T00:00:00Z",
"url": "https://git.teletype.hu/games/impostor/"
},
{
"createdAt": "0001-01-01T00:00:00Z",
"deletedAt": null,
"id": 2,
"label": "Itch.io page",
"softwareId": 19,
"updatedAt": "0001-01-01T00:00:00Z",
"url": "https://teletypegames.itch.io/definitely-not-an-impostor"
}
],
"highlighted": true,
"id": 19,
"imageUrl": null,
"images": [],
"license": "MIT License",
"name": "impostor",
"platform": "tic80",
"platformLinks": [],
"status": "released",
"story": "Norman is an average simulation engineer living a dull but stable life. Hes exactly where he belongsthough he doesnt always feel that way. Follow his mostly work-driven life and discover the excitement within it. Just dont be surprised if you find more than expected.",
"title": "Definitely not an Impostor",
"updatedAt": "2026-04-30T21:15:03.809Z"
},
"totalDownloads": 0,
"webPlayableRelease": {
"assets": [
{
"kind": "cartridge",
"path": "/file/impostor-1.0.tic"
},
{
"kind": "source",
"path": "/file/impostor-1.0.lua"
},
{
"kind": "html",
"path": "/file/impostor-1.0"
},
{
"kind": "docs",
"path": "/file/impostor-1.0-docs"
}
],
"cartridgePath": "/file/impostor-1.0.tic",
"createdAt": "2026-04-30T15:56:12.781Z",
"deletedAt": null,
"docsFolderPath": "/file/impostor-1.0-docs",
"downloadCount": 0,
"htmlFolderPath": "/file/impostor-1.0",
"id": 96,
"softwareId": 19,
"sourcePath": "/file/impostor-1.0.lua",
"updatedAt": "2026-04-30T15:56:12.781Z",
"version": "1.0"
}
},
{
"latestRelease": {
"assets": [
{
"kind": "cartridge",
"path": "/file/bombexpert-0.2.tic"
},
{
"kind": "source",
"path": "/file/bombexpert-0.2.lua"
},
{
"kind": "html",
"path": "/file/bombexpert-0.2"
},
{
"kind": "docs",
"path": "/file/bombexpert-0.2-docs"
}
],
"cartridgePath": "/file/bombexpert-0.2.tic",
"createdAt": "2026-03-04T14:05:54.728Z",
"deletedAt": null,
"docsFolderPath": "/file/bombexpert-0.2-docs",
"downloadCount": 0,
"htmlFolderPath": "/file/bombexpert-0.2",
"id": 52,
"softwareId": 20,
"sourcePath": "/file/bombexpert-0.2.lua",
"updatedAt": "2026-03-04T14:05:54.728Z",
"version": "0.2"
},
"releases": [
{
"assets": [
{
"kind": "cartridge",
"path": "/file/bombexpert-0.2.tic"
},
{
"kind": "source",
"path": "/file/bombexpert-0.2.lua"
},
{
"kind": "html",
"path": "/file/bombexpert-0.2"
},
{
"kind": "docs",
"path": "/file/bombexpert-0.2-docs"
}
],
"cartridgePath": "/file/bombexpert-0.2.tic",
"createdAt": "2026-03-04T14:05:54.728Z",
"deletedAt": null,
"docsFolderPath": "/file/bombexpert-0.2-docs",
"downloadCount": 0,
"htmlFolderPath": "/file/bombexpert-0.2",
"id": 52,
"softwareId": 20,
"sourcePath": "/file/bombexpert-0.2.lua",
"updatedAt": "2026-03-04T14:05:54.728Z",
"version": "0.2"
}
],
"software": {
"author": "Zsolt Tasnadi",
"createdAt": "2026-03-04T14:05:54.725Z",
"deletedAt": null,
"desc": "Simple BombExpert for TIC-80",
"externalLinks": [],
"highlighted": false,
"id": 20,
"imageUrl": null,
"images": [],
"license": "MIT License",
"name": "bombexpert",
"platform": "tic80",
"platformLinks": [],
"status": "archived",
"story": "",
"title": "BombExpert",
"updatedAt": "2026-03-04T14:05:54.725Z"
},
"totalDownloads": 0,
"webPlayableRelease": {
"assets": [
{
"kind": "cartridge",
"path": "/file/bombexpert-0.2.tic"
},
{
"kind": "source",
"path": "/file/bombexpert-0.2.lua"
},
{
"kind": "html",
"path": "/file/bombexpert-0.2"
},
{
"kind": "docs",
"path": "/file/bombexpert-0.2-docs"
}
],
"cartridgePath": "/file/bombexpert-0.2.tic",
"createdAt": "2026-03-04T14:05:54.728Z",
"deletedAt": null,
"docsFolderPath": "/file/bombexpert-0.2-docs",
"downloadCount": 0,
"htmlFolderPath": "/file/bombexpert-0.2",
"id": 52,
"softwareId": 20,
"sourcePath": "/file/bombexpert-0.2.lua",
"updatedAt": "2026-03-04T14:05:54.728Z",
"version": "0.2"
}
},
{
"latestRelease": {
"assets": [
{
"kind": "html",
"path": "/file/ebitenginedemo-1.0.0"
}
],
"cartridgePath": "",
"createdAt": "2026-03-04T14:06:36.626Z",
"deletedAt": null,
"docsFolderPath": "",
"downloadCount": 0,
"htmlFolderPath": "/file/ebitenginedemo-1.0.0",
"id": 53,
"softwareId": 21,
"sourcePath": "",
"updatedAt": "2026-03-04T14:06:36.626Z",
"version": "1.0.0"
},
"releases": [
{
"assets": [
{
"kind": "html",
"path": "/file/ebitenginedemo-1.0.0"
}
],
"cartridgePath": "",
"createdAt": "2026-03-04T14:06:36.626Z",
"deletedAt": null,
"docsFolderPath": "",
"downloadCount": 0,
"htmlFolderPath": "/file/ebitenginedemo-1.0.0",
"id": 53,
"softwareId": 21,
"sourcePath": "",
"updatedAt": "2026-03-04T14:06:36.626Z",
"version": "1.0.0"
}
],
"software": {
"author": "Teletype Games",
"createdAt": "2026-03-04T14:06:36.623Z",
"deletedAt": null,
"desc": "It's a simple demo program in Ebitengine",
"externalLinks": [],
"highlighted": false,
"id": 21,
"imageUrl": null,
"images": [],
"license": "MIT License",
"name": "ebitenginedemo",
"platform": "ebitengine",
"platformLinks": [],
"status": "demo",
"story": "",
"title": "Ebitengine Demo",
"updatedAt": "2026-03-04T14:06:36.623Z"
},
"totalDownloads": 0,
"webPlayableRelease": {
"assets": [
{
"kind": "html",
"path": "/file/ebitenginedemo-1.0.0"
}
],
"cartridgePath": "",
"createdAt": "2026-03-04T14:06:36.626Z",
"deletedAt": null,
"docsFolderPath": "",
"downloadCount": 0,
"htmlFolderPath": "/file/ebitenginedemo-1.0.0",
"id": 53,
"softwareId": 21,
"sourcePath": "",
"updatedAt": "2026-03-04T14:06:36.626Z",
"version": "1.0.0"
}
},
{
"latestRelease": {
"assets": [
{
"kind": "cartridge",
"path": "/file/mranderson-0.1.tic"
},
{
"kind": "source",
"path": "/file/mranderson-0.1.lua"
},
{
"kind": "html",
"path": "/file/mranderson-0.1"
},
{
"kind": "docs",
"path": "/file/mranderson-0.1-docs"
}
],
"cartridgePath": "/file/mranderson-0.1.tic",
"createdAt": "2026-03-04T14:06:58.912Z",
"deletedAt": null,
"docsFolderPath": "/file/mranderson-0.1-docs",
"downloadCount": 0,
"htmlFolderPath": "/file/mranderson-0.1",
"id": 54,
"softwareId": 22,
"sourcePath": "/file/mranderson-0.1.lua",
"updatedAt": "2026-03-04T14:06:58.912Z",
"version": "0.1"
},
"releases": [
{
"assets": [
{
"kind": "cartridge",
"path": "/file/mranderson-0.1.tic"
},
{
"kind": "source",
"path": "/file/mranderson-0.1.lua"
},
{
"kind": "html",
"path": "/file/mranderson-0.1"
},
{
"kind": "docs",
"path": "/file/mranderson-0.1-docs"
}
],
"cartridgePath": "/file/mranderson-0.1.tic",
"createdAt": "2026-03-04T14:06:58.912Z",
"deletedAt": null,
"docsFolderPath": "/file/mranderson-0.1-docs",
"downloadCount": 0,
"htmlFolderPath": "/file/mranderson-0.1",
"id": 54,
"softwareId": 22,
"sourcePath": "/file/mranderson-0.1.lua",
"updatedAt": "2026-03-04T14:06:58.912Z",
"version": "0.1"
}
],
"software": {
"author": "Zsolt Tasnadi",
"createdAt": "2026-03-04T14:06:58.907Z",
"deletedAt": null,
"desc": "Life of a programmer in the Vector",
"externalLinks": [],
"highlighted": false,
"id": 22,
"imageUrl": null,
"images": [],
"license": "MIT License",
"name": "mranderson",
"platform": "tic80",
"platformLinks": [],
"status": "development",
"story": "",
"title": "Mr Anderson's Adventure",
"updatedAt": "2026-03-04T14:06:58.907Z"
},
"totalDownloads": 0,
"webPlayableRelease": {
"assets": [
{
"kind": "cartridge",
"path": "/file/mranderson-0.1.tic"
},
{
"kind": "source",
"path": "/file/mranderson-0.1.lua"
},
{
"kind": "html",
"path": "/file/mranderson-0.1"
},
{
"kind": "docs",
"path": "/file/mranderson-0.1-docs"
}
],
"cartridgePath": "/file/mranderson-0.1.tic",
"createdAt": "2026-03-04T14:06:58.912Z",
"deletedAt": null,
"docsFolderPath": "/file/mranderson-0.1-docs",
"downloadCount": 0,
"htmlFolderPath": "/file/mranderson-0.1",
"id": 54,
"softwareId": 22,
"sourcePath": "/file/mranderson-0.1.lua",
"updatedAt": "2026-03-04T14:06:58.912Z",
"version": "0.1"
}
},
{
"latestRelease": {
"assets": [
{
"kind": "html",
"path": "/file/love2ddemo-1.0.0"
}
],
"cartridgePath": "",
"createdAt": "2026-03-04T14:09:14.425Z",
"deletedAt": null,
"docsFolderPath": "",
"downloadCount": 0,
"htmlFolderPath": "/file/love2ddemo-1.0.0",
"id": 55,
"softwareId": 23,
"sourcePath": "",
"updatedAt": "2026-03-04T14:09:14.425Z",
"version": "1.0.0"
},
"releases": [
{
"assets": [
{
"kind": "html",
"path": "/file/love2ddemo-1.0.0"
}
],
"cartridgePath": "",
"createdAt": "2026-03-04T14:09:14.425Z",
"deletedAt": null,
"docsFolderPath": "",
"downloadCount": 0,
"htmlFolderPath": "/file/love2ddemo-1.0.0",
"id": 55,
"softwareId": 23,
"sourcePath": "",
"updatedAt": "2026-03-04T14:09:14.425Z",
"version": "1.0.0"
}
],
"software": {
"author": "Teletype Games",
"createdAt": "2026-03-04T14:09:14.421Z",
"deletedAt": null,
"desc": "It's a simple demo program in Love2D",
"externalLinks": [],
"highlighted": false,
"id": 23,
"imageUrl": null,
"images": [],
"license": "MIT License",
"name": "love2ddemo",
"platform": "love",
"platformLinks": [],
"status": "demo",
"story": "",
"title": "Love2D Demo",
"updatedAt": "2026-03-04T14:11:51.053Z"
},
"totalDownloads": 0,
"webPlayableRelease": {
"assets": [
{
"kind": "html",
"path": "/file/love2ddemo-1.0.0"
}
],
"cartridgePath": "",
"createdAt": "2026-03-04T14:09:14.425Z",
"deletedAt": null,
"docsFolderPath": "",
"downloadCount": 0,
"htmlFolderPath": "/file/love2ddemo-1.0.0",
"id": 55,
"softwareId": 23,
"sourcePath": "",
"updatedAt": "2026-03-04T14:09:14.425Z",
"version": "1.0.0"
}
}
]
}