From bf921c589dacf669eb3124c1ced64c0a0d1e3b48 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Tue, 4 Aug 2026 18:39:20 +0200 Subject: [PATCH] Phase 0: in-place decoupling before WarpEngine extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/app/admin/images.rb | 11 +- apps/api/app/models/image.rb | 9 +- .../api/app/serializers/release_serializer.rb | 5 +- .../serializers/software_detail_serializer.rb | 6 +- apps/api/app/services/download_service.rb | 17 +- apps/api/app/services/file_service.rb | 13 +- apps/api/app/services/image_usage.rb | 30 + .../services/software_highlighted_service.rb | 4 +- .../app/services/software_response_builder.rb | 13 +- apps/api/app/services/software_service.rb | 5 +- apps/api/config/database.yml | 4 + apps/api/config/environments/test.rb | 15 + apps/api/config/initializers/image_usage.rb | 9 + .../controllers/software_controller_spec.rb | 17 + apps/api/spec/models/member_spec.rb | 5 +- apps/api/spec/models/software_spec.rb | 10 +- apps/api/spec/services/builds_service_spec.rb | 2 +- .../spec/snapshots/api_builds_baseline.json | 79 +++ .../spec/snapshots/api_software_baseline.json | 629 ++++++++++++++++++ 19 files changed, 851 insertions(+), 32 deletions(-) create mode 100644 apps/api/app/services/image_usage.rb create mode 100644 apps/api/config/environments/test.rb create mode 100644 apps/api/config/initializers/image_usage.rb create mode 100644 apps/api/spec/snapshots/api_builds_baseline.json create mode 100644 apps/api/spec/snapshots/api_software_baseline.json diff --git a/apps/api/app/admin/images.rb b/apps/api/app/admin/images.rb index 6853f0b..e17e893 100644 --- a/apps/api/app/admin/images.rb +++ b/apps/api/app/admin/images.rb @@ -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 diff --git a/apps/api/app/models/image.rb b/apps/api/app/models/image.rb index dc295b7..7fc0804 100644 --- a/apps/api/app/models/image.rb +++ b/apps/api/app/models/image.rb @@ -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) diff --git a/apps/api/app/serializers/release_serializer.rb b/apps/api/app/serializers/release_serializer.rb index 00ec0c8..80d8e7a 100644 --- a/apps/api/app/serializers/release_serializer.rb +++ b/apps/api/app/serializers/release_serializer.rb @@ -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 diff --git a/apps/api/app/serializers/software_detail_serializer.rb b/apps/api/app/serializers/software_detail_serializer.rb index ded44e6..727e34d 100644 --- a/apps/api/app/serializers/software_detail_serializer.rb +++ b/apps/api/app/serializers/software_detail_serializer.rb @@ -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 diff --git a/apps/api/app/services/download_service.rb b/apps/api/app/services/download_service.rb index 31296c5..bc242c7 100644 --- a/apps/api/app/services/download_service.rb +++ b/apps/api/app/services/download_service.rb @@ -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!( diff --git a/apps/api/app/services/file_service.rb b/apps/api/app/services/file_service.rb index 9f6628a..3c60e5e 100644 --- a/apps/api/app/services/file_service.rb +++ b/apps/api/app/services/file_service.rb @@ -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 diff --git a/apps/api/app/services/image_usage.rb b/apps/api/app/services/image_usage.rb new file mode 100644 index 0000000..958353e --- /dev/null +++ b/apps/api/app/services/image_usage.rb @@ -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 } — 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 diff --git a/apps/api/app/services/software_highlighted_service.rb b/apps/api/app/services/software_highlighted_service.rb index 25ff53c..9a37015 100644 --- a/apps/api/app/services/software_highlighted_service.rb +++ b/apps/api/app/services/software_highlighted_service.rb @@ -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 diff --git a/apps/api/app/services/software_response_builder.rb b/apps/api/app/services/software_response_builder.rb index d8da64d..a507e8f 100644 --- a/apps/api/app/services/software_response_builder.rb +++ b/apps/api/app/services/software_response_builder.rb @@ -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 diff --git a/apps/api/app/services/software_service.rb b/apps/api/app/services/software_service.rb index 3ad7c9f..3e16178 100644 --- a/apps/api/app/services/software_service.rb +++ b/apps/api/app/services/software_service.rb @@ -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 diff --git a/apps/api/config/database.yml b/apps/api/config/database.yml index ec50605..9cd6537 100644 --- a/apps/api/config/database.yml +++ b/apps/api/config/database.yml @@ -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") %> diff --git a/apps/api/config/environments/test.rb b/apps/api/config/environments/test.rb new file mode 100644 index 0000000..9b7c6a5 --- /dev/null +++ b/apps/api/config/environments/test.rb @@ -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 diff --git a/apps/api/config/initializers/image_usage.rb b/apps/api/config/initializers/image_usage.rb new file mode 100644 index 0000000..2a6ae0f --- /dev/null +++ b/apps/api/config/initializers/image_usage.rb @@ -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 diff --git a/apps/api/spec/controllers/software_controller_spec.rb b/apps/api/spec/controllers/software_controller_spec.rb index 7454afb..43d9d32 100644 --- a/apps/api/spec/controllers/software_controller_spec.rb +++ b/apps/api/spec/controllers/software_controller_spec.rb @@ -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) diff --git a/apps/api/spec/models/member_spec.rb b/apps/api/spec/models/member_spec.rb index 95f4c1d..13450aa 100644 --- a/apps/api/spec/models/member_spec.rb +++ b/apps/api/spec/models/member_spec.rb @@ -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 diff --git a/apps/api/spec/models/software_spec.rb b/apps/api/spec/models/software_spec.rb index e74233c..fdbfbfa 100644 --- a/apps/api/spec/models/software_spec.rb +++ b/apps/api/spec/models/software_spec.rb @@ -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 diff --git a/apps/api/spec/services/builds_service_spec.rb b/apps/api/spec/services/builds_service_spec.rb index 0182d78..feb27ad 100644 --- a/apps/api/spec/services/builds_service_spec.rb +++ b/apps/api/spec/services/builds_service_spec.rb @@ -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 diff --git a/apps/api/spec/snapshots/api_builds_baseline.json b/apps/api/spec/snapshots/api_builds_baseline.json new file mode 100644 index 0000000..2623321 --- /dev/null +++ b/apps/api/spec/snapshots/api_builds_baseline.json @@ -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" + ] +} diff --git a/apps/api/spec/snapshots/api_software_baseline.json b/apps/api/spec/snapshots/api_software_baseline.json new file mode 100644 index 0000000..4e80913 --- /dev/null +++ b/apps/api/spec/snapshots/api_software_baseline.json @@ -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" + } + } + ] +}