gemba 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/THIRD_PARTY_NOTICES +37 -2
- data/assets/placeholder_boxart.png +0 -0
- data/bin/gemba +2 -2
- data/ext/gemba/extconf.rb +23 -1
- data/ext/gemba/gemba_ext.c +436 -2
- data/ext/gemba/gemba_ext.h +2 -0
- data/gemba.gemspec +5 -3
- data/lib/gemba/achievements/achievement.rb +23 -0
- data/lib/gemba/achievements/backend.rb +186 -0
- data/lib/gemba/achievements/cache.rb +70 -0
- data/lib/gemba/achievements/credentials_presenter.rb +142 -0
- data/lib/gemba/achievements/fake_backend.rb +205 -0
- data/lib/gemba/achievements/null_backend.rb +11 -0
- data/lib/gemba/achievements/offline_backend.rb +168 -0
- data/lib/gemba/achievements/retro_achievements/backend.rb +453 -0
- data/lib/gemba/achievements/retro_achievements/cli_sync_requester.rb +64 -0
- data/lib/gemba/achievements/retro_achievements/ping_worker.rb +27 -0
- data/lib/gemba/achievements.rb +19 -0
- data/lib/gemba/achievements_window.rb +556 -0
- data/lib/gemba/app_controller.rb +1015 -0
- data/lib/gemba/bios.rb +54 -0
- data/lib/gemba/boxart_fetcher/libretro_backend.rb +39 -0
- data/lib/gemba/boxart_fetcher/null_backend.rb +12 -0
- data/lib/gemba/boxart_fetcher.rb +79 -0
- data/lib/gemba/bus_emitter.rb +13 -0
- data/lib/gemba/child_window.rb +24 -1
- data/lib/gemba/cli/commands/config_cmd.rb +83 -0
- data/lib/gemba/cli/commands/decode.rb +154 -0
- data/lib/gemba/cli/commands/patch.rb +78 -0
- data/lib/gemba/cli/commands/play.rb +78 -0
- data/lib/gemba/cli/commands/record.rb +114 -0
- data/lib/gemba/cli/commands/replay.rb +161 -0
- data/lib/gemba/cli/commands/retro_achievements.rb +213 -0
- data/lib/gemba/cli/commands/version.rb +22 -0
- data/lib/gemba/cli.rb +52 -364
- data/lib/gemba/config.rb +134 -1
- data/lib/gemba/data/gb_games.json +1 -0
- data/lib/gemba/data/gb_md5.json +1 -0
- data/lib/gemba/data/gba_games.json +1 -0
- data/lib/gemba/data/gba_md5.json +1 -0
- data/lib/gemba/data/gbc_games.json +1 -0
- data/lib/gemba/data/gbc_md5.json +1 -0
- data/lib/gemba/emulator_frame.rb +1060 -0
- data/lib/gemba/event_bus.rb +48 -0
- data/lib/gemba/frame_stack.rb +60 -0
- data/lib/gemba/game_index.rb +84 -0
- data/lib/gemba/game_picker_frame.rb +268 -0
- data/lib/gemba/gamepad_map.rb +103 -0
- data/lib/gemba/headless.rb +6 -5
- data/lib/gemba/headless_player.rb +33 -3
- data/lib/gemba/help_window.rb +61 -0
- data/lib/gemba/hotkey_map.rb +3 -1
- data/lib/gemba/input_recorder.rb +107 -0
- data/lib/gemba/input_replayer.rb +119 -0
- data/lib/gemba/keyboard_map.rb +90 -0
- data/lib/gemba/locales/en.yml +97 -5
- data/lib/gemba/locales/ja.yml +97 -5
- data/lib/gemba/main_window.rb +56 -0
- data/lib/gemba/modal_stack.rb +81 -0
- data/lib/gemba/patcher_window.rb +223 -0
- data/lib/gemba/platform/gb.rb +21 -0
- data/lib/gemba/platform/gba.rb +21 -0
- data/lib/gemba/platform/gbc.rb +23 -0
- data/lib/gemba/platform.rb +20 -0
- data/lib/gemba/platform_open.rb +19 -0
- data/lib/gemba/recorder.rb +4 -3
- data/lib/gemba/replay_player.rb +691 -0
- data/lib/gemba/rom_info.rb +57 -0
- data/lib/gemba/rom_info_window.rb +16 -3
- data/lib/gemba/rom_library.rb +106 -0
- data/lib/gemba/rom_overrides.rb +47 -0
- data/lib/gemba/rom_patcher/bps.rb +161 -0
- data/lib/gemba/rom_patcher/ips.rb +101 -0
- data/lib/gemba/rom_patcher/ups.rb +118 -0
- data/lib/gemba/rom_patcher.rb +109 -0
- data/lib/gemba/{rom_loader.rb → rom_resolver.rb} +7 -6
- data/lib/gemba/runtime.rb +59 -26
- data/lib/gemba/save_state_manager.rb +4 -7
- data/lib/gemba/save_state_picker.rb +17 -4
- data/lib/gemba/session_logger.rb +64 -0
- data/lib/gemba/settings/audio_tab.rb +77 -0
- data/lib/gemba/settings/gamepad_tab.rb +351 -0
- data/lib/gemba/settings/hotkeys_tab.rb +259 -0
- data/lib/gemba/settings/paths.rb +11 -0
- data/lib/gemba/settings/recording_tab.rb +83 -0
- data/lib/gemba/settings/save_states_tab.rb +91 -0
- data/lib/gemba/settings/system_tab.rb +362 -0
- data/lib/gemba/settings/video_tab.rb +318 -0
- data/lib/gemba/settings_window.rb +162 -1036
- data/lib/gemba/version.rb +1 -1
- data/lib/gemba/virtual_keyboard.rb +19 -0
- data/lib/gemba.rb +2 -12
- data/test/achievements_window/test_bulk_sync.rb +218 -0
- data/test/achievements_window/test_bus_events.rb +125 -0
- data/test/achievements_window/test_close_confirmation.rb +201 -0
- data/test/achievements_window/test_initial_state.rb +164 -0
- data/test/achievements_window/test_sorting.rb +227 -0
- data/test/achievements_window/test_tree_rendering.rb +133 -0
- data/test/fixtures/fake_bios.bin +0 -0
- data/test/fixtures/pong.gba +0 -0
- data/test/fixtures/test.gb +0 -0
- data/test/fixtures/test.gbc +0 -0
- data/test/fixtures/test_quicksave.ss +0 -0
- data/test/screenshots/no_focus.png +0 -0
- data/test/shared/teek_test_worker.rb +17 -1
- data/test/shared/tk_test_helper.rb +91 -4
- data/test/support/achievements_window_helpers.rb +18 -0
- data/test/support/fake_core.rb +25 -0
- data/test/support/fake_ra_runtime.rb +74 -0
- data/test/support/fake_requester.rb +68 -0
- data/test/support/player_helpers.rb +20 -5
- data/test/test_achievement.rb +32 -0
- data/test/{test_player.rb → test_app_controller.rb} +353 -85
- data/test/test_bios.rb +123 -0
- data/test/test_boxart_fetcher.rb +150 -0
- data/test/test_cli.rb +17 -265
- data/test/test_cli_config.rb +64 -0
- data/test/test_cli_decode.rb +97 -0
- data/test/test_cli_patch.rb +58 -0
- data/test/test_cli_play.rb +213 -0
- data/test/test_cli_ra.rb +175 -0
- data/test/test_cli_record.rb +69 -0
- data/test/test_cli_replay.rb +72 -0
- data/test/test_cli_sync_requester.rb +152 -0
- data/test/test_cli_version.rb +27 -0
- data/test/test_config.rb +2 -3
- data/test/test_config_ra.rb +69 -0
- data/test/test_core.rb +62 -1
- data/test/test_credentials_presenter.rb +192 -0
- data/test/test_event_bus.rb +100 -0
- data/test/test_fake_backend_achievements.rb +130 -0
- data/test/test_fake_backend_auth.rb +68 -0
- data/test/test_game_index.rb +77 -0
- data/test/test_game_picker_frame.rb +310 -0
- data/test/test_gamepad_map.rb +1 -3
- data/test/test_headless_player.rb +17 -3
- data/test/test_help_window.rb +82 -0
- data/test/test_hotkey_map.rb +22 -1
- data/test/test_input_recorder.rb +179 -0
- data/test/test_input_replay_determinism.rb +113 -0
- data/test/test_input_replayer.rb +162 -0
- data/test/test_keyboard_map.rb +1 -3
- data/test/test_libretro_backend.rb +41 -0
- data/test/test_locale.rb +1 -1
- data/test/test_logging.rb +123 -0
- data/test/test_null_backend.rb +42 -0
- data/test/test_offline_backend.rb +116 -0
- data/test/test_overlay_renderer.rb +1 -1
- data/test/test_platform.rb +149 -0
- data/test/test_ra_backend.rb +313 -0
- data/test/test_ra_backend_unlock_gate.rb +56 -0
- data/test/test_recorder.rb +0 -3
- data/test/test_replay_player.rb +316 -0
- data/test/test_rom_info.rb +149 -0
- data/test/test_rom_overrides.rb +86 -0
- data/test/test_rom_patcher.rb +382 -0
- data/test/{test_rom_loader.rb → test_rom_resolver.rb} +25 -26
- data/test/test_save_state_manager.rb +2 -4
- data/test/test_settings_audio.rb +107 -0
- data/test/test_settings_hotkeys.rb +83 -66
- data/test/test_settings_recording.rb +49 -0
- data/test/test_settings_save_states.rb +97 -0
- data/test/test_settings_system.rb +133 -0
- data/test/test_settings_video.rb +450 -0
- data/test/test_settings_window.rb +76 -507
- data/test/test_tip_service.rb +6 -6
- data/test/test_toast_overlay.rb +1 -1
- data/test/test_virtual_events.rb +156 -0
- data/test/test_virtual_keyboard.rb +1 -1
- data/vendor/rcheevos/CHANGELOG.md +495 -0
- data/vendor/rcheevos/LICENSE +21 -0
- data/vendor/rcheevos/Package.swift +33 -0
- data/vendor/rcheevos/README.md +67 -0
- data/vendor/rcheevos/include/module.modulemap +70 -0
- data/vendor/rcheevos/include/rc_api_editor.h +296 -0
- data/vendor/rcheevos/include/rc_api_info.h +280 -0
- data/vendor/rcheevos/include/rc_api_request.h +77 -0
- data/vendor/rcheevos/include/rc_api_runtime.h +417 -0
- data/vendor/rcheevos/include/rc_api_user.h +262 -0
- data/vendor/rcheevos/include/rc_client.h +877 -0
- data/vendor/rcheevos/include/rc_client_raintegration.h +101 -0
- data/vendor/rcheevos/include/rc_consoles.h +138 -0
- data/vendor/rcheevos/include/rc_error.h +59 -0
- data/vendor/rcheevos/include/rc_export.h +100 -0
- data/vendor/rcheevos/include/rc_hash.h +200 -0
- data/vendor/rcheevos/include/rc_runtime.h +148 -0
- data/vendor/rcheevos/include/rc_runtime_types.h +452 -0
- data/vendor/rcheevos/include/rc_util.h +51 -0
- data/vendor/rcheevos/include/rcheevos.h +8 -0
- data/vendor/rcheevos/src/rapi/rc_api_common.c +1379 -0
- data/vendor/rcheevos/src/rapi/rc_api_common.h +88 -0
- data/vendor/rcheevos/src/rapi/rc_api_editor.c +625 -0
- data/vendor/rcheevos/src/rapi/rc_api_info.c +587 -0
- data/vendor/rcheevos/src/rapi/rc_api_runtime.c +901 -0
- data/vendor/rcheevos/src/rapi/rc_api_user.c +483 -0
- data/vendor/rcheevos/src/rc_client.c +6941 -0
- data/vendor/rcheevos/src/rc_client_external.c +281 -0
- data/vendor/rcheevos/src/rc_client_external.h +177 -0
- data/vendor/rcheevos/src/rc_client_external_versions.h +171 -0
- data/vendor/rcheevos/src/rc_client_internal.h +409 -0
- data/vendor/rcheevos/src/rc_client_raintegration.c +566 -0
- data/vendor/rcheevos/src/rc_client_raintegration_internal.h +61 -0
- data/vendor/rcheevos/src/rc_client_types.natvis +396 -0
- data/vendor/rcheevos/src/rc_compat.c +251 -0
- data/vendor/rcheevos/src/rc_compat.h +121 -0
- data/vendor/rcheevos/src/rc_libretro.c +915 -0
- data/vendor/rcheevos/src/rc_libretro.h +98 -0
- data/vendor/rcheevos/src/rc_util.c +199 -0
- data/vendor/rcheevos/src/rc_version.c +11 -0
- data/vendor/rcheevos/src/rc_version.h +32 -0
- data/vendor/rcheevos/src/rcheevos/alloc.c +312 -0
- data/vendor/rcheevos/src/rcheevos/condition.c +754 -0
- data/vendor/rcheevos/src/rcheevos/condset.c +777 -0
- data/vendor/rcheevos/src/rcheevos/consoleinfo.c +1215 -0
- data/vendor/rcheevos/src/rcheevos/format.c +330 -0
- data/vendor/rcheevos/src/rcheevos/lboard.c +287 -0
- data/vendor/rcheevos/src/rcheevos/memref.c +805 -0
- data/vendor/rcheevos/src/rcheevos/operand.c +607 -0
- data/vendor/rcheevos/src/rcheevos/rc_internal.h +390 -0
- data/vendor/rcheevos/src/rcheevos/rc_runtime_types.natvis +541 -0
- data/vendor/rcheevos/src/rcheevos/rc_validate.c +1406 -0
- data/vendor/rcheevos/src/rcheevos/rc_validate.h +18 -0
- data/vendor/rcheevos/src/rcheevos/richpresence.c +922 -0
- data/vendor/rcheevos/src/rcheevos/runtime.c +852 -0
- data/vendor/rcheevos/src/rcheevos/runtime_progress.c +1073 -0
- data/vendor/rcheevos/src/rcheevos/trigger.c +344 -0
- data/vendor/rcheevos/src/rcheevos/value.c +935 -0
- data/vendor/rcheevos/src/rhash/aes.c +480 -0
- data/vendor/rcheevos/src/rhash/aes.h +49 -0
- data/vendor/rcheevos/src/rhash/cdreader.c +838 -0
- data/vendor/rcheevos/src/rhash/hash.c +1402 -0
- data/vendor/rcheevos/src/rhash/hash_disc.c +1340 -0
- data/vendor/rcheevos/src/rhash/hash_encrypted.c +566 -0
- data/vendor/rcheevos/src/rhash/hash_rom.c +426 -0
- data/vendor/rcheevos/src/rhash/hash_zip.c +460 -0
- data/vendor/rcheevos/src/rhash/md5.c +382 -0
- data/vendor/rcheevos/src/rhash/md5.h +91 -0
- data/vendor/rcheevos/src/rhash/rc_hash_internal.h +116 -0
- data/vendor/rcheevos/test/libretro.h +205 -0
- data/vendor/rcheevos/test/rapi/test_rc_api_common.c +941 -0
- data/vendor/rcheevos/test/rapi/test_rc_api_editor.c +931 -0
- data/vendor/rcheevos/test/rapi/test_rc_api_info.c +545 -0
- data/vendor/rcheevos/test/rapi/test_rc_api_runtime.c +2213 -0
- data/vendor/rcheevos/test/rapi/test_rc_api_user.c +998 -0
- data/vendor/rcheevos/test/rcheevos/mock_memory.h +32 -0
- data/vendor/rcheevos/test/rcheevos/test_condition.c +570 -0
- data/vendor/rcheevos/test/rcheevos/test_condset.c +5170 -0
- data/vendor/rcheevos/test/rcheevos/test_consoleinfo.c +203 -0
- data/vendor/rcheevos/test/rcheevos/test_format.c +112 -0
- data/vendor/rcheevos/test/rcheevos/test_lboard.c +746 -0
- data/vendor/rcheevos/test/rcheevos/test_memref.c +520 -0
- data/vendor/rcheevos/test/rcheevos/test_operand.c +692 -0
- data/vendor/rcheevos/test/rcheevos/test_rc_validate.c +502 -0
- data/vendor/rcheevos/test/rcheevos/test_richpresence.c +1564 -0
- data/vendor/rcheevos/test/rcheevos/test_runtime.c +1667 -0
- data/vendor/rcheevos/test/rcheevos/test_runtime_progress.c +1821 -0
- data/vendor/rcheevos/test/rcheevos/test_timing.c +166 -0
- data/vendor/rcheevos/test/rcheevos/test_trigger.c +2521 -0
- data/vendor/rcheevos/test/rcheevos/test_value.c +870 -0
- data/vendor/rcheevos/test/rcheevos-test.sln +46 -0
- data/vendor/rcheevos/test/rcheevos-test.vcxproj +239 -0
- data/vendor/rcheevos/test/rcheevos-test.vcxproj.filters +335 -0
- data/vendor/rcheevos/test/rhash/data.c +657 -0
- data/vendor/rcheevos/test/rhash/data.h +32 -0
- data/vendor/rcheevos/test/rhash/mock_filereader.c +236 -0
- data/vendor/rcheevos/test/rhash/mock_filereader.h +31 -0
- data/vendor/rcheevos/test/rhash/test_cdreader.c +920 -0
- data/vendor/rcheevos/test/rhash/test_hash.c +310 -0
- data/vendor/rcheevos/test/rhash/test_hash_disc.c +1450 -0
- data/vendor/rcheevos/test/rhash/test_hash_rom.c +899 -0
- data/vendor/rcheevos/test/rhash/test_hash_zip.c +551 -0
- data/vendor/rcheevos/test/test.c +113 -0
- data/vendor/rcheevos/test/test_framework.h +205 -0
- data/vendor/rcheevos/test/test_rc_client.c +10509 -0
- data/vendor/rcheevos/test/test_rc_client_external.c +2197 -0
- data/vendor/rcheevos/test/test_rc_client_raintegration.c +441 -0
- data/vendor/rcheevos/test/test_rc_libretro.c +952 -0
- data/vendor/rcheevos/test/test_types.natvis +9 -0
- data/vendor/rcheevos/validator/validator.c +658 -0
- data/vendor/rcheevos/validator/validator.vcxproj +152 -0
- data/vendor/rcheevos/validator/validator.vcxproj.filters +82 -0
- metadata +274 -11
- data/lib/gemba/input_mappings.rb +0 -214
- data/lib/gemba/player.rb +0 -1525
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gemba
|
|
4
|
+
module Achievements
|
|
5
|
+
# Achievement backend backed by a local database — no HTTP, no rcheevos.
|
|
6
|
+
# Auth is a no-op: always authenticated.
|
|
7
|
+
#
|
|
8
|
+
# The DB maps ROM checksum → array of achievement definition Hashes:
|
|
9
|
+
# {
|
|
10
|
+
# id:, title:, description:, points:,
|
|
11
|
+
# trigger: :on_load | :memory,
|
|
12
|
+
# condition: ->(mem) { bool } # :memory trigger only
|
|
13
|
+
# }
|
|
14
|
+
#
|
|
15
|
+
# :on_load achievements fire immediately in load_game.
|
|
16
|
+
# :memory achievements are evaluated each frame in do_frame (rising edge).
|
|
17
|
+
#
|
|
18
|
+
# Long-term this DB can be populated by RcheevosBackend after a successful
|
|
19
|
+
# server sync, enabling offline play. For now it ships with a small
|
|
20
|
+
# built-in set (one achievement for the GEMBATEST fixture ROM).
|
|
21
|
+
#
|
|
22
|
+
# Tests assign this backend directly:
|
|
23
|
+
# frame.achievement_backend = Achievements::OfflineBackend.new
|
|
24
|
+
class OfflineBackend
|
|
25
|
+
include Backend
|
|
26
|
+
|
|
27
|
+
# Built-in achievement definitions, keyed by ROM checksum.
|
|
28
|
+
BUILTIN_DB = {
|
|
29
|
+
# test/fixtures/test.gba — checksum 3369266971, title "GEMBATEST"
|
|
30
|
+
3369266971 => [
|
|
31
|
+
{
|
|
32
|
+
id: 'gembatest_loaded',
|
|
33
|
+
title: 'Ready to Play',
|
|
34
|
+
description: 'Loaded the Gemba test ROM',
|
|
35
|
+
points: 1,
|
|
36
|
+
trigger: :on_load,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
# @param db [Hash, nil] fully replaces BUILTIN_DB when provided.
|
|
42
|
+
# Pass BUILTIN_DB.merge(extras) explicitly if you want both.
|
|
43
|
+
def initialize(db: nil)
|
|
44
|
+
@db = db || BUILTIN_DB
|
|
45
|
+
@achievements = []
|
|
46
|
+
@earned = {}
|
|
47
|
+
@prev_state = {}
|
|
48
|
+
@rp_db = {} # checksum → String
|
|
49
|
+
@rich_presence_message = nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# -- Authentication (no-op — offline backend is always authenticated) ------
|
|
53
|
+
|
|
54
|
+
def login_with_password(username:, password:)
|
|
55
|
+
return fire_auth_change(:error, 'Username and password required') if username.to_s.strip.empty? || password.to_s.strip.empty?
|
|
56
|
+
# Offline backend accepts any credentials — real auth happens via rcheevos
|
|
57
|
+
fire_auth_change(:ok, "offline_token_#{username.strip}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def login_with_token(username:, token:)
|
|
61
|
+
return if token.to_s.strip.empty?
|
|
62
|
+
fire_auth_change(:ok, nil)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def logout
|
|
66
|
+
fire_auth_change(:logout)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def authenticated? = true
|
|
70
|
+
|
|
71
|
+
def token_test
|
|
72
|
+
fire_auth_change(:ok, nil)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# -- Game lifecycle -------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
def load_game(core, rom_path = nil, md5 = nil)
|
|
78
|
+
@achievements = []
|
|
79
|
+
@earned = {}
|
|
80
|
+
@prev_state = {}
|
|
81
|
+
@rich_presence_message = @rp_db[core.checksum]
|
|
82
|
+
|
|
83
|
+
(@db[core.checksum] || []).each do |defn|
|
|
84
|
+
ach = Achievement.new(
|
|
85
|
+
id: defn[:id],
|
|
86
|
+
title: defn[:title],
|
|
87
|
+
description: defn[:description],
|
|
88
|
+
points: defn[:points],
|
|
89
|
+
earned_at: nil,
|
|
90
|
+
)
|
|
91
|
+
@achievements << ach
|
|
92
|
+
@prev_state[ach.id] = false
|
|
93
|
+
|
|
94
|
+
if defn[:trigger] == :on_load
|
|
95
|
+
earned = ach.earn
|
|
96
|
+
@earned[ach.id] = earned
|
|
97
|
+
fire_unlock(earned)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def unload_game
|
|
103
|
+
@achievements = []
|
|
104
|
+
@earned = {}
|
|
105
|
+
@prev_state = {}
|
|
106
|
+
@rich_presence_message = nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# -- Per-frame evaluation (memory-condition achievements) -----------------
|
|
110
|
+
|
|
111
|
+
def do_frame(core)
|
|
112
|
+
(@db[core.checksum] || []).each do |defn|
|
|
113
|
+
next unless defn[:trigger] == :memory
|
|
114
|
+
next if @earned.key?(defn[:id])
|
|
115
|
+
|
|
116
|
+
condition = defn[:condition]
|
|
117
|
+
next unless condition
|
|
118
|
+
|
|
119
|
+
read_mem = ->(addr) { core.bus_read8(addr) }
|
|
120
|
+
current = condition.call(read_mem) ? true : false
|
|
121
|
+
|
|
122
|
+
if current && !@prev_state[defn[:id]]
|
|
123
|
+
ach = @achievements.find { |a| a.id == defn[:id] }
|
|
124
|
+
if ach
|
|
125
|
+
earned = ach.earn
|
|
126
|
+
@earned[ach.id] = earned
|
|
127
|
+
fire_unlock(earned)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
@prev_state[defn[:id]] = current
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# -- Achievement list -----------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def achievement_list
|
|
138
|
+
@achievements.map { |a| @earned[a.id] || a }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def enabled? = true
|
|
142
|
+
|
|
143
|
+
def rich_presence_message
|
|
144
|
+
@rich_presence_message
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# -- DB management --------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
# Merge achievement definitions for a ROM into the in-memory DB.
|
|
150
|
+
# Intended for use by RcheevosBackend to seed the offline cache.
|
|
151
|
+
#
|
|
152
|
+
# @param checksum [Integer]
|
|
153
|
+
# @param defs [Array<Hash>]
|
|
154
|
+
def store(checksum, defs)
|
|
155
|
+
@db = @db.merge(checksum => defs)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Store a static Rich Presence message for a ROM.
|
|
159
|
+
# Intended for use by RcheevosBackend when caching patch data offline.
|
|
160
|
+
#
|
|
161
|
+
# @param checksum [Integer]
|
|
162
|
+
# @param message [String]
|
|
163
|
+
def store_rich_presence(checksum, message)
|
|
164
|
+
@rp_db = @rp_db.merge(checksum => message.to_s)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module Gemba
|
|
8
|
+
module Achievements
|
|
9
|
+
module RetroAchievements
|
|
10
|
+
# Achievement backend that talks to retroachievements.org.
|
|
11
|
+
#
|
|
12
|
+
# All requests are HTTP POSTs to /dorequest.php. The 'r' parameter tells
|
|
13
|
+
# the server what you want — it's RA's own naming, not ours:
|
|
14
|
+
#
|
|
15
|
+
# r=login2 authenticate (password or token)
|
|
16
|
+
# r=gameid "here's a ROM MD5 hash — what game ID is it?"
|
|
17
|
+
# r=patch "give me the achievement definitions for this game"
|
|
18
|
+
# (called 'patch' because RA's original concept was that
|
|
19
|
+
# achievements are a 'patch' bolted on top of a ROM —
|
|
20
|
+
# extra behaviour injected into the game. the name stuck.)
|
|
21
|
+
# r=unlocks "which of these achievements has the player already earned?"
|
|
22
|
+
# r=awardachievement "the player just earned this achievement, record it"
|
|
23
|
+
#
|
|
24
|
+
# Most HTTP is done off the main thread via Teek::BackgroundWork (thread mode).
|
|
25
|
+
# The ping heartbeat uses PING_BG_MODE which selects ractor mode on Ruby 4+.
|
|
26
|
+
#
|
|
27
|
+
# Authentication flow:
|
|
28
|
+
# login_with_password → r=login2 + password → stores token, fires :ok
|
|
29
|
+
# login_with_token → r=login2 + token → verifies token, fires :ok/:error
|
|
30
|
+
# token_test → same as login_with_token using stored creds
|
|
31
|
+
#
|
|
32
|
+
# Game load flow (all requests are chained — each fires when the previous
|
|
33
|
+
# HTTP response comes back):
|
|
34
|
+
#
|
|
35
|
+
# load_game(core, rom_path)
|
|
36
|
+
# → MD5 hash the ROM file
|
|
37
|
+
# → r=gameid (MD5) — server tells us the RA game ID
|
|
38
|
+
# → r=patch (game ID) — server sends achievement definitions
|
|
39
|
+
# → r=unlocks (game ID) — server says which the player already has
|
|
40
|
+
# → activate each un-earned achievement in the C runtime
|
|
41
|
+
# (parse conditions, load into hash table so do_frame checks them)
|
|
42
|
+
#
|
|
43
|
+
# Achievements are not loaded into the runtime until AFTER the unlocks
|
|
44
|
+
# response arrives. This means @achievements stays empty during the
|
|
45
|
+
# network round-trips, and do_frame's early-return guard prevents any
|
|
46
|
+
# evaluation before we know what the player has already earned.
|
|
47
|
+
class Backend
|
|
48
|
+
include Achievements::Backend
|
|
49
|
+
|
|
50
|
+
RA_HOST = "retroachievements.org"
|
|
51
|
+
RA_PATH = "/dorequest.php"
|
|
52
|
+
PING_BG_MODE = (RUBY_VERSION >= "4.0" ? :ractor : :thread).freeze
|
|
53
|
+
|
|
54
|
+
# Frames between Rich Presence evaluations (~4 s at 60 fps).
|
|
55
|
+
RP_EVAL_INTERVAL = 240
|
|
56
|
+
# Seconds between session ping heartbeats.
|
|
57
|
+
PING_INTERVAL_SEC = 120
|
|
58
|
+
|
|
59
|
+
# Default requester: delegates to Teek::BackgroundWork.
|
|
60
|
+
# Extracted so tests can inject a synchronous fake with the same interface.
|
|
61
|
+
DEFAULT_REQUESTER = lambda do |app, params, mode: :thread, **opts, &block|
|
|
62
|
+
Teek::BackgroundWork.new(app, params, mode: mode, **opts, &block)
|
|
63
|
+
end.freeze
|
|
64
|
+
|
|
65
|
+
def initialize(app:, runtime: nil, requester: nil)
|
|
66
|
+
@app = app
|
|
67
|
+
@requester = requester || DEFAULT_REQUESTER
|
|
68
|
+
@username = nil
|
|
69
|
+
@token = nil
|
|
70
|
+
@game_id = nil
|
|
71
|
+
@achievements = []
|
|
72
|
+
@earned = {}
|
|
73
|
+
@authenticated = false
|
|
74
|
+
@include_unofficial = false
|
|
75
|
+
@rich_presence_enabled = false
|
|
76
|
+
@rich_presence_message = nil
|
|
77
|
+
@rp_eval_frame = 0
|
|
78
|
+
@ping_last_at = nil
|
|
79
|
+
@ra_runtime = runtime || Gemba::RARuntime.new
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
attr_writer :include_unofficial
|
|
83
|
+
attr_writer :rich_presence_enabled
|
|
84
|
+
attr_reader :rich_presence_message
|
|
85
|
+
|
|
86
|
+
# -- Authentication -------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def login_with_password(username:, password:)
|
|
89
|
+
ra_request(r: "login2", u: username, p: password) do |json, ok|
|
|
90
|
+
if ok && json&.dig("Success")
|
|
91
|
+
@username = username
|
|
92
|
+
@token = json["Token"]
|
|
93
|
+
@authenticated = true
|
|
94
|
+
Gemba.log(:info) { "RA: authenticated as #{username}" }
|
|
95
|
+
fire_auth_change(:ok, @token)
|
|
96
|
+
else
|
|
97
|
+
msg = json&.dig("Error") || "Login failed"
|
|
98
|
+
Gemba.log(:warn) { "RA: authentication failed for #{username}: #{msg}" }
|
|
99
|
+
fire_auth_change(:error, msg)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def login_with_token(username:, token:)
|
|
105
|
+
@username = username
|
|
106
|
+
@token = token
|
|
107
|
+
ra_request(r: "login2", u: username, t: token) do |json, ok|
|
|
108
|
+
if ok && json&.dig("Success")
|
|
109
|
+
@authenticated = true
|
|
110
|
+
Gemba.log(:info) { "RA: token verified for #{username}" }
|
|
111
|
+
fire_auth_change(:ok, nil)
|
|
112
|
+
else
|
|
113
|
+
@authenticated = false
|
|
114
|
+
msg = json&.dig("Error") || "Token invalid"
|
|
115
|
+
Gemba.log(:warn) { "RA: token verification failed for #{username}: #{msg}" }
|
|
116
|
+
fire_auth_change(:error, msg)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def token_test
|
|
122
|
+
ra_request(r: "login2", u: @username, t: @token) do |json, ok|
|
|
123
|
+
if ok && json&.dig("Success")
|
|
124
|
+
fire_auth_change(:ok, nil)
|
|
125
|
+
else
|
|
126
|
+
@authenticated = false
|
|
127
|
+
fire_auth_change(:error, json&.dig("Error") || "Token invalid")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def logout
|
|
133
|
+
@username = nil
|
|
134
|
+
@token = nil
|
|
135
|
+
@authenticated = false
|
|
136
|
+
@game_id = nil
|
|
137
|
+
@achievements = []
|
|
138
|
+
@earned = {}
|
|
139
|
+
@ra_runtime.clear
|
|
140
|
+
fire_auth_change(:logout)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def authenticated? = @authenticated
|
|
144
|
+
def enabled? = true
|
|
145
|
+
|
|
146
|
+
# -- Game lifecycle -------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def load_game(core, rom_path = nil, md5 = nil)
|
|
149
|
+
return unless @authenticated
|
|
150
|
+
return unless rom_path && File.exist?(rom_path.to_s)
|
|
151
|
+
|
|
152
|
+
@achievements = []
|
|
153
|
+
@earned = {}
|
|
154
|
+
@game_id = nil
|
|
155
|
+
@rich_presence_message = nil
|
|
156
|
+
@rp_eval_frame = 0
|
|
157
|
+
@ping_last_at = nil
|
|
158
|
+
|
|
159
|
+
# Use pre-computed digest if available (computed at ROM load time and
|
|
160
|
+
# cached in rom_library.json); fall back to computing it here for entries
|
|
161
|
+
# that pre-date MD5 storage.
|
|
162
|
+
md5 ||= Digest::MD5.file(rom_path).hexdigest
|
|
163
|
+
|
|
164
|
+
ra_request(r: "gameid", m: md5) do |json, ok|
|
|
165
|
+
next unless ok
|
|
166
|
+
game_id = json&.dig("GameID")&.to_i
|
|
167
|
+
next if !game_id || game_id == 0
|
|
168
|
+
|
|
169
|
+
@game_id = game_id
|
|
170
|
+
fetch_patch_data(game_id)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Called after a save state is loaded. Memory just jumped to an arbitrary
|
|
175
|
+
# saved state, so every achievement must go back through the priming and
|
|
176
|
+
# waiting startup sequence — otherwise achievements that were already active
|
|
177
|
+
# fire instantly if the saved memory happens to satisfy their conditions.
|
|
178
|
+
def reset_runtime
|
|
179
|
+
@ra_runtime.reset_all
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def unload_game
|
|
183
|
+
@game_id = nil
|
|
184
|
+
@achievements = []
|
|
185
|
+
@earned = {}
|
|
186
|
+
@rich_presence_message = nil
|
|
187
|
+
@rp_eval_frame = 0
|
|
188
|
+
@ping_last_at = nil
|
|
189
|
+
@ra_runtime.clear
|
|
190
|
+
fire_achievements_changed
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def sync_unlocks
|
|
194
|
+
return unless @authenticated
|
|
195
|
+
Gemba.bus.emit(:ra_sync_started)
|
|
196
|
+
unless @game_id
|
|
197
|
+
Gemba.bus.emit(:ra_sync_done, ok: false, reason: :no_game)
|
|
198
|
+
return
|
|
199
|
+
end
|
|
200
|
+
@earned = {}
|
|
201
|
+
@achievements = []
|
|
202
|
+
@ra_runtime.reset_all
|
|
203
|
+
fetch_patch_data(@game_id, emit_sync_done: true)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def do_frame(core)
|
|
207
|
+
return if @achievements.empty?
|
|
208
|
+
|
|
209
|
+
triggered_ids = @ra_runtime.do_frame(core)
|
|
210
|
+
triggered_ids.each do |id|
|
|
211
|
+
next if @earned.key?(id)
|
|
212
|
+
ach = @achievements.find { |a| a.id == id }
|
|
213
|
+
next unless ach
|
|
214
|
+
|
|
215
|
+
earned = ach.earn
|
|
216
|
+
@earned[id] = earned
|
|
217
|
+
fire_unlock(earned)
|
|
218
|
+
submit_unlock(id)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
return unless @rich_presence_enabled
|
|
222
|
+
|
|
223
|
+
@rp_eval_frame = (@rp_eval_frame + 1) % RP_EVAL_INTERVAL
|
|
224
|
+
return unless @rp_eval_frame == 0
|
|
225
|
+
|
|
226
|
+
msg = @ra_runtime.get_richpresence(core)
|
|
227
|
+
if msg && msg != @rich_presence_message
|
|
228
|
+
@rich_presence_message = msg
|
|
229
|
+
fire_rich_presence_changed(msg)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
now = Time.now
|
|
233
|
+
if @game_id && @authenticated && (@ping_last_at.nil? || now - @ping_last_at >= PING_INTERVAL_SEC)
|
|
234
|
+
@ping_last_at = now
|
|
235
|
+
ping_game_session
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# -- Achievement list -----------------------------------------------------
|
|
240
|
+
|
|
241
|
+
def achievement_list
|
|
242
|
+
@achievements.map { |a| @earned[a.id] || a }
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Fetch the full achievement list for any ROM by its RomInfo, purely for
|
|
246
|
+
# display. Does not touch the live game state (@achievements, @earned,
|
|
247
|
+
# @ra_runtime). Calls the block on the main thread with Array<Achievement>
|
|
248
|
+
# on success or nil on failure.
|
|
249
|
+
#
|
|
250
|
+
# Request chain (all POST to /dorequest.php):
|
|
251
|
+
# r=gameid m=<md5>
|
|
252
|
+
# r=patch u= t= g=<game_id>
|
|
253
|
+
# r=unlocks u= t= g=<game_id> h=0
|
|
254
|
+
def fetch_for_display(rom_info:, &callback)
|
|
255
|
+
return unless @authenticated && rom_info.md5
|
|
256
|
+
|
|
257
|
+
Gemba.log(:info) { "RA fetch_for_display: gameid lookup md5=#{rom_info.md5[0, 8]}… (#{rom_info.title})" }
|
|
258
|
+
|
|
259
|
+
ra_request(r: "gameid", m: rom_info.md5) do |json, ok|
|
|
260
|
+
game_id = ok ? json&.dig("GameID")&.to_i : nil
|
|
261
|
+
Gemba.log(game_id&.positive? ? :info : :warn) {
|
|
262
|
+
"RA fetch_for_display: gameid → #{game_id.inspect} ok=#{ok}"
|
|
263
|
+
}
|
|
264
|
+
unless game_id && game_id > 0
|
|
265
|
+
callback.call(nil)
|
|
266
|
+
next
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
ra_request(r: "patch", u: @username, t: @token, g: game_id) do |patch_json, patch_ok|
|
|
270
|
+
Gemba.log(patch_ok ? :info : :warn) {
|
|
271
|
+
"RA fetch_for_display: patch g=#{game_id} ok=#{patch_ok} achievements=#{patch_json&.dig("PatchData", "Achievements")&.size.inspect}"
|
|
272
|
+
}
|
|
273
|
+
unless patch_ok && patch_json
|
|
274
|
+
callback.call(nil)
|
|
275
|
+
next
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
achievements = (patch_json.dig("PatchData", "Achievements") || []).filter_map do |a|
|
|
279
|
+
next if a["MemAddr"].to_s.empty?
|
|
280
|
+
next if a["Flags"].to_i != 3 && !(a["Flags"].to_i == 5 && @include_unofficial)
|
|
281
|
+
next if a["ID"].to_i > 100_000_000 # skip RA-injected system messages
|
|
282
|
+
Achievement.new(
|
|
283
|
+
id: a["ID"].to_s,
|
|
284
|
+
title: a["Title"].to_s,
|
|
285
|
+
description: a["Description"].to_s,
|
|
286
|
+
points: a["Points"].to_i,
|
|
287
|
+
earned_at: nil,
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
ra_request(r: "unlocks", u: @username, t: @token, g: game_id, h: 0) do |ul_json, ul_ok|
|
|
292
|
+
earned_ids = ul_ok && ul_json&.dig("Success") ?
|
|
293
|
+
(ul_json.dig("UserUnlocks") || []).map(&:to_s) : []
|
|
294
|
+
Gemba.log(ul_ok ? :info : :warn) {
|
|
295
|
+
"RA fetch_for_display: unlocks g=#{game_id} ok=#{ul_ok} earned=#{earned_ids.size} total=#{achievements.size}"
|
|
296
|
+
}
|
|
297
|
+
result = achievements.map { |a| earned_ids.include?(a.id) ? a.earn : a }
|
|
298
|
+
callback.call(result)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private
|
|
305
|
+
|
|
306
|
+
# Fetch patch data (achievement definitions). Does NOT activate the runtime
|
|
307
|
+
# or populate @achievements — that happens only after unlocks are known,
|
|
308
|
+
# in activate_from_patch. This ensures do_frame can never evaluate and
|
|
309
|
+
# award achievements during the window between patch data and unlocks.
|
|
310
|
+
def fetch_patch_data(game_id, emit_sync_done: false)
|
|
311
|
+
ra_request(r: "patch", u: @username, t: @token, g: game_id) do |json, ok|
|
|
312
|
+
unless ok && json
|
|
313
|
+
Gemba.log(:warn) { "RA: failed to fetch patch data for game #{game_id}" }
|
|
314
|
+
Gemba.bus.emit(:ra_sync_done, ok: false) if emit_sync_done
|
|
315
|
+
next
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
rp_script = json.dig("PatchData", "RichPresencePatch").to_s
|
|
319
|
+
|
|
320
|
+
raw = (json.dig("PatchData", "Achievements") || []).select do |a|
|
|
321
|
+
!a["MemAddr"].to_s.empty? &&
|
|
322
|
+
(a["Flags"].to_i == 3 || (a["Flags"].to_i == 5 && @include_unofficial)) &&
|
|
323
|
+
a["ID"].to_i <= 100_000_000
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
fetch_unlocks(game_id, raw_ach_data: raw, rp_script: rp_script, emit_sync_done: emit_sync_done)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# Fetch already-earned achievement IDs, then activate the runtime with
|
|
331
|
+
# all achievements, immediately deactivating the already-earned ones.
|
|
332
|
+
# Only after this step is @achievements populated — so do_frame's
|
|
333
|
+
# `return if @achievements.empty?` guard covers the entire window.
|
|
334
|
+
def fetch_unlocks(game_id, raw_ach_data: nil, rp_script: nil, emit_sync_done: false)
|
|
335
|
+
ra_request(r: "unlocks", u: @username, t: @token, g: game_id, h: 0) do |json, ok|
|
|
336
|
+
unless ok && json&.dig("Success")
|
|
337
|
+
Gemba.log(:warn) { "RA: failed to fetch unlocks for game #{game_id}" }
|
|
338
|
+
Gemba.bus.emit(:ra_sync_done, ok: false) if emit_sync_done
|
|
339
|
+
next
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
earned_ids = (json.dig("UserUnlocks") || []).map(&:to_s)
|
|
343
|
+
|
|
344
|
+
if raw_ach_data
|
|
345
|
+
# Fresh load / re-sync: activate runtime now that we know earned set.
|
|
346
|
+
@ra_runtime.clear
|
|
347
|
+
@achievements = raw_ach_data.filter_map do |a|
|
|
348
|
+
id = a["ID"].to_s
|
|
349
|
+
memaddr = a["MemAddr"].to_s
|
|
350
|
+
begin
|
|
351
|
+
@ra_runtime.activate(id, memaddr)
|
|
352
|
+
rescue ArgumentError => e
|
|
353
|
+
Gemba.log(:warn) { "RA: skipping achievement #{id} — #{e.message}" }
|
|
354
|
+
next
|
|
355
|
+
end
|
|
356
|
+
@ra_runtime.deactivate(id) if earned_ids.include?(id)
|
|
357
|
+
Achievement.new(
|
|
358
|
+
id: id,
|
|
359
|
+
title: a["Title"].to_s,
|
|
360
|
+
description: a["Description"].to_s,
|
|
361
|
+
points: a["Points"].to_i,
|
|
362
|
+
earned_at: nil,
|
|
363
|
+
)
|
|
364
|
+
end
|
|
365
|
+
Gemba.log(:info) { "RA: loaded #{@achievements.size} achievements for game #{game_id}" }
|
|
366
|
+
|
|
367
|
+
if rp_script && !rp_script.empty?
|
|
368
|
+
ok = @ra_runtime.activate_richpresence(rp_script)
|
|
369
|
+
Gemba.log(ok ? :info : :warn) { "RA: rich presence script #{ok ? "activated" : "failed to parse"} for game #{game_id}" }
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
newly_marked = 0
|
|
374
|
+
earned_ids.each do |id|
|
|
375
|
+
next if @earned.key?(id)
|
|
376
|
+
ach = @achievements.find { |a| a.id == id }
|
|
377
|
+
next unless ach
|
|
378
|
+
earned = ach.earn
|
|
379
|
+
@earned[id] = earned
|
|
380
|
+
newly_marked += 1
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
Gemba.log(:info) { "RA: synced #{newly_marked} pre-earned achievements for game #{game_id}" } if newly_marked > 0
|
|
384
|
+
fire_achievements_changed
|
|
385
|
+
Gemba.bus.emit(:ra_sync_done, ok: true) if emit_sync_done
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# POST r=ping heartbeat — keeps the RA session alive and records
|
|
390
|
+
# the current Rich Presence string on the server.
|
|
391
|
+
# Runs via PingWorker which is Ractor-safe on Ruby 4+.
|
|
392
|
+
def ping_game_session
|
|
393
|
+
data = {
|
|
394
|
+
host: RA_HOST,
|
|
395
|
+
path: RA_PATH,
|
|
396
|
+
params: {
|
|
397
|
+
"r" => "ping",
|
|
398
|
+
"u" => @username,
|
|
399
|
+
"t" => @token,
|
|
400
|
+
"g" => @game_id.to_s,
|
|
401
|
+
"m" => @rich_presence_message.to_s,
|
|
402
|
+
},
|
|
403
|
+
}
|
|
404
|
+
data = Ractor.make_shareable(data) if PING_BG_MODE == :ractor
|
|
405
|
+
game_id = @game_id
|
|
406
|
+
@requester.call(@app, data, mode: PING_BG_MODE, worker: PingWorker)
|
|
407
|
+
.on_progress { |ok| Gemba.log(ok ? :info : :warn) { "RA: ping g=#{game_id} ok=#{ok}" } }
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Best-effort unlock submission — fires and forgets, result only logged.
|
|
411
|
+
def submit_unlock(achievement_id, hardcore: false)
|
|
412
|
+
ra_request(r: "awardachievement", u: @username, t: @token,
|
|
413
|
+
a: achievement_id, h: hardcore ? 1 : 0) do |json, ok|
|
|
414
|
+
if ok && json&.dig("Success")
|
|
415
|
+
Gemba.log(:info) { "RA: submitted unlock for achievement #{achievement_id}" }
|
|
416
|
+
else
|
|
417
|
+
Gemba.log(:warn) { "RA: unlock submission failed for #{achievement_id}: #{json&.dig("Error")}" }
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# POST to dorequest.php via @requester (BackgroundWork in production,
|
|
423
|
+
# a synchronous fake in tests). Calls on_done with (json_or_nil, ok_bool).
|
|
424
|
+
def ra_request(params, &on_done)
|
|
425
|
+
@requester.call(@app, params, mode: :thread) do |t, req_params|
|
|
426
|
+
uri = URI::HTTPS.build(host: RA_HOST, path: RA_PATH)
|
|
427
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
428
|
+
http.use_ssl = true
|
|
429
|
+
http.read_timeout = 15
|
|
430
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
431
|
+
safe = req_params.reject { |k, _| [:t, :p, "t", "p"].include?(k) }
|
|
432
|
+
Gemba.log(:info) { "RA request: r=#{params[:r]} #{safe.map { |k, v| "#{k}=#{v}" }.join(" ")}" }
|
|
433
|
+
req.set_form_data(req_params.transform_keys(&:to_s).transform_values(&:to_s))
|
|
434
|
+
resp = http.request(req)
|
|
435
|
+
if resp.is_a?(Net::HTTPSuccess)
|
|
436
|
+
body = resp.body
|
|
437
|
+
Gemba.log(:info) { "RA response: r=#{params[:r]} HTTP #{resp.code} body=#{body.length}b" }
|
|
438
|
+
t.yield([JSON.parse(body), true])
|
|
439
|
+
else
|
|
440
|
+
Gemba.log(:warn) { "RA response: r=#{params[:r]} HTTP #{resp.code} #{resp.message} body=#{resp.body.to_s[0, 200]}" }
|
|
441
|
+
t.yield([nil, false])
|
|
442
|
+
end
|
|
443
|
+
rescue => e
|
|
444
|
+
Gemba.log(:warn) { "RA: request error (#{params[:r]}): #{e.class} #{e.message}" }
|
|
445
|
+
t.yield([nil, false])
|
|
446
|
+
end.on_progress do |result|
|
|
447
|
+
on_done.call(*result) if on_done && result
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Gemba
|
|
7
|
+
module Achievements
|
|
8
|
+
module RetroAchievements
|
|
9
|
+
# Synchronous HTTP requester for CLI use.
|
|
10
|
+
#
|
|
11
|
+
# Implements the same interface as FakeRequester and the DEFAULT_REQUESTER
|
|
12
|
+
# lambda in Backend — call() returns a Result that fires on_progress
|
|
13
|
+
# synchronously — but makes a real blocking Net::HTTP POST instead of
|
|
14
|
+
# delegating to Teek::BackgroundWork.
|
|
15
|
+
#
|
|
16
|
+
# This means CLI commands get their result back in-line with no event loop.
|
|
17
|
+
class CliSyncRequester
|
|
18
|
+
# Mirrors FakeRequester::Result so the calling code (ra_request) is
|
|
19
|
+
# identical regardless of whether it's running in CLI or GUI mode.
|
|
20
|
+
class Result
|
|
21
|
+
def initialize(value)
|
|
22
|
+
@value = value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def on_progress(&block)
|
|
26
|
+
block.call(@value)
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def on_done(&block)
|
|
31
|
+
self
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Called by ra_request with the same signature as the DEFAULT_REQUESTER
|
|
36
|
+
# lambda. Ignores the BackgroundWork block (which uses t.yield / Ractor
|
|
37
|
+
# protocol) and performs a direct synchronous HTTP POST instead.
|
|
38
|
+
def call(_app, params, mode: nil, **_opts, &_block)
|
|
39
|
+
Result.new(perform(params))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def perform(params)
|
|
45
|
+
uri = URI::HTTPS.build(host: Backend::RA_HOST, path: Backend::RA_PATH)
|
|
46
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
47
|
+
http.use_ssl = true
|
|
48
|
+
http.read_timeout = 15
|
|
49
|
+
req = Net::HTTP::Post.new(uri.path)
|
|
50
|
+
req.set_form_data(params.transform_keys(&:to_s).transform_values(&:to_s))
|
|
51
|
+
resp = http.request(req)
|
|
52
|
+
if resp.is_a?(Net::HTTPSuccess)
|
|
53
|
+
[JSON.parse(resp.body), true]
|
|
54
|
+
else
|
|
55
|
+
[nil, false]
|
|
56
|
+
end
|
|
57
|
+
rescue => e
|
|
58
|
+
$stderr.puts "RA request error (#{params[:r]}): #{e.class} #{e.message}"
|
|
59
|
+
[nil, false]
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|