Port small tactics modules to typed Kotlin

ober

b10503044ae02a9b9b5b61f177f14317ed079649

diff --git a/templates/original-tactics-parts/RulesClient.ss b/templates/original-tactics-parts/RulesClient.ss
new file mode 100644
index 0000000..e8551fe
--- /dev/null
+++ b/templates/original-tactics-parts/RulesClient.ss
@@ -0,0 +1,312 @@
+(import (jerboa prelude))
+
+(def fragment
+  '((typed-kotlin-file "com/jerboa/originaltactics/RulesClient.kt"
+      (kotlin-imports (android content Context)
+                      (java io File)
+                      (java io InputStream)
+                      (java io OutputStream)
+                      (java net HttpURLConnection)
+                      (java net URL)
+                      (java net URLConnection)
+                      (java nio charset Charset)
+                      (kotlin text Charsets)
+                      (org json JSONObject))
+      (typed-library (com jerboa originaltactics)
+        (export RulesClient)
+        (type Charset)
+        (type Context)
+        (type File)
+        (type HttpURLConnection)
+        (type InputStream)
+        (type Int32)
+        (type JSONObject)
+        (type OutputStream)
+        (type Throwable)
+        (type URL)
+        (type URLConnection)
+        (extern (contextCacheDir (context : Context)) : File
+          (kotlin-member-get cacheDir))
+        (extern (contextFilesDir (context : Context)) : File
+          (kotlin-member-get filesDir))
+        (extern (fileAbsolutePath (file : File)) : String
+          (kotlin-member-get absolutePath))
+        (extern (fileResolve (file : File) (child : String)) : File
+          (kotlin-member-call resolve))
+        (extern (fileMkdirs (file : File)) : Bool
+          (kotlin-member-call mkdirs))
+        (extern (fileAppendText
+                  (file : File)
+                  (text : String)
+                  (charset : Charset)) : Unit
+          (kotlin-member-call appendText))
+        (extern (utf8Charset) : Charset
+          (kotlin-value Charsets UTF_8))
+        (extern (jsonObject) : JSONObject
+          (kotlin-call JSONObject))
+        (extern (jsonPutBool
+                  (json : JSONObject)
+                  (key : String)
+                  (value : Bool)) : JSONObject
+          (kotlin-member-call put))
+        (extern (jsonPutString
+                  (json : JSONObject)
+                  (key : String)
+                  (value : String)) : JSONObject
+          (kotlin-member-call put))
+        (extern (jsonText (json : JSONObject)) : String
+          (kotlin-member-call toString))
+        (extern (urlOpenConnection (url : URL)) : URLConnection
+          (kotlin-member-call openConnection))
+        (extern (asHttpURLConnection
+                  (connection : URLConnection)) : HttpURLConnection
+          (kotlin-cast HttpURLConnection))
+        (extern (connectionRequestMethodSet
+                  (connection : HttpURLConnection)
+                  (method : String)) : Unit
+          (kotlin-member-set requestMethod))
+        (extern (connectionConnectTimeoutSet
+                  (connection : HttpURLConnection)
+                  (timeout : Int32)) : Unit
+          (kotlin-member-set connectTimeout))
+        (extern (connectionReadTimeoutSet
+                  (connection : HttpURLConnection)
+                  (timeout : Int32)) : Unit
+          (kotlin-member-set readTimeout))
+        (extern (connectionDoOutputSet
+                  (connection : HttpURLConnection)
+                  (enabled : Bool)) : Unit
+          (kotlin-member-set doOutput))
+        (extern (connectionSetRequestProperty
+                  (connection : HttpURLConnection)
+                  (name : String)
+                  (value : String)) : Unit
+          (kotlin-member-call setRequestProperty))
+        (extern (connectionSetFixedLengthStreamingMode
+                  (connection : HttpURLConnection)
+                  (size : Int32)) : Unit
+          (kotlin-member-call setFixedLengthStreamingMode))
+        (extern (connectionOutputStream
+                  (connection : HttpURLConnection)) : OutputStream
+          (kotlin-member-get outputStream))
+        (extern (connectionResponseCode
+                  (connection : HttpURLConnection)) : Int32
+          (kotlin-member-get responseCode))
+        (extern (connectionInputStream
+                  (connection : HttpURLConnection)) : InputStream
+          (kotlin-member-get inputStream))
+        (extern (connectionErrorStream
+                  (connection : HttpURLConnection)) : (Nullable InputStream)
+          (kotlin-member-get errorStream))
+        (extern (connectionDisconnect
+                  (connection : HttpURLConnection)) : Unit
+          (kotlin-member-call disconnect))
+        (extern (stringBytes (text : String) (charset : Charset)) : Bytes
+          (kotlin-member-call toByteArray))
+        (extern (outputWrite (stream : OutputStream) (bytes : Bytes)) : Unit
+          (kotlin-member-call write))
+        (extern (outputClose (stream : OutputStream)) : Unit
+          (kotlin-member-call close))
+        (extern (inputReadBytes (stream : InputStream)) : Bytes
+          (kotlin-member-call readBytes))
+        (extern (bytesDecodeUtf8 (bytes : Bytes)) : String
+          (kotlin-member-call decodeToString))
+        (extern (bytesSize (bytes : Bytes)) : Int32
+          (kotlin-member-get size))
+        (extern (inputClose (stream : InputStream)) : Unit
+          (kotlin-member-call close))
+        (extern (illegalStateException (message : String)) : Throwable
+          (kotlin-call IllegalStateException))
+        (extern (removeSuffix
+                  (text : String)
+                  (suffix : String)) : String
+          (kotlin-member-call removeSuffix))
+        (extern (embeddedRulesAvailable
+                  (cacheDir : String)) : Bool
+          (kotlin-call EmbeddedRulesBridge isAvailable))
+        (extern (embeddedRulesHandle (body : String)) : String
+          (kotlin-call EmbeddedRulesBridge handleServiceJson))
+        (val EMBEDDED_SERVICE_ENDPOINT : String
+          "embedded://sfb-rules/api/service"
+          (modifiers private const))
+        (val EMBEDDED_HEALTH_ENDPOINT : String
+          "embedded://sfb-rules/health"
+          (modifiers private const))
+        (val EMBEDDED_CLIENT_LOG_ENDPOINT : String
+          "embedded://sfb-rules/api/client-log"
+          (modifiers private const))
+        (val EXTERNAL_CLIENT_LOG_ENDPOINT : String
+          "http://10.66.60.2:8799/api/client-log"
+          (modifiers private const))
+        (def (httpSuccessCode? (code : Int32)) : Bool
+          (and (>= code (int32 200)) (<= code (int32 299))))
+        (def (newHttpConnection (text : String)) : HttpURLConnection
+          (asHttpURLConnection (urlOpenConnection (new URL text))))
+        (def (readInputText (stream : InputStream)) : String
+          (try-finally
+            (bytesDecodeUtf8 (inputReadBytes stream))
+            (inputClose stream)))
+        (def (readNullableInputText
+               (stream : (Nullable InputStream))) : String
+          (if (nullable-null? stream)
+            ""
+            (readInputText (nullable-get stream))))
+        (def (readRulesConnection
+               (connection : HttpURLConnection)) : String
+          (try-finally
+            (let ((code (connectionResponseCode connection)))
+              (let ((text
+                      (if (httpSuccessCode? code)
+                        (readInputText (connectionInputStream connection))
+                        (readNullableInputText
+                          (connectionErrorStream connection)))))
+                (if (httpSuccessCode? code)
+                  text
+                  (throw
+                    (illegalStateException
+                      (string-append
+                        "HTTP "
+                        (string-append
+                          (int32->string code)
+                          (string-append " " text))))
+                    String))))
+            (connectionDisconnect connection)))
+        (object EmbeddedRulesBridge
+          (extern (systemLoadLibrary (name : String)) : Unit
+            (kotlin-call System loadLibrary))
+          (extern (nativeReady (cacheDir : String)) : Bool
+            (kotlin-external))
+          (extern (nativeHandleServiceJson (body : String)) : String
+            (kotlin-external))
+          (var loadError : (Nullable Throwable)
+            (nullable-none Throwable)
+            (modifiers private))
+          (val loaded : Bool
+            (try
+              (begin
+                (systemLoadLibrary "originaltactics_rules")
+                #t)
+              (catch (error : Throwable)
+                (begin
+                  (set! loadError (nullable-some error))
+                  #f)))
+            (modifiers private))
+          (def (isAvailable (cacheDir : String)) : Bool
+            (and loaded
+                 (try
+                   (nativeReady cacheDir)
+                   (catch (error : Throwable)
+                     (begin
+                       (set! loadError (nullable-some error))
+                       #f)))))
+          (def (handleServiceJson (body : String)) : String
+            (begin
+              (if (not loaded)
+                (throw
+                  (illegalStateException
+                    "Embedded rules bridge is not available: native library not loaded")
+                  Unit)
+                (begin))
+              (nativeHandleServiceJson body))))
+        (class RulesClient
+          ((context : Context) (fallbackEndpoint : String))
+          (val contextValue : Context context
+            (modifiers private))
+          (val fallbackEndpointValue : String fallbackEndpoint
+            (modifiers private))
+          (val embeddedAvailable : Bool
+            (embeddedRulesAvailable
+              (fileAbsolutePath (contextCacheDir context)))
+            (modifiers private))
+          (def (serviceEndpoint) : String
+            (if embeddedAvailable
+              EMBEDDED_SERVICE_ENDPOINT
+              fallbackEndpointValue))
+          (def (healthEndpoint) : String
+            (if embeddedAvailable
+              EMBEDDED_HEALTH_ENDPOINT
+              (string-append
+                (removeSuffix
+                  fallbackEndpointValue
+                  "/api/service")
+                "/health")))
+          (def (clientLogEndpoint) : String
+            EXTERNAL_CLIENT_LOG_ENDPOINT)
+          (def (modeLabel) : String
+            (if embeddedAvailable "embedded" "http-fallback"))
+          (def (getHealth (url : String)) : String
+            (if (equal? url EMBEDDED_HEALTH_ENDPOINT)
+              (jsonText
+                (jsonPutString
+                  (jsonPutString
+                    (jsonPutString
+                      (jsonPutString
+                        (jsonPutString
+                          (jsonPutBool (jsonObject) "ok?" #t)
+                          "service"
+                          "sfb-rules")
+                        "engine"
+                        "jerboa")
+                      "transport"
+                      "embedded")
+                    "endpoint"
+                    EMBEDDED_SERVICE_ENDPOINT)
+                  "client-log-endpoint"
+                  EXTERNAL_CLIENT_LOG_ENDPOINT))
+              (httpGet url)))
+          (def (post
+                 (url : String)
+                 (body : String)
+                 (readTimeoutMillis : Int32)) : String
+            (if (equal? url EMBEDDED_SERVICE_ENDPOINT)
+              (embeddedRulesHandle body)
+              (if (equal? url EMBEDDED_CLIENT_LOG_ENDPOINT)
+                (writeEmbeddedClientLog body)
+                (httpPost url body readTimeoutMillis))))
+          (def (writeEmbeddedClientLog (body : String)) : String
+            (modifiers private)
+            (let ((dir (fileResolve (contextFilesDir contextValue) "logs")))
+              (let ((logFile (fileResolve dir "apk-client.jsonl")))
+                (begin
+                  (fileMkdirs dir)
+                  (fileAppendText
+                    logFile
+                    (string-append body "\n")
+                    (utf8Charset))
+                  (jsonText
+                    (jsonPutString
+                      (jsonPutBool (jsonObject) "ok?" #t)
+                      "log-path"
+                      (fileAbsolutePath logFile)))))))
+          (def (httpGet (url : String)) : String
+            (modifiers private)
+            (let ((connection (newHttpConnection url)))
+              (begin
+                (connectionRequestMethodSet connection "GET")
+                (connectionConnectTimeoutSet connection (int32 2500))
+                (connectionReadTimeoutSet connection (int32 5000))
+                (readRulesConnection connection))))
+          (def (httpPost
+                 (url : String)
+                 (body : String)
+                 (readTimeoutMillis : Int32)) : String
+            (modifiers private)
+            (let ((bytes (stringBytes body (utf8Charset)))
+                  (connection (newHttpConnection url)))
+              (begin
+                (connectionRequestMethodSet connection "POST")
+                (connectionConnectTimeoutSet connection (int32 2500))
+                (connectionReadTimeoutSet connection readTimeoutMillis)
+                (connectionDoOutputSet connection #t)
+                (connectionSetRequestProperty
+                  connection
+                  "Content-Type"
+                  "application/json; charset=utf-8")
+                (connectionSetFixedLengthStreamingMode
+                  connection
+                  (bytesSize bytes))
+                (let ((output (connectionOutputStream connection)))
+                  (try-finally
+                    (outputWrite output bytes)
+                    (outputClose output)))
+                (readRulesConnection connection)))))))))
diff --git a/templates/original-tactics-parts/SfcAssets.ss b/templates/original-tactics-parts/SfcAssets.ss
new file mode 100644
index 0000000..7a76736
--- /dev/null
+++ b/templates/original-tactics-parts/SfcAssets.ss
@@ -0,0 +1,474 @@
+(import (jerboa prelude))
+
+(def fragment
+  '((typed-kotlin-file "com/jerboa/originaltactics/SfcAssets.kt"
+      (kotlin-imports (android content Context)
+                      (android content res AssetFileDescriptor)
+                      (android content res AssetManager)
+                      (android graphics Bitmap)
+                      (android graphics BitmapFactory)
+                      (android media AudioAttributes)
+                      (android media AudioAttributes Builder)
+                      (android media SoundPool)
+                      (java io FileDescriptor)
+                      (java io InputStream)
+                      (java util Locale)
+                      (org json JSONObject))
+      (typed-library (com jerboa originaltactics)
+        (export SfcSound SfcAssets)
+        (type AssetFileDescriptor)
+        (type AssetManager)
+        (type AudioAttributes)
+        (type Bitmap)
+        (type Builder)
+        (type Collection)
+        (type Context)
+        (type Exception)
+        (type FileDescriptor)
+        (type Float32)
+        (type InputStream)
+        (type Int32)
+        (type Int64)
+        (type JSONObject)
+        (type Locale)
+        (type SoundPool)
+        (type SoundPoolBuilder)
+        (enum SfcSound ()
+          (BUTTON)
+          (CONFIRM)
+          (CANCEL)
+          (PHASER)
+          (PHOTON)
+          (PLASMA)
+          (DISRUPTOR)
+          (SHIELD_HIT)
+          (HULL_HIT)
+          (EXPLOSION)
+          (RED_ALERT)
+          (HET))
+        (extern (sfcButton) : SfcSound
+          (kotlin-value SfcSound BUTTON))
+        (extern (sfcConfirm) : SfcSound
+          (kotlin-value SfcSound CONFIRM))
+        (extern (sfcCancel) : SfcSound
+          (kotlin-value SfcSound CANCEL))
+        (extern (sfcPhaser) : SfcSound
+          (kotlin-value SfcSound PHASER))
+        (extern (sfcPhoton) : SfcSound
+          (kotlin-value SfcSound PHOTON))
+        (extern (sfcPlasma) : SfcSound
+          (kotlin-value SfcSound PLASMA))
+        (extern (sfcDisruptor) : SfcSound
+          (kotlin-value SfcSound DISRUPTOR))
+        (extern (sfcShieldHit) : SfcSound
+          (kotlin-value SfcSound SHIELD_HIT))
+        (extern (sfcHullHit) : SfcSound
+          (kotlin-value SfcSound HULL_HIT))
+        (extern (sfcExplosion) : SfcSound
+          (kotlin-value SfcSound EXPLOSION))
+        (extern (sfcRedAlert) : SfcSound
+          (kotlin-value SfcSound RED_ALERT))
+        (extern (sfcHet) : SfcSound
+          (kotlin-value SfcSound HET))
+        (extern (contextAssets (context : Context)) : AssetManager
+          (kotlin-member-get assets))
+        (extern (assetsOpen
+                  (assets : AssetManager)
+                  (path : String)) : InputStream
+          (kotlin-member-call open))
+        (extern (assetsOpenFd
+                  (assets : AssetManager)
+                  (path : String)) : AssetFileDescriptor
+          (kotlin-member-call openFd))
+        (extern (inputClose (input : InputStream)) : Unit
+          (kotlin-member-call close))
+        (extern (assetFdClose (afd : AssetFileDescriptor)) : Unit
+          (kotlin-member-call close))
+        (extern (assetFdFileDescriptor
+                  (afd : AssetFileDescriptor)) : FileDescriptor
+          (kotlin-member-get fileDescriptor))
+        (extern (assetFdStartOffset
+                  (afd : AssetFileDescriptor)) : Int64
+          (kotlin-member-get startOffset))
+        (extern (assetFdLength
+                  (afd : AssetFileDescriptor)) : Int64
+          (kotlin-member-get length))
+        (extern (decodeBitmap
+                  (input : InputStream)) : (Nullable Bitmap)
+          (kotlin-call BitmapFactory decodeStream))
+        (extern (bitmapIsRecycled (bitmap : Bitmap)) : Bool
+          (kotlin-member-get isRecycled))
+        (extern (bitmapRecycle (bitmap : Bitmap)) : Unit
+          (kotlin-member-call recycle))
+        (extern (audioUsageGame) : Int32
+          (kotlin-value AudioAttributes USAGE_GAME))
+        (extern (audioContentSonification) : Int32
+          (kotlin-value AudioAttributes CONTENT_TYPE_SONIFICATION))
+        (extern (audioBuilderSetUsage
+                  (builder : Builder)
+                  (usage : Int32)) : Builder
+          (kotlin-member-call setUsage))
+        (extern (audioBuilderSetContentType
+                  (builder : Builder)
+                  (contentType : Int32)) : Builder
+          (kotlin-member-call setContentType))
+        (extern (audioBuilderBuild
+                  (builder : Builder)) : AudioAttributes
+          (kotlin-member-call build))
+        (extern (soundPoolBuilder) : SoundPoolBuilder
+          (kotlin-call SoundPool Builder))
+        (extern (soundPoolBuilderAttributes
+                  (builder : SoundPoolBuilder)
+                  (attributes : AudioAttributes)) : SoundPoolBuilder
+          (kotlin-member-call setAudioAttributes))
+        (extern (soundPoolBuilderMaxStreams
+                  (builder : SoundPoolBuilder)
+                  (count : Int32)) : SoundPoolBuilder
+          (kotlin-member-call setMaxStreams))
+        (extern (soundPoolBuilderBuild
+                  (builder : SoundPoolBuilder)) : SoundPool
+          (kotlin-member-call build))
+        (extern (soundPoolSetLoadListener
+                  (pool : SoundPool)
+                  (listener : (-> SoundPool Int32 Int32 Unit))) : Unit
+          (kotlin-member-call setOnLoadCompleteListener))
+        (extern (soundPoolLoad
+                  (pool : SoundPool)
+                  (descriptor : FileDescriptor)
+                  (offset : Int64)
+                  (length : Int64)
+                  (priority : Int32)) : Int32
+          (kotlin-member-call load))
+        (extern (soundPoolPlay
+                  (pool : SoundPool)
+                  (id : Int32)
+                  (left : Float32)
+                  (right : Float32)
+                  (priority : Int32)
+                  (loop : Int32)
+                  (rate : Float32)) : Int32
+          (kotlin-member-call play))
+        (extern (soundPoolRelease (pool : SoundPool)) : Unit
+          (kotlin-member-call release))
+        (extern (soundIdGet
+                  (map : (MutableMap SfcSound Int32))
+                  (sound : SfcSound)) : (Nullable Int32)
+          (kotlin-member-call get))
+        (extern (mapClearSoundIds
+                  (map : (MutableMap SfcSound Int32))) : Unit
+          (kotlin-member-call clear))
+        (extern (setContainsInt
+                  (set : (MutableSet Int32))
+                  (value : Int32)) : Bool
+          (kotlin-member-call contains))
+        (extern (setClearInt
+                  (set : (MutableSet Int32))) : Unit
+          (kotlin-member-call clear))
+        (extern (bitmapMapContains
+                  (map : (MutableMap String (Nullable Bitmap)))
+                  (path : String)) : Bool
+          (kotlin-member-call containsKey))
+        (extern (bitmapMapGet
+                  (map : (MutableMap String (Nullable Bitmap)))
+                  (path : String)) : (Nullable Bitmap)
+          (kotlin-member-call get))
+        (extern (bitmapMapValues
+                  (map : (MutableMap String (Nullable Bitmap))))
+                  : (List (Nullable Bitmap))
+          (kotlin-member-get values))
+        (extern (bitmapCollectionForEach
+                  (values : (List (Nullable Bitmap)))
+                  (action : (-> (Nullable Bitmap) Unit))) : Unit
+          (kotlin-member-call forEach))
+        (extern (bitmapMapClear
+                  (map : (MutableMap String (Nullable Bitmap)))) : Unit
+          (kotlin-member-call clear))
+        (extern (localeUs) : Locale
+          (kotlin-value Locale US))
+        (extern (lowercaseLocale
+                  (text : String)
+                  (locale : Locale)) : String
+          (kotlin-member-call lowercase))
+        (extern (assetsEnsureSoundPool) : SoundPool
+          (kotlin-call SfcAssets ensureSoundPool))
+        (extern (assetsEnsureSoundLoaded
+                  (context : Context)
+                  (sound : SfcSound)) : (Nullable Int32)
+          (kotlin-call SfcAssets ensureSoundLoaded))
+        (extern (assetsArtKeyForShip
+                  (ship : (Nullable JSONObject))) : (Nullable String)
+          (kotlin-call SfcAssets artKeyForShip))
+        (extern (assetsMarkLoaded
+                  (sampleId : Int32)
+                  (status : Int32)) : Unit
+          (kotlin-call SfcAssets markLoaded))
+        (def (soundPath (sound : SfcSound)) : (Nullable String)
+          (if (equal? sound (sfcButton))
+            (nullable-some "sfc/sound/button.wav")
+            (if (equal? sound (sfcConfirm))
+              (nullable-some "sfc/sound/confirm.wav")
+              (if (equal? sound (sfcCancel))
+                (nullable-some "sfc/sound/cancel.wav")
+                (if (equal? sound (sfcPhaser))
+                  (nullable-some "sfc/sound/phaser.wav")
+                  (if (equal? sound (sfcPhoton))
+                    (nullable-some "sfc/sound/photon.wav")
+                    (if (equal? sound (sfcPlasma))
+                      (nullable-some "sfc/sound/plasma.wav")
+                      (if (equal? sound (sfcDisruptor))
+                        (nullable-some "sfc/sound/disruptor.wav")
+                        (if (equal? sound (sfcShieldHit))
+                          (nullable-some "sfc/sound/shield_hit.wav")
+                          (if (equal? sound (sfcHullHit))
+                            (nullable-some "sfc/sound/hull_hit.wav")
+                            (if (equal? sound (sfcExplosion))
+                              (nullable-some "sfc/sound/explosion.wav")
+                              (if (equal? sound (sfcRedAlert))
+                                (nullable-some "sfc/sound/red_alert.wav")
+                                (if (equal? sound (sfcHet))
+                                  (nullable-some "sfc/sound/het.wav")
+                                  (nullable-none String))))))))))))))
+        (def (artPath (key : String)) : (Nullable String)
+          (if (equal? key "federation")
+            (nullable-some "sfc2/art/federation.png")
+            (if (equal? key "klingon")
+              (nullable-some "sfc2/art/klingon.png")
+              (if (equal? key "romulan")
+                (nullable-some "sfc2/art/romulan.png")
+                (if (equal? key "gorn")
+                  (nullable-some "sfc2/art/gorn.png")
+                  (if (equal? key "hydran")
+                    (nullable-some "sfc2/art/hydran.png")
+                    (if (equal? key "lyran")
+                      (nullable-some "sfc2/art/lyran.png")
+                      (if (equal? key "mirak")
+                        (nullable-some "sfc2/art/mirak.png")
+                        (if (equal? key "isc")
+                          (nullable-some "sfc2/art/isc.png")
+                          (nullable-none String))))))))))
+        (def (shipAssetText
+               (ship : (Nullable JSONObject))) : String
+          (if (nullable-null? ship)
+            "     "
+            (let ((json (nullable-get ship)))
+              (let ((properties
+                      (json-object-opt-json-object json "properties")))
+                (string-append
+                  (json-object-opt-string-default json "template-id" "")
+                  (string-append
+                    " "
+                    (string-append
+                      (json-object-opt-string-default json "id" "")
+                      (string-append
+                        " "
+                        (string-append
+                          (json-object-opt-string-default json "name" "")
+                          (string-append
+                            " "
+                            (if (nullable-null? properties)
+                              "  "
+                              (let ((props (nullable-get properties)))
+                                (string-append
+                                  (json-object-opt-string-default props "race" "")
+                                  (string-append
+                                    " "
+                                    (string-append
+                                      (json-object-opt-string-default props "empire" "")
+                                      (string-append
+                                        " "
+                                        (json-object-opt-string-default
+                                          props "ship-group" "")))))))))))))))))
+        (object SfcAssets
+          (var soundPool : (Nullable SoundPool)
+            (nullable-none SoundPool)
+            (modifiers private))
+          (val soundIds : (MutableMap SfcSound Int32)
+            (mutable-map-empty SfcSound Int32)
+            (modifiers private))
+          (val loadedIds : (MutableSet Int32)
+            (mutable-set-empty Int32)
+            (modifiers private))
+          (val bitmapCache : (MutableMap String (Nullable Bitmap))
+            (mutable-map-empty String (Nullable Bitmap))
+            (modifiers private))
+          (def (preload (context : Context)) : Unit
+            (modifiers @Synchronized)
+            (begin
+              (assetsEnsureSoundPool)
+              (assetsEnsureSoundLoaded context (sfcButton))
+              (assetsEnsureSoundLoaded context (sfcConfirm))
+              (assetsEnsureSoundLoaded context (sfcCancel))
+              (assetsEnsureSoundLoaded context (sfcPhaser))
+              (assetsEnsureSoundLoaded context (sfcPhoton))
+              (assetsEnsureSoundLoaded context (sfcPlasma))
+              (assetsEnsureSoundLoaded context (sfcDisruptor))
+              (assetsEnsureSoundLoaded context (sfcShieldHit))
+              (assetsEnsureSoundLoaded context (sfcHullHit))
+              (assetsEnsureSoundLoaded context (sfcExplosion))
+              (assetsEnsureSoundLoaded context (sfcRedAlert))
+              (assetsEnsureSoundLoaded context (sfcHet))
+              (begin)))
+          (def (release) : Unit
+            (modifiers @Synchronized)
+            (begin
+              (if (nullable-null? soundPool)
+                (begin)
+                (soundPoolRelease (nullable-get soundPool)))
+              (set! soundPool (nullable-none SoundPool))
+              (mapClearSoundIds soundIds)
+              (setClearInt loadedIds)
+              (bitmapCollectionForEach
+                (bitmapMapValues bitmapCache)
+                (lambda ((bitmap : (Nullable Bitmap)))
+                  (if (nullable-null? bitmap)
+                    (begin)
+                    (let ((image (nullable-get bitmap)))
+                      (if (bitmapIsRecycled image)
+                        (begin)
+                        (bitmapRecycle image))))))
+              (bitmapMapClear bitmapCache)))
+          (def (play
+                 (context : Context)
+                 (sound : SfcSound)
+                 (volume : Float32 (default (float32 0.72)))) : Unit
+            (modifiers @Synchronized)
+            (let ((pool (assetsEnsureSoundPool))
+                  (id (assetsEnsureSoundLoaded context sound)))
+              (if (nullable-null? id)
+                (begin)
+                (let ((sampleId (nullable-get id)))
+                  (if (setContainsInt loadedIds sampleId)
+                    (begin
+                      (soundPoolPlay
+                        pool sampleId volume volume
+                        (int32 1) (int32 0) (float32 1.0))
+                      (begin))
+                    (begin))))))
+          (def (bitmapForShip
+                 (context : Context)
+                 (ship : (Nullable JSONObject))) : (Nullable Bitmap)
+            (modifiers @Synchronized)
+            (let ((key (assetsArtKeyForShip ship)))
+              (if (nullable-null? key)
+                (nullable-none Bitmap)
+                (let ((path (artPath (nullable-get key))))
+                  (if (nullable-null? path)
+                    (nullable-none Bitmap)
+                    (let ((assetPath (nullable-get path)))
+                      (if (bitmapMapContains bitmapCache assetPath)
+                        (bitmapMapGet bitmapCache assetPath)
+                        (let ((bitmap
+                                (try
+                                  (let ((input
+                                          (assetsOpen
+                                            (contextAssets context)
+                                            assetPath)))
+                                    (try-finally
+                                      (decodeBitmap input)
+                                      (inputClose input)))
+                                  (catch (error : Exception)
+                                    (nullable-none Bitmap)))))
+                          (begin
+                            (mutable-map-put!
+                              bitmapCache assetPath bitmap)
+                            bitmap)))))))))
+          (def (artKeyForShip
+                 (ship : (Nullable JSONObject))) : (Nullable String)
+            (let ((text
+                    (lowercaseLocale (shipAssetText ship) (localeUs))))
+              (if (or (string-contains? text "federation")
+                      (or (string-contains? text "fed-")
+                          (string-starts-with? text "f")))
+                (nullable-some "federation")
+                (if (or (string-contains? text "klingon")
+                        (or (string-contains? text "kli")
+                            (string-starts-with? text "k")))
+                  (nullable-some "klingon")
+                  (if (or (string-contains? text "romulan")
+                          (or (string-contains? text "rom")
+                              (string-starts-with? text "r")))
+                    (nullable-some "romulan")
+                    (if (or (string-contains? text "gorn")
+                            (string-starts-with? text "g"))
+                      (nullable-some "gorn")
+                      (if (or (string-contains? text "hydran")
+                              (string-starts-with? text "h"))
+                        (nullable-some "hydran")
+                        (if (or (string-contains? text "lyran")
+                                (string-starts-with? text "l"))
+                          (nullable-some "lyran")
+                          (if (or (string-contains? text "mirak")
+                                  (or (string-contains? text "kzinti")
+                                      (string-starts-with? text "m")))
+                            (nullable-some "mirak")
+                            (if (or (string-contains? text "isc")
+                                    (string-starts-with? text "i"))
+                              (nullable-some "isc")
+                              (nullable-none String)))))))))))
+          (def (markLoaded (sampleId : Int32) (status : Int32)) : Unit
+            (modifiers @Synchronized private)
+            (if (= status (int32 0))
+              (begin
+                (mutable-set-add! loadedIds sampleId)
+                (begin))
+              (begin)))
+          (def (ensureSoundPool) : SoundPool
+            (modifiers private)
+            (if (nullable-null? soundPool)
+              (let ((attributes
+                      (audioBuilderBuild
+                        (audioBuilderSetContentType
+                          (audioBuilderSetUsage
+                            (new Builder)
+                            (audioUsageGame))
+                          (audioContentSonification)))))
+                (let ((pool
+                        (soundPoolBuilderBuild
+                          (soundPoolBuilderMaxStreams
+                            (soundPoolBuilderAttributes
+                              (soundPoolBuilder)
+                              attributes)
+                            (int32 6)))))
+                  (begin
+                    (soundPoolSetLoadListener
+                      pool
+                      (lambda ((ignored : SoundPool)
+                               (sampleId : Int32)
+                               (status : Int32))
+                        (assetsMarkLoaded sampleId status)))
+                    (set! soundPool (nullable-some pool))
+                    pool)))
+              (nullable-get soundPool)))
+          (def (ensureSoundLoaded
+                 (context : Context)
+                 (sound : SfcSound)) : (Nullable Int32)
+            (modifiers private)
+            (let ((existing (soundIdGet soundIds sound)))
+              (if (nullable-null? existing)
+                (let ((path (soundPath sound)))
+                  (if (nullable-null? path)
+                    (nullable-none Int32)
+                    (let ((loaded
+                            (try
+                              (let ((afd
+                                      (assetsOpenFd
+                                        (contextAssets context)
+                                        (nullable-get path))))
+                                (try-finally
+                                  (nullable-some
+                                    (soundPoolLoad
+                                      (assetsEnsureSoundPool)
+                                      (assetFdFileDescriptor afd)
+                                      (assetFdStartOffset afd)
+                                      (assetFdLength afd)
+                                      (int32 1)))
+                                  (assetFdClose afd)))
+                              (catch (error : Exception)
+                                (nullable-none Int32)))))
+                      (if (nullable-null? loaded)
+                        (nullable-none Int32)
+                        (begin
+                          (mutable-map-put!
+                            soundIds sound (nullable-get loaded))
+                          loaded)))))
+                existing))))))))
diff --git a/templates/original-tactics-parts/SfcShipArtView.ss b/templates/original-tactics-parts/SfcShipArtView.ss
new file mode 100644
index 0000000..c24d584
--- /dev/null
+++ b/templates/original-tactics-parts/SfcShipArtView.ss
@@ -0,0 +1,396 @@
+(import (jerboa prelude))
+
+(def fragment
+  '((typed-kotlin-file "com/jerboa/originaltactics/SfcShipArtView.kt"
+      (kotlin-imports (android content Context)
+                      (android graphics Bitmap)
+                      (android graphics Canvas)
+                      (android graphics Color)
+                      (android graphics Paint)
+                      (android graphics Rect)
+                      (android graphics RectF)
+                      (android view View)
+                      (org json JSONObject))
+      (typed-library (com jerboa originaltactics)
+        (export SfcShipArtView)
+        (type Bitmap)
+        (type Canvas)
+        (type Context)
+        (type DisplayMetrics)
+        (type Float32)
+        (type Int32)
+        (type JSONObject)
+        (type Paint)
+        (type PaintStyle)
+        (type Rect)
+        (type RectF)
+        (type Resources)
+        (type View)
+        (extern (paintAntiAliasFlag) : Int32
+          (kotlin-value Paint ANTI_ALIAS_FLAG))
+        (extern (paintStyleFill) : PaintStyle
+          (kotlin-value Paint Style FILL))
+        (extern (paintStyleStroke) : PaintStyle
+          (kotlin-value Paint Style STROKE))
+        (extern (paintColorSet (paint : Paint) (color : Int32)) : Unit
+          (kotlin-member-set color))
+        (extern (paintStyleSet (paint : Paint) (style : PaintStyle)) : Unit
+          (kotlin-member-set style))
+        (extern (paintStrokeWidthSet
+                  (paint : Paint)
+                  (width : Float32)) : Unit
+          (kotlin-member-set strokeWidth))
+        (extern (paintTextSizeSet
+                  (paint : Paint)
+                  (size : Float32)) : Unit
+          (kotlin-member-set textSize))
+        (extern (paintFakeBoldSet
+                  (paint : Paint)
+                  (bold : Bool)) : Unit
+          (kotlin-member-set isFakeBoldText))
+        (extern (colorRgb
+                  (red : Int32)
+                  (green : Int32)
+                  (blue : Int32)) : Int32
+          (kotlin-call Color rgb))
+        (extern (colorArgb
+                  (alpha : Int32)
+                  (red : Int32)
+                  (green : Int32)
+                  (blue : Int32)) : Int32
+          (kotlin-call Color argb))
+        (extern (colorWhite) : Int32
+          (kotlin-value Color WHITE))
+        (extern (superOnDraw (canvas : Canvas)) : Unit
+          (kotlin-call super onDraw))
+        (extern (viewInvalidate (view : SfcShipArtView)) : Unit
+          (kotlin-member-call invalidate))
+        (extern (viewResources (view : SfcShipArtView)) : Resources
+          (kotlin-member-get resources))
+        (extern (resourcesDisplayMetrics
+                  (resources : Resources)) : DisplayMetrics
+          (kotlin-member-get displayMetrics))
+        (extern (displayMetricsDensity
+                  (metrics : DisplayMetrics)) : Float32
+          (kotlin-member-get density))
+        (extern (viewWidth (view : SfcShipArtView)) : Int32
+          (kotlin-member-get width))
+        (extern (viewHeight (view : SfcShipArtView)) : Int32
+          (kotlin-member-get height))
+        (extern (canvasSave (canvas : Canvas)) : Int32
+          (kotlin-member-call save))
+        (extern (canvasScale
+                  (canvas : Canvas)
+                  (x : Float32)
+                  (y : Float32)) : Unit
+          (kotlin-member-call scale))
+        (extern (canvasRestoreToCount
+                  (canvas : Canvas)
+                  (state : Int32)) : Unit
+          (kotlin-member-call restoreToCount))
+        (extern (canvasDrawRect
+                  (canvas : Canvas)
+                  (left : Float32)
+                  (top : Float32)
+                  (right : Float32)
+                  (bottom : Float32)
+                  (paint : Paint)) : Unit
+          (kotlin-member-call drawRect))
+        (extern (canvasDrawRoundRect
+                  (canvas : Canvas)
+                  (rect : RectF)
+                  (rx : Float32)
+                  (ry : Float32)
+                  (paint : Paint)) : Unit
+          (kotlin-member-call drawRoundRect))
+        (extern (canvasDrawText
+                  (canvas : Canvas)
+                  (text : String)
+                  (x : Float32)
+                  (y : Float32)
+                  (paint : Paint)) : Unit
+          (kotlin-member-call drawText))
+        (extern (canvasDrawBitmap
+                  (canvas : Canvas)
+                  (bitmap : Bitmap)
+                  (source : Rect)
+                  (dest : RectF)
+                  (paint : (Nullable Paint))) : Unit
+          (kotlin-member-call drawBitmap))
+        (extern (rectFLeft (rect : RectF)) : Float32
+          (kotlin-member-get left))
+        (extern (rectFTop (rect : RectF)) : Float32
+          (kotlin-member-get top))
+        (extern (rectFRight (rect : RectF)) : Float32
+          (kotlin-member-get right))
+        (extern (rectFBottom (rect : RectF)) : Float32
+          (kotlin-member-get bottom))
+        (extern (rectFWidth (rect : RectF)) : Float32
+          (kotlin-member-call width))
+        (extern (rectFHeight (rect : RectF)) : Float32
+          (kotlin-member-call height))
+        (extern (bitmapWidth (bitmap : Bitmap)) : Int32
+          (kotlin-member-get width))
+        (extern (bitmapHeight (bitmap : Bitmap)) : Int32
+          (kotlin-member-get height))
+        (extern (float32ToInt (value : Float32)) : Int32
+          (kotlin-member-call toInt))
+        (extern (gameBattleState
+                  (game : JSONObject)) : (Nullable JSONObject)
+          (kotlin-call gameBattleState))
+        (extern (jsonObjectChild
+                  (json : JSONObject)
+                  (key : String)) : (Nullable JSONObject)
+          (kotlin-member-call optJSONObject))
+        (extern (shipArtBitmap
+                  (context : Context)
+                  (ship : (Nullable JSONObject))) : (Nullable Bitmap)
+          (kotlin-call SfcAssets bitmapForShip))
+        (extern (shipArtKey
+                  (ship : (Nullable JSONObject))) : (Nullable String)
+          (kotlin-call SfcAssets artKeyForShip))
+        (extern (shipLabel
+                  (ship : (Nullable JSONObject))
+                  (fallback : String)) : String
+          (kotlin-call shipDisplayLabel))
+        (def (configureShipArtPaint
+               (paint : Paint)
+               (color : Int32)
+               (style : PaintStyle)
+               (strokeWidth : Float32)) : Paint
+          (begin
+            (paintColorSet paint color)
+            (paintStyleSet paint style)
+            (paintStrokeWidthSet paint strokeWidth)
+            paint))
+        (def (configureShipArtTextPaint
+               (paint : Paint)
+               (color : Int32)
+               (size : Float32)
+               (bold : Bool)) : Paint
+          (begin
+            (paintColorSet paint color)
+            (paintTextSizeSet paint size)
+            (paintFakeBoldSet paint bold)
+            paint))
+        (def (nullableUppercaseOr
+               (text : (Nullable String))
+               (fallback : String)) : String
+          (if (nullable-null? text)
+            fallback
+            (string-uppercase (nullable-get text))))
+        (class SfcShipArtView ((context : Context))
+          (extends View context)
+          (var game : (Nullable JSONObject)
+            (nullable-none JSONObject)
+            (modifiers private))
+          (var drawWidth : Float32
+            (float32 0.0)
+            (modifiers private))
+          (var drawHeight : Float32
+            (float32 0.0)
+            (modifiers private))
+          (val backgroundPaint : Paint
+            (configureShipArtPaint
+              (new Paint (paintAntiAliasFlag))
+              (colorRgb (int32 8) (int32 13) (int32 21))
+              (paintStyleFill)
+              (float32 0.0))
+            (modifiers private))
+          (val cardPaint : Paint
+            (configureShipArtPaint
+              (new Paint (paintAntiAliasFlag))
+              (colorRgb (int32 18) (int32 27) (int32 40))
+              (paintStyleFill)
+              (float32 0.0))
+            (modifiers private))
+          (val alphaPaint : Paint