1/// Everything the platform owns.2///3/// Storage, tones, haptics, the install prompt, the survey worker, the clock,4/// and the two questions the system has already been asked. Each one is a thin5/// effect over a capability that has no Gleam expression; none of them decides6/// anything.7import gleam/dynamic.{type Dynamic}8import klotski/prefs.{type Preference}9import lustre/effect.{type Effect}1011// ------------------------------------------------------------------ storage1213@external(javascript, "./browser_ffi.mjs", "load")14pub fn load() -> String1516@external(javascript, "./browser_ffi.mjs", "save")17fn save_ffi(value: String) -> Nil1819pub fn persist(value: String) -> Effect(message) {20 effect.from(fn(_dispatch) { save_ffi(value) })21}2223@external(javascript, "./browser_ffi.mjs", "requestPersistence")24fn request_persistence_ffi() -> Nil2526/// Ask to keep the record through storage pressure. Best effort everywhere, and27/// on iOS it is genuinely only a request.28pub fn request_persistence() -> Effect(message) {29 effect.from(fn(_dispatch) { request_persistence_ffi() })30}3132// -------------------------------------------------------------------- sound3334@external(javascript, "./browser_ffi.mjs", "unlockAudio")35fn unlock_audio_ffi() -> Nil3637/// Audio starts suspended until a real gesture; the title screen's tap exists38/// partly to pay this debt.39pub fn unlock_audio() -> Effect(message) {40 effect.from(fn(_dispatch) { unlock_audio_ffi() })41}4243@external(javascript, "./browser_ffi.mjs", "steps")44fn steps_ffi(count: Int, enabled: Bool) -> Nil4546/// A block sliding: every cell it travels sounds one degree of a minor47/// pentatonic, so a long slide plays a rising figure and solving a board plays a48/// line rather than a rattle.49pub fn steps(count: Int, enabled: Bool) -> Effect(message) {50 effect.from(fn(_dispatch) { steps_ffi(count, enabled) })51}5253@external(javascript, "./browser_ffi.mjs", "cue")54fn cue_ffi(name: String, enabled: Bool) -> Nil5556/// One of the fixed figures: `page`, `thud`, `solve`.57pub fn cue(name: String, enabled: Bool) -> Effect(message) {58 effect.from(fn(_dispatch) { cue_ffi(name, enabled) })59}6061@external(javascript, "./browser_ffi.mjs", "buzz")62fn buzz_ffi(pattern: List(Int), enabled: Bool) -> Nil6364/// Android only; iOS has no vibration API, so this is simply nothing there.65pub fn buzz(pattern: List(Int), enabled: Bool) -> Effect(message) {66 effect.from(fn(_dispatch) { buzz_ffi(pattern, enabled) })67}6869// ------------------------------------------------------------------- system7071@external(javascript, "./browser_ffi.mjs", "prefersDark")72pub fn prefers_dark() -> Bool7374@external(javascript, "./browser_ffi.mjs", "prefersStill")75pub fn prefers_still() -> Bool7677@external(javascript, "./browser_ffi.mjs", "watchSystem")78fn watch_system_ffi(dispatch: fn(message) -> Nil, message: message) -> Nil7980/// The system can change its mind while the app is open — a phone that goes81/// dark at sunset, or someone turning motion off because of something this just82/// did. Re-resolving costs nothing and applying it late looks like a bug.83pub fn watch_system(message: message) -> Effect(message) {84 effect.from(fn(dispatch) { watch_system_ffi(dispatch, message) })85}8687/// Both preferences, read live.88pub fn preference() -> Preference {89 prefs.Preference(dark: prefers_dark(), still: prefers_still())90}9192@external(javascript, "./browser_ffi.mjs", "watchViewport")93fn watch_viewport_ffi(dispatch: fn(message) -> Nil, message: message) -> Nil9495pub fn watch_viewport(message: message) -> Effect(message) {96 effect.from(fn(dispatch) { watch_viewport_ffi(dispatch, message) })97}9899@external(javascript, "./browser_ffi.mjs", "watchKeys")100fn watch_keys_ffi(101 dispatch: fn(message) -> Nil,102 to_message: fn(String) -> message,103) -> Nil104105pub fn watch_keys(to_message: fn(String) -> message) -> Effect(message) {106 effect.from(fn(dispatch) { watch_keys_ffi(dispatch, to_message) })107}108109@external(javascript, "./browser_ffi.mjs", "watchFrame")110fn watch_frame_ffi(111 dispatch: fn(message) -> Nil,112 to_message: fn(Float) -> message,113) -> Nil114115/// The clock. Every screen in this app is stepped rather than smooth, so what116/// this drives is a comparison and not a redraw: the loop asks for a picture117/// when one of the stepped values moves, which for a 300ms rise is seven118/// pictures however many times a second the display asks for one.119pub fn watch_frame(to_message: fn(Float) -> message) -> Effect(message) {120 effect.from(fn(dispatch) { watch_frame_ffi(dispatch, to_message) })121}122123@external(javascript, "./browser_ffi.mjs", "now")124pub fn now() -> Int125126@external(javascript, "./browser_ffi.mjs", "today")127pub fn today() -> String128129@external(javascript, "./browser_ffi.mjs", "dayOfYear")130pub fn day_of_year() -> Int131132// ------------------------------------------------------------- installation133134@external(javascript, "./browser_ffi.mjs", "watchInstall")135fn watch_install_ffi(dispatch: fn(message) -> Nil, message: message) -> Nil136137pub fn watch_install(message: message) -> Effect(message) {138 effect.from(fn(dispatch) { watch_install_ffi(dispatch, message) })139}140141@external(javascript, "./browser_ffi.mjs", "promptInstall")142fn prompt_install_ffi(dispatch: fn(message) -> Nil, message: message) -> Nil143144pub fn prompt_install(message: message) -> Effect(message) {145 effect.from(fn(dispatch) { prompt_install_ffi(dispatch, message) })146}147148@external(javascript, "./browser_ffi.mjs", "registerServiceWorker")149fn register_service_worker_ffi() -> Nil150151pub fn register_service_worker() -> Effect(message) {152 effect.from(fn(_dispatch) { register_service_worker_ffi() })153}154155@external(javascript, "./browser_ffi.mjs", "applyUpdate")156fn apply_update_ffi(resume: String) -> Nil157158@external(javascript, "./browser_ffi.mjs", "takeResume")159fn take_resume_ffi() -> String160161/// The family the app was opening when it reloaded to take an update, if it was.162/// Reading it clears it.163pub fn take_resume() -> String {164 take_resume_ffi()165}166167/// Take a waiting version, if there is one. Nothing happens when there is not.168///169/// Called at the seam between one board and the next, which is the only moment170/// in this app where a reload costs a player nothing.171pub fn apply_update(resume: String) -> Effect(message) {172 effect.from(fn(_dispatch) { apply_update_ffi(resume) })173}174175@external(javascript, "./browser_ffi.mjs", "loadFonts")176fn load_fonts_ffi(dispatch: fn(message) -> Nil, message: message) -> Nil177178/// Wait for the faces before the first frame: type is drawn on its own canvas at179/// full resolution, but a fallback face would still be measured and laid out at180/// the wrong widths for the first frames.181pub fn load_fonts(message: message) -> Effect(message) {182 effect.from(fn(dispatch) { load_fonts_ffi(dispatch, message) })183}184185@external(javascript, "./browser_ffi.mjs", "setThemeColour")186fn set_theme_colour_ffi(ink: String, paper: String, ground: String) -> Nil187188/// The picture buffer is upscaled by a whole number, so on anything wider than a189/// phone there is a margin of page around it. That margin was paper, which was190/// invisible while the only ground *was* paper and became a seam the moment the191/// ground could be any colour. It follows the ground now instead.192pub fn set_theme_colour(193 ink: String,194 paper: String,195 ground: String,196) -> Effect(message) {197 effect.from(fn(_dispatch) { set_theme_colour_ffi(ink, paper, ground) })198}199200// ------------------------------------------------------------- the surveyor201202@external(javascript, "./browser_ffi.mjs", "watchSolver")203fn watch_solver_ffi(204 dispatch: fn(message) -> Nil,205 sweeping: fn(String, Int, Int, Int) -> message,206 surveyed: fn(String, Int, Int) -> message,207 puzzled: fn(String, Int, List(Dynamic)) -> message,208 measured: fn(String, Int, List(Int)) -> message,209 probed: fn(String, List(Int)) -> message,210) -> Nil211212/// Subscribe to the survey worker. Five answers: how a sweep is going, that one213/// has finished, the candidates for a rung, the exact distance of states just214/// walked through, and — for the harness — the distance of states nobody has215/// walked yet.216pub fn watch_solver(217 sweeping: fn(String, Int, Int, Int) -> message,218 surveyed: fn(String, Int, Int) -> message,219 puzzled: fn(String, Int, List(Dynamic)) -> message,220 measured: fn(String, Int, List(Int)) -> message,221 probed: fn(String, List(Int)) -> message,222) -> Effect(message) {223 effect.from(fn(dispatch) {224 watch_solver_ffi(dispatch, sweeping, surveyed, puzzled, measured, probed)225 })226}227228@external(javascript, "./browser_ffi.mjs", "probe")229fn probe_ffi(family_id: String, states: List(Int)) -> Nil230231/// How far each of these is, with no claim that they are a run.232pub fn probe(family_id: String, states: List(Int)) -> Effect(message) {233 effect.from(fn(_dispatch) { probe_ffi(family_id, states) })234}235236@external(javascript, "./browser_ffi.mjs", "exposeHarness")237fn expose_harness_ffi(dispatch: fn(message) -> Nil, solve: message) -> Nil238239/// The handle a screenshot harness drives the game through, and the one anybody240/// inspecting a phone over a remote debugger reaches for. It dispatches the same241/// message a thumb would, so a walked board and a played one are the same run.242pub fn expose_harness(solve: message) -> Effect(message) {243 effect.from(fn(dispatch) { expose_harness_ffi(dispatch, solve) })244}245246@external(javascript, "./browser_ffi.mjs", "survey")247fn survey_ffi(248 family_id: String,249 width: Int,250 height: Int,251 goal_x: Int,252 goal_y: Int,253 grid: List(Int),254) -> Nil255256pub fn survey(257 family_id: String,258 width: Int,259 height: Int,260 goal_x: Int,261 goal_y: Int,262 grid: List(Int),263) -> Effect(message) {264 effect.from(fn(_dispatch) {265 survey_ffi(family_id, width, height, goal_x, goal_y, grid)266 })267}268269@external(javascript, "./browser_ffi.mjs", "requestPuzzle")270fn request_puzzle_ffi(271 family_id: String,272 rung: Int,273 want: Int,274 ceiling: Bool,275 window: Int,276 offset: Int,277 seed: Int,278 stray: Float,279 rollouts: Int,280 goal_x: Int,281 goal_y: Int,282) -> Nil283284/// Ask for the candidates a rung may be drawn from.285///286/// Every number here was decided in Gleam: the depth the schedule asks for,287/// whether the ladder has run out of it, how wide the window is, where in the288/// band it starts, and the seed the walker is run under. The worker reaches into289/// the field and measures; it never judges.290pub fn request_puzzle(291 family_id family_id: String,292 rung rung: Int,293 want want: Int,294 ceiling ceiling: Bool,295 window window: Int,296 offset offset: Int,297 seed seed: Int,298 stray stray: Float,299 rollouts rollouts: Int,300 goal_x goal_x: Int,301 goal_y goal_y: Int,302) -> Effect(message) {303 effect.from(fn(_dispatch) {304 request_puzzle_ffi(305 family_id,306 rung,307 want,308 ceiling,309 window,310 offset,311 seed,312 stray,313 rollouts,314 goal_x,315 goal_y,316 )317 })318}319320@external(javascript, "./browser_ffi.mjs", "requestDistances")321fn request_distances_ffi(family_id: String, states: List(Int), seq: Int) -> Nil322323/// The exact distance of every state a move passed through, in order, so the324/// trace the record screen draws is measured rather than inferred.325pub fn request_distances(326 family_id: String,327 states: List(Int),328 seq: Int,329) -> Effect(message) {330 effect.from(fn(_dispatch) { request_distances_ffi(family_id, states, seq) })331}