Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ddac2775f | ||
|
|
1e9520dc14 | ||
|
|
55b90fb0a5 | ||
|
|
fa68e2e04a | ||
|
|
9d792bdee4 | ||
|
|
a8d72024f2 | ||
|
|
f06467fcfa | ||
|
|
f07d1cf830 | ||
|
|
da3777b4cc | ||
|
|
650bfb3533 | ||
|
|
88a1ba9b34 | ||
|
|
de3b9485c1 | ||
|
|
21775542d5 | ||
|
|
dcb16ef4d0 | ||
|
|
00323d7916 |
@@ -24,3 +24,7 @@
|
||||
{"id":"int-2cbd2a1d","kind":"field_change","created_at":"2026-08-18T10:41:46.8206Z","actor":"E.Gavrilov","issue_id":"goon-game-level10-sliding-corpse","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Acceptance met: delivered body remains on lift plate, signal stays active, normal 3x and delayed-input level 10 routes pass."}}
|
||||
{"id":"int-a046aa8d","kind":"field_change","created_at":"2026-08-18T10:41:48.065633Z","actor":"E.Gavrilov","issue_id":"goon-game-level-route-risks","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All intended risky routes are reproduced by connected bots; possible L2 alternative is explicitly accepted and documented as non-blocking."}}
|
||||
{"id":"int-33f283fa","kind":"field_change","created_at":"2026-08-18T11:43:04.54852Z","actor":"E.Gavrilov","issue_id":"goon-game-controls-settings","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Acceptance met: size, opacity and vibration settings persist; real touch targets and held-jump edge are tested; UI contract passes 16:9, 20:9 and tablet-sized displays."}}
|
||||
{"id":"int-002dd441","kind":"field_change","created_at":"2026-08-18T16:26:41.026722Z","actor":"E.Gavrilov","issue_id":"goon-game-accessibility","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Stage 3 engineering accepted: final Android 16 unfiltered gate OK 30/30 in 1409.366 s; UI contracts also GREEN on 20:9, 16:9 and 16:10."}}
|
||||
{"id":"int-96bf6e31","kind":"field_change","created_at":"2026-08-18T16:26:41.415975Z","actor":"E.Gavrilov","issue_id":"goon-game-stage3-onboarding-results","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Stage 3 engineering accepted: final Android 16 unfiltered gate OK 30/30 in 1409.366 s; UI contracts also GREEN on 20:9, 16:9 and 16:10."}}
|
||||
{"id":"int-d07e319c","kind":"field_change","created_at":"2026-08-18T16:26:41.837124Z","actor":"E.Gavrilov","issue_id":"goon-game-stage3-localization","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Stage 3 engineering accepted: final Android 16 unfiltered gate OK 30/30 in 1409.366 s; UI contracts also GREEN on 20:9, 16:9 and 16:10."}}
|
||||
{"id":"int-b7849cda","kind":"field_change","created_at":"2026-08-18T16:26:42.858032Z","actor":"E.Gavrilov","issue_id":"goon-game-menus-localization","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Persistent RU/EN, onboarding and full result flow integrated and verified by final 30/30 gate. External human comprehension playtest remains tracked separately."}}
|
||||
|
||||
+4
-2
@@ -25,6 +25,8 @@
|
||||
{"_type":"issue","id":"goon-game-e03","title":"EPIC-03 Physics","description":"Предсказуемая гибридная физика и бюджеты активных объектов. Разделы 15, 41–43, 65.","acceptance_criteria":"Одинаковая последовательность ввода воспроизводимо приводит к одинаковому состоянию головоломки.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:49Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:38:49Z","labels":["epic","physics"],"dependencies":[{"issue_id":"goon-game-e03","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:44Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-e02","title":"EPIC-02 Death \u0026 Corpse","description":"Смерть как игровая механика, состояния тела, CorpseManager и respawn. Разделы 4–8.","acceptance_criteria":"Все типы смерти создают предсказуемое и пригодное для головоломки состояние тела.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:47Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:38:47Z","labels":["corpse","death","epic"],"dependencies":[{"issue_id":"goon-game-e02","depends_on_id":"goon-game-plan","type":"parent-child","created_at":"2026-08-18T00:42:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-plan","title":"Project Sacrifice: выполнить утверждённый production plan","description":"Главный bead для плана из docs/DEVELOPMENT_PLAN.md: vertical slice, production, alpha, beta и release candidate.","acceptance_criteria":"Release Candidate удовлетворяет критериям раздела 59; все дочерние эпики завершены либо явно исключены решением владельца продукта.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"in_progress","priority":0,"issue_type":"epic","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:38:23Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:44:08Z","started_at":"2026-08-17T21:41:28Z","labels":["gdd","master","production","tdd"],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-stage3-localization","title":"Persistent RU/EN локализация","description":"Локализовать player-facing Canvas UI, названия и hints уровней на русский и английский с переключателем языка.","acceptance_criteria":"Все player-facing строки имеют RU/EN варианты; выбранный язык сохраняется после Activity recreation; смена языка не сбрасывает progress или control settings.","spec_id":"docs/CURRENT_ROADMAP.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T13:09:55Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T16:26:42Z","started_at":"2026-08-18T13:09:56Z","closed_at":"2026-08-18T16:26:42Z","close_reason":"Stage 3 engineering accepted: final Android 16 unfiltered gate OK 30/30 in 1409.366 s; UI contracts also GREEN on 20:9, 16:9 and 16:10.","labels":["localization","stage3","ui"],"dependencies":[{"issue_id":"goon-game-stage3-localization","depends_on_id":"goon-game-menus-localization","type":"parent-child","created_at":"2026-08-18T16:09:56Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-stage3-onboarding-results","title":"Onboarding и полноценный result flow","description":"Добавить короткое пропускаемое обучение без блокирующих длинных окон и result screen с текущими/лучшими временем и жертвами, replay/next/levels.","acceptance_criteria":"Первый запуск объясняет движение, прыжок и полезную смерть; обучение можно пропустить и оно не повторяется после завершения; result показывает current/best и все переходы работают без сброса прогресса.","spec_id":"docs/CURRENT_ROADMAP.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-18T13:09:54Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T16:26:41Z","started_at":"2026-08-18T13:09:55Z","closed_at":"2026-08-18T16:26:41Z","close_reason":"Stage 3 engineering accepted: final Android 16 unfiltered gate OK 30/30 in 1409.366 s; UI contracts also GREEN on 20:9, 16:9 and 16:10.","labels":["onboarding","stage3","ui"],"dependencies":[{"issue_id":"goon-game-stage3-onboarding-results","depends_on_id":"goon-game-menus-localization","type":"parent-child","created_at":"2026-08-18T16:09:54Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-level-route-risks","title":"Доказать спорные маршруты уровней 2, 4 и 10","description":"Проверить возможный sequence break уровня 2, узкий lift→gallery прыжок уровня 4 и optional FREEZER/body delivery/lift→ledge риски уровня 10.","acceptance_criteria":"Для каждого риска есть воспроизводимый connected bot test; intended route проходит 3 раза, а допустимые альтернативы явно зафиксированы или геометрия исправлена.","notes":"2026-08-18: exploratory production-input direct-jump attempt on level 2 did not confirm the suspected zero-death sequence break; it timed out at x=549.36 with deaths=2/corpses=2. Intended three-corpse route separately passes 3 times. Sequence-break risk remains unproven and open.\n2026-08-18: L4 lift route remains green 3x. L10 bot now explicitly stands on and jumps from the frozen corpse, then completes normal 3x plus delayed traversal. L2 intended three-corpse route remains green 3x; the unconfirmed zero-death hypothesis is accepted as a non-blocking emergent alternative because targetDeaths measures the intended route rather than enforced uniqueness.","spec_id":"docs/LEVEL_SOLUTION_GRAPHS.md","status":"closed","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T22:09:58Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T10:41:48Z","closed_at":"2026-08-18T10:41:48Z","close_reason":"All intended risky routes are reproduced by connected bots; possible L2 alternative is explicitly accepted and documented as non-blocking.","labels":["balance","bot","current","levels"],"dependencies":[{"issue_id":"goon-game-level-route-risks","depends_on_id":"goon-game-e07","type":"parent-child","created_at":"2026-08-18T01:10:06Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-mobile-qa","title":"Mobile QA matrix","description":"GPU, API, RAM, refresh rate, thermal, background, battery и controller проверки.","acceptance_criteria":"Матрица из раздела 49 заполнена и приложена к release evidence.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:40:56Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:56Z","labels":["android","qa"],"dependencies":[{"issue_id":"goon-game-mobile-qa","depends_on_id":"goon-game-device-matrix","type":"blocks","created_at":"2026-08-18T00:41:39Z","created_by":"E.Gavrilov","metadata":"{}"},{"issue_id":"goon-game-mobile-qa","depends_on_id":"goon-game-e15","type":"parent-child","created_at":"2026-08-18T00:43:53Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-crash-reporting","title":"Crash/ANR reporting без персональных данных","description":"Подключить отчёты ошибок с build/level context и политикой приватности.","acceptance_criteria":"Тестовая ошибка видна в системе; payload не содержит пользовательских секретов.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:38Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:38Z","labels":["analytics","android"],"dependencies":[{"issue_id":"goon-game-crash-reporting","depends_on_id":"goon-game-e14","type":"parent-child","created_at":"2026-08-18T00:43:43Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
@@ -37,8 +39,8 @@
|
||||
{"_type":"issue","id":"goon-game-audio-vslice","title":"Audio pass vertical slice","description":"Добавить UI, movement, mechanisms, deaths, voice variations и музыку без немедленных повторов.","acceptance_criteria":"Все 10 уровней имеют настроенный звук и отдельные volume controls.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":2400,"created_at":"2026-08-17T21:40:15Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:15Z","labels":["audio","vertical-slice"],"dependencies":[{"issue_id":"goon-game-audio-vslice","depends_on_id":"goon-game-e10","type":"parent-child","created_at":"2026-08-18T00:43:30Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-save-versioning","title":"Версионирование и миграции сохранений","description":"Добавить saveVersion, миграции и тест обновления поверх предыдущего APK.","acceptance_criteria":"Старое сохранение мигрирует без потери завершённых уровней.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:11Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:11Z","labels":["save","vertical-slice"],"dependencies":[{"issue_id":"goon-game-save-versioning","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:27Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-save-basic","title":"Базовое сохранение прогресса","description":"Сохраняются unlock, best time и минимальное число жертв.","acceptance_criteria":"Прогресс переживает перезапуск приложения.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:09Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:41:22Z","closed_at":"2026-08-17T21:41:22Z","close_reason":"Локальный прогресс и лучшие результаты сохраняются.","labels":["baseline","closed"],"dependencies":[{"issue_id":"goon-game-save-basic","depends_on_id":"goon-game-e09","type":"parent-child","created_at":"2026-08-18T00:43:26Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-accessibility","title":"Accessibility pass","description":"Проверить контраст, масштаб текста, safe areas, режим без вибрации и не-звуковые сигналы.","acceptance_criteria":"Критическая информация доступна визуально, элементы не перекрываются.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"task","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:06Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:06Z","labels":["accessibility","ui"],"dependencies":[{"issue_id":"goon-game-accessibility","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:25Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-menus-localization","title":"Меню, результаты и RU/EN локализация","description":"Добавить main/world/level select, settings, statistics, credits и полноценный result screen.","acceptance_criteria":"Все строки локализованы; игра проходится без объяснений разработчика.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:40:04Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:40:04Z","labels":["localization","ui","vertical-slice"],"dependencies":[{"issue_id":"goon-game-menus-localization","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:24Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-accessibility","title":"Accessibility pass","description":"Проверить контраст, масштаб текста, safe areas, режим без вибрации и не-звуковые сигналы.","acceptance_criteria":"Критическая информация доступна визуально, элементы не перекрываются.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"task","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:06Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T16:26:41Z","started_at":"2026-08-18T13:09:20Z","closed_at":"2026-08-18T16:26:41Z","close_reason":"Stage 3 engineering accepted: final Android 16 unfiltered gate OK 30/30 in 1409.366 s; UI contracts also GREEN on 20:9, 16:9 and 16:10.","labels":["accessibility","ui"],"dependencies":[{"issue_id":"goon-game-accessibility","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:25Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-menus-localization","title":"Меню, результаты и RU/EN локализация","description":"Добавить main/world/level select, settings, statistics, credits и полноценный result screen.","acceptance_criteria":"Все строки локализованы; игра проходится без объяснений разработчика.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":1920,"created_at":"2026-08-17T21:40:04Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T16:26:43Z","closed_at":"2026-08-18T16:26:43Z","close_reason":"Persistent RU/EN, onboarding and full result flow integrated and verified by final 30/30 gate. External human comprehension playtest remains tracked separately.","labels":["localization","ui","vertical-slice"],"dependencies":[{"issue_id":"goon-game-menus-localization","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:24Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-controls-settings","title":"Настройки размера, opacity и вибрации controls","description":"Дать игроку настройку touch controls и исключить случайные повторные прыжки.","acceptance_criteria":"Настройки сохраняются и проверены на 16:9, 20:9 и планшете.","notes":"2026-08-18 Stage 3 controls slice complete. RED/GREEN unit contracts cover size/opacity/haptics presets. Production Settings screen changes values through real taps; SharedPreferences survives Activity recreation. Large preset expands real hit target, held jump does not retrigger after landing. Device GREEN: 20:9 class 3/3, logical 16:9 persistence 1/1, tablet-sized 1/1; lifecycle/restart/L5/L10 regressions 4/4; clean 20 JVM tests, lint and app/test APK build.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","assignee":"E.Gavrilov","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:40:02Z","created_by":"E.Gavrilov","updated_at":"2026-08-18T11:43:04Z","started_at":"2026-08-18T11:28:25Z","closed_at":"2026-08-18T11:43:04Z","close_reason":"Acceptance met: size, opacity and vibration settings persist; real touch targets and held-jump edge are tested; UI contract passes 16:9, 20:9 and tablet-sized displays.","labels":["current","ui","vertical-slice"],"dependencies":[{"issue_id":"goon-game-controls-settings","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:23Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":1,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-ui-vslice","title":"Базовый HUD и multitouch controls","description":"В APK есть направления, jump, restart, pause, deaths и timer.","acceptance_criteria":"Управление работает одновременно двумя касаниями.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"closed","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","created_at":"2026-08-17T21:40:00Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:41:21Z","closed_at":"2026-08-17T21:41:21Z","close_reason":"Базовый HUD и multitouch controls реализованы.","labels":["baseline","closed"],"dependencies":[{"issue_id":"goon-game-ui-vslice","depends_on_id":"goon-game-e08","type":"parent-child","created_at":"2026-08-18T00:43:22Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"goon-game-camera-polish","title":"Доработать production camera controller","description":"Добавить vertical damping, room transitions, focus target и управляемый shake.","acceptance_criteria":"Камера не скрывает обязательные объекты и не вызывает дискомфорт.","spec_id":"docs/DEVELOPMENT_PLAN.md","status":"open","priority":1,"issue_type":"feature","owner":"gavrilovdev@yandex-team.ru","estimated_minutes":960,"created_at":"2026-08-17T21:39:50Z","created_by":"E.Gavrilov","updated_at":"2026-08-17T21:39:50Z","labels":["camera","vertical-slice"],"dependencies":[{"issue_id":"goon-game-camera-polish","depends_on_id":"goon-game-e06","type":"parent-child","created_at":"2026-08-18T00:43:16Z","created_by":"E.Gavrilov","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
||||
@@ -33,7 +33,13 @@ Death does not reset the level. A new volunteer respawns after a short delay whi
|
||||
- `Restart` restores the current level.
|
||||
- `II` pauses the game and exposes level selection.
|
||||
- `Settings` on the level-select screen cycles three control sizes, three
|
||||
opacity presets, and vibration on/off. Changes persist across app restarts.
|
||||
opacity presets, vibration on/off, Standard/Large text, high contrast, and
|
||||
English/Russian. Changes persist across app restarts without resetting progress.
|
||||
|
||||
The first level includes a three-step, non-blocking tutorial for movement,
|
||||
multitouch jumping, and using retained bodies. Completion shows the current run,
|
||||
saved best, targets, a next goal, and Level select/Replay/Next level routes. Critical
|
||||
signal, door, and respawn state remains visible when vibration is disabled.
|
||||
|
||||
The movement constants include 130 ms coyote time and 140 ms input buffering.
|
||||
|
||||
@@ -59,6 +65,10 @@ matrix with:
|
||||
./gradlew connectedDebugAndroidTest
|
||||
```
|
||||
|
||||
The Stage 3 device suite additionally checks persisted controls, RU/EN copy,
|
||||
accessibility presets, tutorial/result navigation, Back behavior, and safe viewports
|
||||
on 20:9, 16:9, and 16:10 tablet-sized displays.
|
||||
|
||||
The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`. It is signed with Android's standard debug key and can be installed with:
|
||||
|
||||
```sh
|
||||
|
||||
+2
-2
@@ -10,8 +10,8 @@ android {
|
||||
applicationId 'com.projectsacrifice.game'
|
||||
minSdk 23
|
||||
targetSdk 34
|
||||
versionCode 6
|
||||
versionName '0.5.0-stage3-controls'
|
||||
versionCode 7
|
||||
versionName '0.6.0-stage3-complete'
|
||||
|
||||
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.os.SystemClock;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class AccessibilityPersistenceTest {
|
||||
@Test
|
||||
public void accessibilityAndControlChoicesSurviveActivityRecreationTogether() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
view.selectNextControlSizeForAutomation();
|
||||
view.selectNextTextSizeForAutomation();
|
||||
view.toggleHighContrastForAutomation();
|
||||
});
|
||||
|
||||
activity.recreate();
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
assertEquals("Large", view.controlSettingsForAutomation().controlSizeLabel());
|
||||
assertEquals("Large", view.accessibilitySettingsForAutomation().textSizeLabel());
|
||||
assertTrue(view.accessibilitySettingsForAutomation().highContrastEnabled());
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void descriptionsFollowScreensWhileBackAlwaysHasOneDeterministicResult() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> assertContains(
|
||||
current.gameViewForAutomation(), "Level select"));
|
||||
|
||||
activity.onActivity(current -> current.gameViewForAutomation()
|
||||
.openSettingsForAutomation());
|
||||
activity.onActivity(current -> {
|
||||
assertContains(current.gameViewForAutomation(), "Settings");
|
||||
assertContains(current.gameViewForAutomation(), "Text size");
|
||||
current.onBackPressed();
|
||||
assertContains(current.gameViewForAutomation(), "Level select");
|
||||
});
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.startLevelForAutomation(1);
|
||||
assertContains(view, "Playing level 1");
|
||||
view.pauseForAutomation();
|
||||
assertContains(view, "Paused on level 1");
|
||||
current.onBackPressed();
|
||||
assertContains(view, "Playing level 1");
|
||||
assertFalse(view.automationSnapshot().visiblyPaused());
|
||||
view.showCompleteForAutomation();
|
||||
assertContains(view, "Level complete");
|
||||
current.onBackPressed();
|
||||
assertContains(view, "Level select");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void textAndContrastPresetsDoNotMoveContentOutsideTheSafeViewport() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
float[] before = view.safeViewportForAutomation();
|
||||
view.selectNextTextSizeForAutomation();
|
||||
view.toggleHighContrastForAutomation();
|
||||
float[] after = view.safeViewportForAutomation();
|
||||
|
||||
assertTrue(before[0] >= 0f);
|
||||
assertTrue(before[1] >= 0f);
|
||||
assertTrue(before[2] <= view.getWidth());
|
||||
assertTrue(before[3] <= view.getHeight());
|
||||
assertEquals(before[0], after[0], 0f);
|
||||
assertEquals(before[1], after[1], 0f);
|
||||
assertEquals(before[2], after[2], 0f);
|
||||
assertEquals(before[3], after[3], 0f);
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void accessibilityRowsChangeThePersistedSettingsThroughRealTouchInput() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.toggleHapticsForAutomation();
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
view.openSettingsForAutomation();
|
||||
});
|
||||
|
||||
tap(activity, 220f, 345f);
|
||||
tap(activity, 220f, 515f);
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
assertEquals(1.25f, view.textScaleForAutomation(), 0f);
|
||||
assertTrue(view.highContrastForAutomation());
|
||||
assertTrue(view.screenDescriptionForAutomation().contains("Text size Large"));
|
||||
assertTrue(view.screenDescriptionForAutomation().contains("High contrast On"));
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void criticalSignalAndDoorStateRemainsTextualWhenVibrationIsOff() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.toggleHapticsForAutomation();
|
||||
view.startLevelForAutomation(3);
|
||||
|
||||
assertEquals("Signals 0/1 · Doors 0/1 · Volunteer active",
|
||||
view.criticalVisibleStatusForAutomation());
|
||||
assertTrue(view.screenDescriptionForAutomation().contains(
|
||||
view.criticalVisibleStatusForAutomation()));
|
||||
view.resetControlSettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void tap(ActivityScenario<MainActivity> activity, float canvasX, float canvasY) {
|
||||
touch(activity, MotionEvent.ACTION_DOWN, canvasX, canvasY);
|
||||
touch(activity, MotionEvent.ACTION_UP, canvasX, canvasY);
|
||||
}
|
||||
|
||||
private static void touch(ActivityScenario<MainActivity> activity, int action,
|
||||
float canvasX, float canvasY) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f);
|
||||
float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale;
|
||||
float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale;
|
||||
long now = SystemClock.uptimeMillis();
|
||||
MotionEvent event = MotionEvent.obtain(now, now, action, x, y, 0);
|
||||
view.dispatchTouchEvent(event);
|
||||
event.recycle();
|
||||
});
|
||||
}
|
||||
|
||||
private static void assertContains(GameView view, String expected) {
|
||||
String description = String.valueOf(view.getContentDescription());
|
||||
assertTrue("Expected '" + expected + "' in '" + description + "'",
|
||||
description.contains(expected));
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -22,13 +22,13 @@ public final class ControlSettingsPersistenceTest {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(current -> current.gameViewForAutomation()
|
||||
.resetControlSettingsForAutomation());
|
||||
|
||||
tap(activity, 1_700f, 115f);
|
||||
activity.onActivity(current -> current.gameViewForAutomation()
|
||||
.openSettingsForAutomation());
|
||||
activity.onActivity(current -> assertTrue(current.gameViewForAutomation()
|
||||
.settingsVisibleForAutomation()));
|
||||
tap(activity, 1_250f, 685f);
|
||||
tap(activity, 1_250f, 345f);
|
||||
tap(activity, 1_250f, 515f);
|
||||
tap(activity, 1_250f, 685f);
|
||||
|
||||
activity.recreate();
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.os.SystemClock;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class GameLanguagePersistenceTest {
|
||||
@Test
|
||||
public void settingsLanguageSwitchSurvivesRecreationWithoutChangingProgressOrControls() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
int[] expectedUnlocked = new int[1];
|
||||
int[] expectedBestDeaths = new int[1];
|
||||
long[] expectedBestTime = new long[1];
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
view.resetLanguageForAutomation();
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.selectNextControlSizeForAutomation();
|
||||
view.selectNextControlOpacityForAutomation();
|
||||
view.toggleHapticsForAutomation();
|
||||
|
||||
ProgressStore progress = new ProgressStore(current);
|
||||
progress.recordCompletion(2, 4, 22_222L);
|
||||
expectedUnlocked[0] = progress.unlockedCount();
|
||||
expectedBestDeaths[0] = progress.bestDeaths(2);
|
||||
expectedBestTime[0] = progress.bestTimeMs(2);
|
||||
});
|
||||
|
||||
tap(activity, 1_700f, 115f); // Settings from level select.
|
||||
activity.onActivity(current -> assertTrue(current.gameViewForAutomation()
|
||||
.settingsVisibleForAutomation()));
|
||||
tap(activity, 1_700f, 115f); // Language switch on Settings.
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
assertEquals(GameLanguage.RU, view.languageForAutomation());
|
||||
assertTrue(view.visibleLocalizedTextForAutomation().contains("НАСТРОЙКИ"));
|
||||
assertTrue(view.visibleLocalizedTextForAutomation().contains("Русский"));
|
||||
});
|
||||
|
||||
activity.recreate();
|
||||
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
assertEquals(GameLanguage.RU, view.languageForAutomation());
|
||||
assertFalse(view.settingsVisibleForAutomation());
|
||||
|
||||
ControlSettings settings = view.controlSettingsForAutomation();
|
||||
assertEquals("Large", settings.controlSizeLabel());
|
||||
assertEquals("High", settings.controlOpacityLabel());
|
||||
assertFalse(settings.hapticsEnabled());
|
||||
|
||||
ProgressStore progress = new ProgressStore(current);
|
||||
assertEquals(expectedUnlocked[0], progress.unlockedCount());
|
||||
assertEquals(expectedBestDeaths[0], progress.bestDeaths(2));
|
||||
assertEquals(expectedBestTime[0], progress.bestTimeMs(2));
|
||||
|
||||
view.setLanguageForAutomation(GameLanguage.EN);
|
||||
view.resetControlSettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void tap(ActivityScenario<MainActivity> activity, float canvasX, float canvasY) {
|
||||
activity.onActivity(current -> {
|
||||
GameView view = current.gameViewForAutomation();
|
||||
float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f);
|
||||
float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale;
|
||||
float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale;
|
||||
long now = SystemClock.uptimeMillis();
|
||||
MotionEvent down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0);
|
||||
view.dispatchTouchEvent(down);
|
||||
down.recycle();
|
||||
MotionEvent up = MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, x, y, 0);
|
||||
view.dispatchTouchEvent(up);
|
||||
up.recycle();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package com.projectsacrifice.game;
|
||||
|
||||
import android.app.Instrumentation;
|
||||
|
||||
import androidx.lifecycle.Lifecycle;
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
@@ -21,12 +20,16 @@ public final class GameLifecycleTest {
|
||||
public void returningFromBackgroundKeepsPuzzleVisiblePausedAndClearsHeldInput() {
|
||||
Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
|
||||
try (ActivityScenario<MainActivity> scenario = ActivityScenario.launch(MainActivity.class)) {
|
||||
final MainActivity[] activity = new MainActivity[1];
|
||||
final GameView[] view = new GameView[1];
|
||||
scenario.onActivity(activity -> view[0] = activity.gameViewForAutomation());
|
||||
scenario.onActivity(current -> {
|
||||
activity[0] = current;
|
||||
view[0] = current.gameViewForAutomation();
|
||||
});
|
||||
createRetainedCorpse(instrumentation, view[0]);
|
||||
|
||||
runOnGame(instrumentation, view[0], false, false, true, false);
|
||||
scenario.moveToState(Lifecycle.State.STARTED);
|
||||
instrumentation.runOnMainSync(activity[0]::onPause);
|
||||
GameView.AutomationSnapshot backgroundStart = snapshot(instrumentation, view[0]);
|
||||
sleep(700L);
|
||||
GameView.AutomationSnapshot backgroundEnd = snapshot(instrumentation, view[0]);
|
||||
@@ -37,7 +40,7 @@ public final class GameLifecycleTest {
|
||||
assertEquals(backgroundStart.playerX(), backgroundEnd.playerX(), 0.01f);
|
||||
assertEquals(backgroundStart.elapsedMilliseconds(), backgroundEnd.elapsedMilliseconds());
|
||||
|
||||
scenario.moveToState(Lifecycle.State.RESUMED);
|
||||
instrumentation.runOnMainSync(activity[0]::onResume);
|
||||
GameView.AutomationSnapshot returned = snapshot(instrumentation, view[0]);
|
||||
assertTrue("Returning to an active puzzle must show the pause screen", returned.visiblyPaused());
|
||||
assertFalse("Held touch input must not survive backgrounding", returned.inputActive());
|
||||
@@ -49,13 +52,31 @@ public final class GameLifecycleTest {
|
||||
GameView.AutomationSnapshot resumed = snapshot(instrumentation, view[0]);
|
||||
assertFalse("Back from the visible pause screen must resume the puzzle", resumed.visiblyPaused());
|
||||
runOnGame(instrumentation, view[0], false, false, true, false);
|
||||
sleep(250L);
|
||||
GameView.AutomationSnapshot moving = waitForMovement(
|
||||
instrumentation, view[0], resumed.playerX(), 10_000L);
|
||||
runOnGame(instrumentation, view[0], false, false, false, false);
|
||||
GameView.AutomationSnapshot moving = snapshot(instrumentation, view[0]);
|
||||
assertTrue("The resumed simulation must accept movement", moving.playerX() > resumed.playerX() + 20f);
|
||||
}
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot waitForMovement(
|
||||
Instrumentation instrumentation, GameView gameView, float startX, long timeoutMillis) {
|
||||
long deadline = System.currentTimeMillis() + timeoutMillis;
|
||||
GameView.AutomationSnapshot current = snapshot(instrumentation, gameView);
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
if (current.playerX() > startX + 20f) return current;
|
||||
sleep(24L);
|
||||
current = snapshot(instrumentation, gameView);
|
||||
}
|
||||
throw new AssertionError(
|
||||
"The resumed simulation did not move within " + timeoutMillis
|
||||
+ "ms; startX=" + startX
|
||||
+ ", finalX=" + current.playerX()
|
||||
+ ", alive=" + current.playerAlive()
|
||||
+ ", paused=" + current.visiblyPaused()
|
||||
+ ", inputActive=" + current.inputActive());
|
||||
}
|
||||
|
||||
private static void createRetainedCorpse(Instrumentation instrumentation, GameView gameView) {
|
||||
runOnGame(instrumentation, gameView, true, false, true, false);
|
||||
long deadline = System.currentTimeMillis() + 8_000L;
|
||||
|
||||
@@ -17,13 +17,17 @@ final class LateLevelScenarios {
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 640f)
|
||||
.step("Stand on the frozen block", 2_500L, LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerX() >= 626f
|
||||
snapshot -> snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 626f
|
||||
&& snapshot.playerY() >= 668f
|
||||
&& snapshot.playerY() <= 690f)
|
||||
.step("Jump from the block to the first ledge", 2_500L,
|
||||
.step("Land on the first ledge from the frozen block", 3_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 750f
|
||||
&& snapshot.playerY() <= 505f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 810f
|
||||
&& snapshot.playerY() <= 500f))
|
||||
.step("Cross the first ledge", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 1_230f)
|
||||
.step("Jump the gap to the second ledge", 2_500L,
|
||||
@@ -117,18 +121,29 @@ final class LateLevelScenarios {
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1)
|
||||
.step("Observe the delivered body power the lift", 3_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.deaths() == 1
|
||||
&& snapshot.corpseCount() == 1
|
||||
&& snapshot.firstMovingPlatformPowered())
|
||||
.step("Wait for the freezer volunteer", 3_000L,
|
||||
LevelPlaytestBot.Input.none(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the launch saw", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 350f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(320f)))
|
||||
.step("Take off before the launch saw", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 580f))
|
||||
.step("Clear the launch saw", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(1,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 680f))
|
||||
.step("Approach the delivered lift body", 1_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 730f)
|
||||
@@ -143,13 +158,19 @@ final class LateLevelScenarios {
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Return to the launch saw", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 350f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(320f)))
|
||||
.step("Take off before the saw again", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 580f))
|
||||
.step("Clear the saw again", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(2,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 680f))
|
||||
.step("Approach the lift body again", 1_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 730f)
|
||||
@@ -190,13 +211,19 @@ final class LateLevelScenarios {
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the saw for the final crossing", 2_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 350f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(3,
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(320f)))
|
||||
.step("Take off for the final saw crossing", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 680f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(3,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 580f))
|
||||
.step("Clear the saw for the final crossing", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(3,
|
||||
snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerX() >= 680f))
|
||||
.step("Approach the retained lift body", 1_500L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 730f)
|
||||
@@ -251,7 +278,8 @@ final class LateLevelScenarios {
|
||||
&& (snapshot.playerOnMovingPlatform()
|
||||
|| (snapshot.playerY() >= 770f
|
||||
&& !Float.isNaN(snapshot.firstMovingPlatformY())
|
||||
&& snapshot.firstMovingPlatformY() >= 780f)))
|
||||
&& snapshot.firstMovingPlatformY() >= 750f
|
||||
&& snapshot.firstMovingPlatformMovingDown())))
|
||||
.step("Take off toward the moving lift", 1_000L,
|
||||
LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive()
|
||||
@@ -259,7 +287,8 @@ final class LateLevelScenarios {
|
||||
|| snapshot.playerY() <= 670f))
|
||||
.step("Board the moving lift", 2_000L,
|
||||
LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::playerOnMovingPlatform)
|
||||
LevelPlaytestBot.whileDeathsAtMost(3,
|
||||
GameView.AutomationSnapshot::playerOnMovingPlatform))
|
||||
.step("Brake toward the lift center", 1_000L,
|
||||
LevelPlaytestBot.Input.left(),
|
||||
snapshot -> snapshot.playerOnMovingPlatform()
|
||||
|
||||
@@ -3,6 +3,9 @@ package com.projectsacrifice.game;
|
||||
/** Black-box level-8 scenarios driven only through the production input boundary. */
|
||||
final class LevelEightScenario {
|
||||
private static final long DECISION_MILLIS = 32L;
|
||||
private static final float LAUNCH_PLATFORM_X = 1_150f;
|
||||
|
||||
private String lastStep = "Start level 8";
|
||||
|
||||
static final class Result {
|
||||
private final boolean completed;
|
||||
@@ -21,145 +24,215 @@ final class LevelEightScenario {
|
||||
}
|
||||
|
||||
Result recoverAfterClockExpires(LevelPlaytestBot.Driver driver, long timeoutMillis) {
|
||||
long deadline = driver.currentTimeMillis() + timeoutMillis;
|
||||
driver.startLevel(8);
|
||||
GameView.AutomationSnapshot snapshot = crossPit(driver, deadline);
|
||||
if (failed(snapshot, deadline, driver)) {
|
||||
return failure("Cross the pit before the clock expires", snapshot);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
PlaytestSimulationBudget budget = PlaytestSimulationBudget.start(
|
||||
snapshot.elapsedMilliseconds(), driver.currentTimeMillis(), timeoutMillis);
|
||||
snapshot = crossPit(driver, budget);
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Cross the pit before the clock expires", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
snapshot = await(driver, deadline, "Wait for the far-side trap",
|
||||
snapshot = await(driver, budget, "Move clear of the platform sweep",
|
||||
LevelPlaytestBot.Input.right(),
|
||||
current -> current.playerX() >= 1_680f
|
||||
&& current.playerY() >= 779f && current.allDoorsOpen());
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Move clear of the platform sweep", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
snapshot = await(driver, budget, "Wait for the far-side trap",
|
||||
LevelPlaytestBot.Input.none(),
|
||||
current -> current.playerX() >= 1_510f && !current.allDoorsOpen());
|
||||
if (failed(snapshot, deadline, driver)) {
|
||||
return failure("Wait for the far-side trap", snapshot);
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Wait for the far-side trap", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
snapshot = await(driver, deadline, "Return beneath the recovery relay",
|
||||
LevelPlaytestBot.Input.left(),
|
||||
current -> current.playerX() <= 1_680f && current.playerY() >= 779f);
|
||||
if (failed(snapshot, deadline, driver)) {
|
||||
return failure("Return beneath the recovery relay", snapshot);
|
||||
snapshot = positionBeneathRecoveryRelay(driver, budget);
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Return beneath the recovery relay", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
snapshot = pulseLeftJumpThenAwait(driver, deadline, "Jump into the recovery relay",
|
||||
snapshot = pulseRightJumpThenAwait(driver, budget, "Jump into the recovery relay",
|
||||
current -> current.allDoorsOpen());
|
||||
if (failed(snapshot, deadline, driver)) {
|
||||
return failure("Jump into the recovery relay", snapshot);
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Jump into the recovery relay", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
snapshot = await(driver, deadline, "Land after rearming the clock",
|
||||
LevelPlaytestBot.Input.none(), current -> current.playerY() >= 779f);
|
||||
if (failed(snapshot, deadline, driver)) {
|
||||
return failure("Land after rearming the clock", snapshot);
|
||||
snapshot = await(driver, budget, "Land after rearming the clock",
|
||||
LevelPlaytestBot.Input.right(),
|
||||
current -> current.playerX() >= 1_650f
|
||||
&& current.playerY() >= 779f && current.allDoorsOpen());
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Land after rearming the clock", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
return finishFromFarSide(driver, deadline, snapshot, "Finish after recovery");
|
||||
return finishFromFarSide(driver, budget, snapshot, "Finish after recovery");
|
||||
}
|
||||
|
||||
Result completeIntendedRoute(LevelPlaytestBot.Driver driver, long timeoutMillis) {
|
||||
long deadline = driver.currentTimeMillis() + timeoutMillis;
|
||||
driver.startLevel(8);
|
||||
GameView.AutomationSnapshot snapshot = crossPit(driver, deadline);
|
||||
if (failed(snapshot, deadline, driver)) {
|
||||
return failure("Cross the pit before the clock expires", snapshot);
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
PlaytestSimulationBudget budget = PlaytestSimulationBudget.start(
|
||||
snapshot.elapsedMilliseconds(), driver.currentTimeMillis(), timeoutMillis);
|
||||
snapshot = crossPit(driver, budget);
|
||||
if (failed(snapshot, budget, driver)) {
|
||||
return failure("Cross the pit before the clock expires", snapshot, budget, driver);
|
||||
}
|
||||
return finishFromFarSide(driver, deadline, snapshot, "Finish on the original timer");
|
||||
return finishFromFarSide(driver, budget, snapshot, "Finish on the original timer");
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot crossPit(LevelPlaytestBot.Driver driver, long deadline) {
|
||||
GameView.AutomationSnapshot snapshot = await(driver, deadline, "Trigger the left timer",
|
||||
private GameView.AutomationSnapshot crossPit(LevelPlaytestBot.Driver driver,
|
||||
PlaytestSimulationBudget budget) {
|
||||
GameView.AutomationSnapshot snapshot = await(driver, budget, "Trigger the left timer",
|
||||
LevelPlaytestBot.Input.right(), GameView.AutomationSnapshot::allDoorsOpen);
|
||||
if (failed(snapshot, deadline, driver)) return snapshot;
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
long timerStartedAt = snapshot.elapsedMilliseconds();
|
||||
|
||||
snapshot = await(driver, deadline, "Approach the pit",
|
||||
snapshot = await(driver, budget, "Approach the pit",
|
||||
LevelPlaytestBot.Input.right(), current -> current.playerX() >= 760f);
|
||||
if (failed(snapshot, deadline, driver)) return snapshot;
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
|
||||
snapshot = await(driver, deadline, "Wait for the moving platform",
|
||||
snapshot = await(driver, budget, "Wait for the outbound moving platform",
|
||||
LevelPlaytestBot.Input.none(),
|
||||
current -> current.elapsedMilliseconds() >= timerStartedAt + 2_250L
|
||||
&& current.firstMovingPlatformX() <= 1_050f);
|
||||
if (failed(snapshot, deadline, driver)) return snapshot;
|
||||
&& MovingPlatformLaunchPolicy.readyToBoard(current, 1_050f));
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(64L);
|
||||
driver.setInput(false, true, false);
|
||||
snapshot = await(driver, deadline, "Land on the moving platform",
|
||||
snapshot = await(driver, budget, "Land on the moving platform",
|
||||
LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::playerOnMovingPlatform);
|
||||
if (failed(snapshot, deadline, driver)) return snapshot;
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
|
||||
snapshot = await(driver, budget, "Ride the moving platform to the launch zone",
|
||||
LevelPlaytestBot.Input.none(),
|
||||
current -> MovingPlatformLaunchPolicy.ready(current, LAUNCH_PLATFORM_X));
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(64L);
|
||||
driver.setInput(false, true, false);
|
||||
snapshot = await(driver, deadline, "Jump from the moving platform to the far floor",
|
||||
snapshot = await(driver, budget, "Jump from the moving platform to the far floor",
|
||||
LevelPlaytestBot.Input.right(), current -> current.playerX() >= 1_510f);
|
||||
if (failed(snapshot, deadline, driver)) return snapshot;
|
||||
snapshot = awaitFarFloorLandingOnOriginalTimer(driver, deadline);
|
||||
if (failed(snapshot, deadline, driver)) return snapshot;
|
||||
return await(driver, deadline, "Move under the relay after landing",
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
snapshot = awaitFarFloorLandingOnOriginalTimer(driver, budget);
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
return await(driver, budget, "Move under the relay after landing",
|
||||
LevelPlaytestBot.Input.right(),
|
||||
current -> current.playerX() >= 1_600f
|
||||
&& current.playerY() >= 779f && current.allDoorsOpen());
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot awaitFarFloorLandingOnOriginalTimer(
|
||||
LevelPlaytestBot.Driver driver, long deadline
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget
|
||||
) {
|
||||
GameView.AutomationSnapshot snapshot = await(driver, deadline,
|
||||
GameView.AutomationSnapshot snapshot = await(driver, budget,
|
||||
"Land on the far floor", LevelPlaytestBot.Input.right(),
|
||||
current -> current.playerX() >= 1_510f
|
||||
&& current.playerY() >= 779f && current.allDoorsOpen());
|
||||
if (failed(snapshot, deadline, driver)) return snapshot;
|
||||
return await(driver, deadline, "Stabilize on the far floor",
|
||||
if (failed(snapshot, budget, driver)) return snapshot;
|
||||
return await(driver, budget, "Stabilize on the far floor",
|
||||
LevelPlaytestBot.Input.left(),
|
||||
current -> current.playerX() < 1_580f
|
||||
&& current.playerY() >= 779f && current.allDoorsOpen());
|
||||
}
|
||||
|
||||
private Result finishFromFarSide(LevelPlaytestBot.Driver driver, long deadline,
|
||||
private Result finishFromFarSide(LevelPlaytestBot.Driver driver,
|
||||
PlaytestSimulationBudget budget,
|
||||
GameView.AutomationSnapshot snapshot, String step) {
|
||||
snapshot = await(driver, deadline, "Reach the open-door jump",
|
||||
LevelPlaytestBot.Input.right(),
|
||||
current -> current.playerX() >= 1_730f && current.allDoorsOpen());
|
||||
if (failed(snapshot, deadline, driver)) {
|
||||
return failure("Reach the open-door jump", snapshot);
|
||||
snapshot = positionForFinalJump(driver, budget);
|
||||
if (failed(snapshot, budget, driver) || !snapshot.allDoorsOpen()) {
|
||||
return failure("Reach the open-door jump", snapshot, budget, driver);
|
||||
}
|
||||
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(64L);
|
||||
driver.setInput(false, true, false);
|
||||
snapshot = await(driver, deadline, step, LevelPlaytestBot.Input.right(),
|
||||
snapshot = await(driver, budget, step, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed);
|
||||
driver.setInput(false, false, false);
|
||||
if (!snapshot.completed()) return failure(step, snapshot);
|
||||
return new Result(true, snapshot.deaths(), step + " completed; " + state(snapshot));
|
||||
if (!snapshot.completed()) return failure(step, snapshot, budget, driver);
|
||||
return new Result(true, snapshot.deaths(), step + " completed; "
|
||||
+ budget.timingState(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
+ "; " + state(snapshot));
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot pulseLeftJumpThenAwait(
|
||||
LevelPlaytestBot.Driver driver, long deadline, String step,
|
||||
private GameView.AutomationSnapshot positionForFinalJump(
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget
|
||||
) {
|
||||
lastStep = "Reach the open-door jump";
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
&& snapshot.playerAlive() && !snapshot.completed() && snapshot.allDoorsOpen()) {
|
||||
if (snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 1_690f && snapshot.playerX() <= 1_730f) {
|
||||
driver.setInput(false, false, false);
|
||||
return snapshot;
|
||||
}
|
||||
if (snapshot.playerX() > 1_730f) {
|
||||
driver.setInput(true, false, false);
|
||||
} else {
|
||||
driver.setInput(false, true, false);
|
||||
}
|
||||
driver.waitForNextDecision(DECISION_MILLIS);
|
||||
snapshot = driver.snapshot();
|
||||
}
|
||||
driver.setInput(false, false, false);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot pulseRightJumpThenAwait(
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step,
|
||||
LevelPlaytestBot.SnapshotCondition completion
|
||||
) {
|
||||
driver.setInput(true, false, true);
|
||||
driver.setInput(false, true, true);
|
||||
driver.waitForNextDecision(64L);
|
||||
driver.setInput(false, true, false);
|
||||
return await(driver, budget, step, LevelPlaytestBot.Input.right(), completion);
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot positionBeneathRecoveryRelay(
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget
|
||||
) {
|
||||
lastStep = "Return beneath the recovery relay";
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
&& snapshot.playerAlive() && !snapshot.completed()) {
|
||||
if (snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 1_620f && snapshot.playerX() <= 1_650f) {
|
||||
driver.setInput(false, false, false);
|
||||
return snapshot;
|
||||
}
|
||||
if (snapshot.playerX() > 1_650f) {
|
||||
driver.setInput(true, false, false);
|
||||
return await(driver, deadline, step, LevelPlaytestBot.Input.left(), completion);
|
||||
} else {
|
||||
driver.setInput(false, true, false);
|
||||
}
|
||||
driver.waitForNextDecision(DECISION_MILLIS);
|
||||
snapshot = driver.snapshot();
|
||||
}
|
||||
driver.setInput(false, false, false);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private GameView.AutomationSnapshot await(
|
||||
LevelPlaytestBot.Driver driver, long deadline, String step,
|
||||
LevelPlaytestBot.Driver driver, PlaytestSimulationBudget budget, String step,
|
||||
LevelPlaytestBot.Input input, LevelPlaytestBot.SnapshotCondition completion
|
||||
) {
|
||||
lastStep = step;
|
||||
GameView.AutomationSnapshot snapshot = driver.snapshot();
|
||||
while (driver.currentTimeMillis() < deadline && snapshot.playerAlive()
|
||||
while (!budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
&& snapshot.playerAlive()
|
||||
&& !snapshot.completed() && !completion.reached(snapshot)) {
|
||||
setInput(driver, input);
|
||||
driver.waitForNextDecision(DECISION_MILLIS);
|
||||
snapshot = driver.snapshot();
|
||||
}
|
||||
if (driver.currentTimeMillis() >= deadline || !snapshot.playerAlive()) {
|
||||
if (budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
|| !snapshot.playerAlive()) {
|
||||
driver.setInput(false, false, false);
|
||||
}
|
||||
return snapshot;
|
||||
@@ -177,14 +250,20 @@ final class LevelEightScenario {
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean failed(GameView.AutomationSnapshot snapshot, long deadline,
|
||||
private static boolean failed(GameView.AutomationSnapshot snapshot,
|
||||
PlaytestSimulationBudget budget,
|
||||
LevelPlaytestBot.Driver driver) {
|
||||
return !snapshot.completed()
|
||||
&& (!snapshot.playerAlive() || driver.currentTimeMillis() >= deadline);
|
||||
&& (!snapshot.playerAlive()
|
||||
|| budget.exhausted(snapshot.elapsedMilliseconds(), driver.currentTimeMillis()));
|
||||
}
|
||||
|
||||
private static Result failure(String step, GameView.AutomationSnapshot snapshot) {
|
||||
return new Result(false, snapshot.deaths(), step + " failed; " + state(snapshot));
|
||||
private Result failure(String step, GameView.AutomationSnapshot snapshot,
|
||||
PlaytestSimulationBudget budget, LevelPlaytestBot.Driver driver) {
|
||||
return new Result(false, snapshot.deaths(), step + " failed; lastStep=" + lastStep
|
||||
+ "; " + budget.timingState(
|
||||
snapshot.elapsedMilliseconds(), driver.currentTimeMillis())
|
||||
+ "; " + state(snapshot));
|
||||
}
|
||||
|
||||
private static String state(GameView.AutomationSnapshot snapshot) {
|
||||
@@ -194,6 +273,12 @@ final class LevelEightScenario {
|
||||
+ ", doorOpen=" + snapshot.allDoorsOpen()
|
||||
+ ", completed=" + snapshot.completed()
|
||||
+ ", elapsedMs=" + snapshot.elapsedMilliseconds()
|
||||
+ ", deaths=" + snapshot.deaths();
|
||||
+ ", deaths=" + snapshot.deaths()
|
||||
+ ", platformX=" + snapshot.firstMovingPlatformX()
|
||||
+ ", platformY=" + snapshot.firstMovingPlatformY()
|
||||
+ ", onMovingPlatform=" + snapshot.playerOnMovingPlatform()
|
||||
+ ", onGround=" + snapshot.playerOnGround()
|
||||
+ ", platformPowered=" + snapshot.firstMovingPlatformPowered()
|
||||
+ ", platformMovingRight=" + snapshot.firstMovingPlatformMovingRight();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class OnboardingAndResultFlowTest {
|
||||
@Test
|
||||
public void tutorialIsOptionalNonBlockingAndDoesNotRepeatAfterRecreation() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
view.resetOnboardingForAutomation();
|
||||
view.startLevelForAutomation(1);
|
||||
assertTrue(view.onboardingVisibleForAutomation());
|
||||
assertEquals(OnboardingFlow.Step.MOVEMENT,
|
||||
view.onboardingStepForAutomation());
|
||||
|
||||
view.setAutomationInput(false, true, false);
|
||||
assertTrue("Tutorial must not disable gameplay controls",
|
||||
view.automationSnapshot().inputActive());
|
||||
|
||||
view.advanceOnboardingForAutomation();
|
||||
view.advanceOnboardingForAutomation();
|
||||
view.advanceOnboardingForAutomation();
|
||||
assertFalse(view.onboardingVisibleForAutomation());
|
||||
});
|
||||
|
||||
activity.recreate();
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
view.startLevelForAutomation(1);
|
||||
assertFalse(view.onboardingVisibleForAutomation());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void completionShowsRunAndSavedBestAndAllResultRoutesPreserveState() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
view.selectNextControlSizeForAutomation();
|
||||
String selectedSize = view.controlSettingsForAutomation().controlSizeLabel();
|
||||
|
||||
view.startLevelForAutomation(1);
|
||||
view.finishLevelForAutomation(2, 42_000L);
|
||||
CompletionResult result = view.completionResultForAutomation();
|
||||
assertNotNull(result);
|
||||
assertTrue(view.resultVisibleForAutomation());
|
||||
assertEquals(2, result.currentDeaths());
|
||||
assertEquals(42_000L, result.currentTimeMs());
|
||||
assertTrue(result.bestDeaths() <= result.currentDeaths());
|
||||
assertTrue(result.bestTimeMs() <= result.currentTimeMs());
|
||||
assertTrue(view.levelCompletedForAutomation(1));
|
||||
|
||||
view.replayFromResultForAutomation();
|
||||
assertEquals(1, view.automationSnapshot().levelNumber());
|
||||
assertFalse(view.automationSnapshot().completed());
|
||||
assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel());
|
||||
assertTrue(view.levelCompletedForAutomation(1));
|
||||
|
||||
view.finishLevelForAutomation(1, 35_000L);
|
||||
view.nextLevelFromResultForAutomation();
|
||||
assertEquals(2, view.automationSnapshot().levelNumber());
|
||||
assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel());
|
||||
assertTrue(view.levelCompletedForAutomation(1));
|
||||
|
||||
view.finishLevelForAutomation(1, 35_000L);
|
||||
view.levelsFromResultForAutomation();
|
||||
assertTrue(view.levelsVisibleForAutomation());
|
||||
assertEquals(selectedSize, view.controlSettingsForAutomation().controlSizeLabel());
|
||||
assertTrue(view.levelCompletedForAutomation(2));
|
||||
view.resetControlSettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class OnboardingStoreTest {
|
||||
@Test
|
||||
public void dismissalSurvivesStoreRecreation() {
|
||||
Context context = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||
OnboardingStore first = new OnboardingStore(context);
|
||||
first.resetForAutomation();
|
||||
assertFalse(first.isComplete());
|
||||
|
||||
first.markComplete();
|
||||
|
||||
assertTrue(new OnboardingStore(context).isComplete());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.os.SystemClock;
|
||||
import android.view.MotionEvent;
|
||||
|
||||
import androidx.test.core.app.ActivityScenario;
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public final class StageThreeAcceptanceTest {
|
||||
@Test
|
||||
public void localizedAccessibleSettingsSurviveRecreationWithoutChangingProgress() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
int[] unlocked = new int[1];
|
||||
int[] bestDeaths = new int[1];
|
||||
long[] bestTime = new long[1];
|
||||
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
view.resetLanguageForAutomation();
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.toggleHapticsForAutomation();
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
ProgressStore progress = new ProgressStore(owner);
|
||||
unlocked[0] = progress.unlockedCount();
|
||||
bestDeaths[0] = progress.bestDeaths(1);
|
||||
bestTime[0] = progress.bestTimeMs(1);
|
||||
});
|
||||
|
||||
tap(activity, 1_700f, 115f); // Settings.
|
||||
tap(activity, 1_700f, 115f); // RU.
|
||||
tap(activity, 220f, 345f); // Large text.
|
||||
tap(activity, 220f, 515f); // High contrast.
|
||||
tap(activity, 1_250f, 345f); // Large controls.
|
||||
tap(activity, 1_250f, 515f); // High control opacity.
|
||||
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
assertEquals(GameLanguage.RU, view.languageForAutomation());
|
||||
assertEquals(1.25f, view.textScaleForAutomation(), 0f);
|
||||
assertTrue(view.highContrastForAutomation());
|
||||
assertTrue(view.visibleLocalizedTextForAutomation().contains("ДОСТУПНОСТЬ"));
|
||||
assertTrue(view.visibleLocalizedTextForAutomation().contains("Крупный"));
|
||||
assertTrue(view.screenDescriptionForAutomation().contains("Настройки"));
|
||||
});
|
||||
|
||||
activity.recreate();
|
||||
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
assertEquals(GameLanguage.RU, view.languageForAutomation());
|
||||
assertEquals(1.25f, view.textScaleForAutomation(), 0f);
|
||||
assertTrue(view.highContrastForAutomation());
|
||||
assertEquals("Large", view.controlSettingsForAutomation().controlSizeLabel());
|
||||
assertEquals("High", view.controlSettingsForAutomation().controlOpacityLabel());
|
||||
assertFalse(view.controlSettingsForAutomation().hapticsEnabled());
|
||||
|
||||
ProgressStore progress = new ProgressStore(owner);
|
||||
assertEquals(unlocked[0], progress.unlockedCount());
|
||||
assertEquals(bestDeaths[0], progress.bestDeaths(1));
|
||||
assertEquals(bestTime[0], progress.bestTimeMs(1));
|
||||
|
||||
view.resetLanguageForAutomation();
|
||||
view.resetControlSettingsForAutomation();
|
||||
view.resetAccessibilitySettingsForAutomation();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void russianTutorialAndResultRemainNonBlockingAndExposeEveryOutcome() {
|
||||
try (ActivityScenario<MainActivity> activity = ActivityScenario.launch(MainActivity.class)) {
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
view.setLanguageForAutomation(GameLanguage.RU);
|
||||
view.resetOnboardingForAutomation();
|
||||
view.startLevelForAutomation(1);
|
||||
|
||||
assertTrue(view.onboardingVisibleForAutomation());
|
||||
assertContainsPrefix(view.visibleLocalizedTextForAutomation(), "ДВИЖЕНИЕ");
|
||||
view.setAutomationInput(false, true, false);
|
||||
assertTrue(view.automationSnapshot().inputActive());
|
||||
|
||||
view.advanceOnboardingForAutomation();
|
||||
assertContainsPrefix(view.visibleLocalizedTextForAutomation(),
|
||||
"ДВИЖЕНИЕ + ПРЫЖОК");
|
||||
view.advanceOnboardingForAutomation();
|
||||
assertContainsPrefix(view.visibleLocalizedTextForAutomation(),
|
||||
"СМЕРТЬ ОСТАВЛЯЕТ ИНСТРУМЕНТ");
|
||||
view.advanceOnboardingForAutomation();
|
||||
assertFalse(view.onboardingVisibleForAutomation());
|
||||
|
||||
view.finishLevelForAutomation(2, 42_000L);
|
||||
List<String> result = view.visibleLocalizedTextForAutomation();
|
||||
assertContainsPrefix(result, "Текущий результат");
|
||||
assertContainsPrefix(result, "Лучший результат");
|
||||
assertContainsPrefix(result, "Цели");
|
||||
assertContainsPrefix(result, "Следующая цель");
|
||||
assertTrue(view.screenDescriptionForAutomation().contains("Уровень завершён"));
|
||||
view.setLanguageForAutomation(GameLanguage.EN);
|
||||
});
|
||||
|
||||
activity.recreate();
|
||||
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
view.startLevelForAutomation(1);
|
||||
assertFalse(view.onboardingVisibleForAutomation());
|
||||
assertEquals(GameLanguage.EN, view.languageForAutomation());
|
||||
view.setAutomationInput(false, false, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertContainsPrefix(List<String> values, String prefix) {
|
||||
for (String value : values) {
|
||||
if (value.startsWith(prefix)) return;
|
||||
}
|
||||
throw new AssertionError("Missing prefix '" + prefix + "' in " + values);
|
||||
}
|
||||
|
||||
private static void tap(ActivityScenario<MainActivity> activity, float canvasX, float canvasY) {
|
||||
activity.onActivity(owner -> {
|
||||
GameView view = owner.gameViewForAutomation();
|
||||
float scale = Math.min(view.getWidth() / 1_920f, view.getHeight() / 1_080f);
|
||||
float x = (view.getWidth() - 1_920f * scale) * 0.5f + canvasX * scale;
|
||||
float y = (view.getHeight() - 1_080f * scale) * 0.5f + canvasY * scale;
|
||||
long now = SystemClock.uptimeMillis();
|
||||
MotionEvent down = MotionEvent.obtain(now, now, MotionEvent.ACTION_DOWN, x, y, 0);
|
||||
view.dispatchTouchEvent(down);
|
||||
down.recycle();
|
||||
MotionEvent up = MotionEvent.obtain(now, now, MotionEvent.ACTION_UP, x, y, 0);
|
||||
view.dispatchTouchEvent(up);
|
||||
up.recycle();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
final class AccessibilityScreenDescription {
|
||||
private AccessibilityScreenDescription() {
|
||||
}
|
||||
|
||||
static String levelSelect(int unlocked, int total) {
|
||||
return "Level select. " + unlocked + " of " + total
|
||||
+ " unlocked. Choose an unlocked level or open Settings.";
|
||||
}
|
||||
|
||||
static String settings(ControlSettings controls, AccessibilitySettings accessibility) {
|
||||
return "Settings. Control size " + controls.controlSizeLabel()
|
||||
+ ". Control opacity " + controls.controlOpacityLabel()
|
||||
+ ". Vibration " + controls.hapticsLabel()
|
||||
+ ". Text size " + accessibility.textSizeLabel()
|
||||
+ ". High contrast " + accessibility.highContrastLabel()
|
||||
+ ". Back.";
|
||||
}
|
||||
|
||||
static String playing(LevelDefinition level, int deaths, String elapsed,
|
||||
String criticalState, boolean paused) {
|
||||
if (paused) {
|
||||
return "Paused on level " + level.number + ", " + level.title
|
||||
+ ". Resume or return to level select.";
|
||||
}
|
||||
return "Playing level " + level.number + ", " + level.title
|
||||
+ ". " + deaths + " volunteers. Time " + elapsed
|
||||
+ ". " + criticalState
|
||||
+ ". Move left, move right, jump, restart, pause.";
|
||||
}
|
||||
|
||||
static String complete(LevelDefinition level, int deaths, String elapsed,
|
||||
boolean hasNextLevel) {
|
||||
return "Level complete. " + level.title + ". " + deaths
|
||||
+ " volunteers. Time " + elapsed
|
||||
+ ". Level select. Replay."
|
||||
+ (hasNextLevel ? " Next level." : "");
|
||||
}
|
||||
|
||||
static String levelSelect(GameStrings strings, int unlocked, int total) {
|
||||
return strings.levelSelectDescription(unlocked, total);
|
||||
}
|
||||
|
||||
static String settings(GameStrings strings, ControlSettings controls,
|
||||
AccessibilitySettings accessibility) {
|
||||
return strings.settingsDescription(controls, accessibility);
|
||||
}
|
||||
|
||||
static String playing(GameStrings strings, LevelDefinition level, int deaths,
|
||||
String elapsed, String criticalState, boolean paused) {
|
||||
return strings.playingDescription(level.number, deaths, elapsed, criticalState, paused);
|
||||
}
|
||||
|
||||
static String complete(GameStrings strings, LevelDefinition level, int deaths,
|
||||
String elapsed, boolean hasNextLevel) {
|
||||
return strings.completeDescription(level.number, deaths, elapsed, hasNextLevel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
public final class AccessibilitySettings {
|
||||
private enum TextSize {
|
||||
STANDARD("Standard", 1f),
|
||||
LARGE("Large", 1.25f);
|
||||
|
||||
final String label;
|
||||
final float scale;
|
||||
|
||||
TextSize(String label, float scale) {
|
||||
this.label = label;
|
||||
this.scale = scale;
|
||||
}
|
||||
}
|
||||
|
||||
private final TextSize textSize;
|
||||
private final boolean highContrastEnabled;
|
||||
|
||||
private AccessibilitySettings(TextSize textSize, boolean highContrastEnabled) {
|
||||
this.textSize = textSize;
|
||||
this.highContrastEnabled = highContrastEnabled;
|
||||
}
|
||||
|
||||
public static AccessibilitySettings defaults() {
|
||||
return new AccessibilitySettings(TextSize.STANDARD, false);
|
||||
}
|
||||
|
||||
static AccessibilitySettings restore(int textSizeIndex, boolean highContrastEnabled) {
|
||||
TextSize[] values = TextSize.values();
|
||||
TextSize restored = textSizeIndex >= 0 && textSizeIndex < values.length
|
||||
? values[textSizeIndex] : TextSize.STANDARD;
|
||||
return new AccessibilitySettings(restored, highContrastEnabled);
|
||||
}
|
||||
|
||||
public String textSizeLabel() {
|
||||
return textSize.label;
|
||||
}
|
||||
|
||||
public float textScale() {
|
||||
return textSize.scale;
|
||||
}
|
||||
|
||||
public float canvasTextSize(float baseSize) {
|
||||
return baseSize * textSize.scale;
|
||||
}
|
||||
|
||||
public AccessibilitySettings nextTextSize() {
|
||||
TextSize[] values = TextSize.values();
|
||||
return new AccessibilitySettings(values[(textSize.ordinal() + 1) % values.length],
|
||||
highContrastEnabled);
|
||||
}
|
||||
|
||||
public boolean highContrastEnabled() {
|
||||
return highContrastEnabled;
|
||||
}
|
||||
|
||||
public String highContrastLabel() {
|
||||
return highContrastEnabled ? "On" : "Off";
|
||||
}
|
||||
|
||||
public AccessibilitySettings toggleHighContrast() {
|
||||
return new AccessibilitySettings(textSize, !highContrastEnabled);
|
||||
}
|
||||
|
||||
int textSizeIndex() {
|
||||
return textSize.ordinal();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
final class AccessibilitySettingsStore {
|
||||
private static final String FILE = "project_sacrifice_accessibility_settings_v1";
|
||||
private final SharedPreferences preferences;
|
||||
|
||||
AccessibilitySettingsStore(Context context) {
|
||||
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
AccessibilitySettings load() {
|
||||
AccessibilitySettings defaults = AccessibilitySettings.defaults();
|
||||
return AccessibilitySettings.restore(
|
||||
preferences.getInt("text_size", defaults.textSizeIndex()),
|
||||
preferences.getBoolean("high_contrast", defaults.highContrastEnabled())
|
||||
);
|
||||
}
|
||||
|
||||
void save(AccessibilitySettings settings) {
|
||||
preferences.edit()
|
||||
.putInt("text_size", settings.textSizeIndex())
|
||||
.putBoolean("high_contrast", settings.highContrastEnabled())
|
||||
.apply();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/** Allocation-free critical-state comparison after a level has been configured. */
|
||||
final class AccessibilityStateCache {
|
||||
private List<String> signalIds = Collections.emptyList();
|
||||
private GameLanguage language;
|
||||
private int activeSignals = Integer.MIN_VALUE;
|
||||
private int openDoors = Integer.MIN_VALUE;
|
||||
private int totalDoors = Integer.MIN_VALUE;
|
||||
private boolean playerAlive;
|
||||
private String statusText;
|
||||
|
||||
void configureSignals(List<String> sourceIds) {
|
||||
ArrayList<String> unique = new ArrayList<>();
|
||||
for (String signalId : sourceIds) {
|
||||
if (!unique.contains(signalId)) unique.add(signalId);
|
||||
}
|
||||
signalIds = Collections.unmodifiableList(unique);
|
||||
language = null;
|
||||
activeSignals = Integer.MIN_VALUE;
|
||||
openDoors = Integer.MIN_VALUE;
|
||||
totalDoors = Integer.MIN_VALUE;
|
||||
statusText = null;
|
||||
}
|
||||
|
||||
List<String> signalIds() {
|
||||
return signalIds;
|
||||
}
|
||||
|
||||
boolean update(GameStrings strings, int updatedActiveSignals, int updatedOpenDoors,
|
||||
int updatedTotalDoors, boolean updatedPlayerAlive) {
|
||||
GameLanguage updatedLanguage = strings.language();
|
||||
if (statusText != null
|
||||
&& language == updatedLanguage
|
||||
&& activeSignals == updatedActiveSignals
|
||||
&& openDoors == updatedOpenDoors
|
||||
&& totalDoors == updatedTotalDoors
|
||||
&& playerAlive == updatedPlayerAlive) {
|
||||
return false;
|
||||
}
|
||||
language = updatedLanguage;
|
||||
activeSignals = updatedActiveSignals;
|
||||
openDoors = updatedOpenDoors;
|
||||
totalDoors = updatedTotalDoors;
|
||||
playerAlive = updatedPlayerAlive;
|
||||
statusText = AccessibilityStatus.describe(strings, activeSignals, signalIds.size(),
|
||||
openDoors, totalDoors, playerAlive);
|
||||
return true;
|
||||
}
|
||||
|
||||
String statusText() {
|
||||
return statusText;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
final class AccessibilityStatus {
|
||||
private AccessibilityStatus() {
|
||||
}
|
||||
|
||||
static String describe(int activeSignals, int totalSignals, int openDoors, int totalDoors,
|
||||
boolean playerAlive) {
|
||||
return "Signals " + activeSignals + "/" + totalSignals
|
||||
+ " · Doors " + openDoors + "/" + totalDoors
|
||||
+ " · Volunteer " + (playerAlive ? "active" : "respawning");
|
||||
}
|
||||
|
||||
static String describe(GameStrings strings, int activeSignals, int totalSignals,
|
||||
int openDoors, int totalDoors, boolean playerAlive) {
|
||||
return strings.accessibilityStatus(activeSignals, totalSignals, openDoors, totalDoors,
|
||||
playerAlive);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Immutable completion statistics used by rendering and device-level navigation tests. */
|
||||
public final class CompletionResult {
|
||||
public enum NextGoal {
|
||||
TARGET_DEATHS,
|
||||
TARGET_TIME,
|
||||
NEXT_LEVEL,
|
||||
PERSONAL_BEST
|
||||
}
|
||||
|
||||
private final int currentDeaths;
|
||||
private final long currentTimeMs;
|
||||
private final int bestDeaths;
|
||||
private final long bestTimeMs;
|
||||
private final int targetDeaths;
|
||||
private final long targetTimeMs;
|
||||
private final NextGoal nextGoal;
|
||||
private final long nextGoalValue;
|
||||
|
||||
private CompletionResult(int currentDeaths, long currentTimeMs,
|
||||
int bestDeaths, long bestTimeMs,
|
||||
int targetDeaths, long targetTimeMs,
|
||||
NextGoal nextGoal, long nextGoalValue) {
|
||||
this.currentDeaths = currentDeaths;
|
||||
this.currentTimeMs = currentTimeMs;
|
||||
this.bestDeaths = bestDeaths;
|
||||
this.bestTimeMs = bestTimeMs;
|
||||
this.targetDeaths = targetDeaths;
|
||||
this.targetTimeMs = targetTimeMs;
|
||||
this.nextGoal = nextGoal;
|
||||
this.nextGoalValue = nextGoalValue;
|
||||
}
|
||||
|
||||
public static CompletionResult create(LevelDefinition level,
|
||||
int currentDeaths, long currentTimeMs,
|
||||
int bestDeaths, long bestTimeMs,
|
||||
boolean hasNextLevel) {
|
||||
long targetTimeMs = level.targetSeconds * 1_000L;
|
||||
NextGoal goal;
|
||||
long goalValue;
|
||||
if (currentDeaths > level.targetDeaths) {
|
||||
goal = NextGoal.TARGET_DEATHS;
|
||||
goalValue = level.targetDeaths;
|
||||
} else if (currentTimeMs > targetTimeMs) {
|
||||
goal = NextGoal.TARGET_TIME;
|
||||
goalValue = targetTimeMs;
|
||||
} else if (hasNextLevel) {
|
||||
goal = NextGoal.NEXT_LEVEL;
|
||||
goalValue = level.number + 1L;
|
||||
} else {
|
||||
goal = NextGoal.PERSONAL_BEST;
|
||||
goalValue = 0L;
|
||||
}
|
||||
return new CompletionResult(currentDeaths, currentTimeMs, bestDeaths, bestTimeMs,
|
||||
level.targetDeaths, targetTimeMs, goal, goalValue);
|
||||
}
|
||||
|
||||
public int currentDeaths() { return currentDeaths; }
|
||||
public long currentTimeMs() { return currentTimeMs; }
|
||||
public int bestDeaths() { return bestDeaths; }
|
||||
public long bestTimeMs() { return bestTimeMs; }
|
||||
public int targetDeaths() { return targetDeaths; }
|
||||
public long targetTimeMs() { return targetTimeMs; }
|
||||
public NextGoal nextGoal() { return nextGoal; }
|
||||
public long nextGoalValue() { return nextGoalValue; }
|
||||
}
|
||||
@@ -45,7 +45,9 @@ public final class EarlyLevelScenarios {
|
||||
EarlyLevelScenarios::respawnedAtStart)
|
||||
.step("Approach the completed bridge", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 470f)
|
||||
.step("Cross the corpse bridge", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
.step("Land on the far bridge body", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerOnGround() && snapshot.playerX() >= 800f)
|
||||
.step("Jump from the bridge to safe floor", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 900f)
|
||||
.step("Release jump before the final ledge", 2_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 1_030f)
|
||||
@@ -65,9 +67,16 @@ public final class EarlyLevelScenarios {
|
||||
.step("Wait for the next volunteer", 2_000L, LevelPlaytestBot.Input.none(),
|
||||
EarlyLevelScenarios::respawnedAtStart)
|
||||
.step("Approach the saw", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 390f)
|
||||
.step("Jump over the saw and plate body", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 700f)
|
||||
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= 360f))
|
||||
.step("Take off before the saw", 1_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1, snapshot -> snapshot.playerAlive()
|
||||
&& !snapshot.playerOnGround()
|
||||
&& snapshot.playerY() <= 620f))
|
||||
.step("Clear the saw and plate body", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
LevelPlaytestBot.whileDeathsAtMost(1, snapshot ->
|
||||
snapshot.playerAlive() && snapshot.playerX() >= 700f))
|
||||
.step("Reach the relic through the open door", 5_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
@@ -76,7 +85,10 @@ public final class EarlyLevelScenarios {
|
||||
public static LevelPlaytestBot.Scenario levelFour() {
|
||||
return LevelPlaytestBot.Scenario.builder(4, "Level 4: toggle lift to gallery")
|
||||
.step("Cross the toggle once and activate the lift", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 800f)
|
||||
GameView.AutomationSnapshot::firstMovingPlatformPowered)
|
||||
.step("Approach the powered moving lift", 3_000L, LevelPlaytestBot.Input.right(),
|
||||
snapshot -> snapshot.firstMovingPlatformPowered()
|
||||
&& snapshot.playerX() >= 800f)
|
||||
.step("Jump onto the moving lift", 3_000L, LevelPlaytestBot.Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 1_050f && snapshot.playerY() < 690f)
|
||||
.step("Ride the lift to its upper phase", 7_000L, LevelPlaytestBot.Input.none(),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Player-selected language, independent from the device locale. */
|
||||
public enum GameLanguage {
|
||||
EN("en"),
|
||||
RU("ru");
|
||||
|
||||
private final String code;
|
||||
|
||||
GameLanguage(String code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public GameLanguage next() {
|
||||
return this == EN ? RU : EN;
|
||||
}
|
||||
|
||||
public static GameLanguage fromCode(String code) {
|
||||
if (RU.code.equalsIgnoreCase(code)) return RU;
|
||||
return EN;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
/** Persists the explicit in-game language without mutating progress or controls. */
|
||||
final class GameLanguageStore {
|
||||
private static final String FILE = "project_sacrifice_language_v1";
|
||||
private static final String KEY_LANGUAGE = "language";
|
||||
private final SharedPreferences preferences;
|
||||
|
||||
GameLanguageStore(Context context) {
|
||||
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
GameLanguage load() {
|
||||
return GameLanguage.fromCode(preferences.getString(KEY_LANGUAGE, GameLanguage.EN.code()));
|
||||
}
|
||||
|
||||
void save(GameLanguage language) {
|
||||
preferences.edit().putString(KEY_LANGUAGE,
|
||||
language == null ? GameLanguage.EN.code() : language.code()).apply();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** All player-facing copy behind one typed, Android-independent interface. */
|
||||
public final class GameStrings {
|
||||
private static final String[] EN_TITLES = {
|
||||
"The Volunteer", "A Useful End", "Dead Weight", "The Long Lift",
|
||||
"Express Delivery", "Cold Comfort", "Live Wire", "Borrowed Time",
|
||||
"Two Good Reasons", "The Last Procession"
|
||||
};
|
||||
private static final String[] RU_TITLES = {
|
||||
"Рыцарь-доброволец", "Полезный конец", "Мёртвый груз", "Долгий подъём",
|
||||
"Срочная доставка", "Холодное утешение", "Живой провод", "Время взаймы",
|
||||
"Две веские причины", "Последнее шествие"
|
||||
};
|
||||
private static final String[] EN_HINTS = {
|
||||
"Reach the relic. Arrows move; the winged button jumps.",
|
||||
"The spikes keep every volunteer. Build a path from what remains.",
|
||||
"Leave a body on the brass plate so the next volunteer can pass.",
|
||||
"Touch the lever, then ride the moving platform to the upper gallery.",
|
||||
"The saw throws to the right. Deliver a volunteer to the distant plate.",
|
||||
"The frost makes a solid block. Use the frozen volunteer as a step.",
|
||||
"A fallen volunteer conducts the current and keeps the relay alive.",
|
||||
"Jump for the far blue relay if the timed gate closes.",
|
||||
"The final gate needs weight and power at the same time.",
|
||||
"Launch, freeze, conduct, ascend. Every lesson opens the last relic."
|
||||
};
|
||||
private static final String[] RU_HINTS = {
|
||||
"Доберитесь до реликвии. Стрелки двигают; кнопка с крылом — прыжок.",
|
||||
"Шипы сохраняют каждого добровольца. Проложите путь из того, что осталось.",
|
||||
"Оставьте тело на латунной плите, чтобы следующий доброволец прошёл.",
|
||||
"Коснитесь рычага, затем поднимитесь на платформе к верхней галерее.",
|
||||
"Пила бросает вправо. Доставьте добровольца на дальнюю плиту.",
|
||||
"Мороз создаёт твёрдый блок. Используйте замёрзшего добровольца как ступень.",
|
||||
"Павший доброволец проводит ток и поддерживает реле.",
|
||||
"Прыгните к дальнему синему реле, если ворота по таймеру закроются.",
|
||||
"Последним воротам одновременно нужны вес и питание.",
|
||||
"Разгонитесь, замёрзните, проведите ток, поднимитесь. Каждый урок открывает последнюю реликвию."
|
||||
};
|
||||
|
||||
private final GameLanguage language;
|
||||
|
||||
private GameStrings(GameLanguage language) {
|
||||
this.language = language == null ? GameLanguage.EN : language;
|
||||
}
|
||||
|
||||
public static GameStrings forLanguage(GameLanguage language) {
|
||||
return new GameStrings(language);
|
||||
}
|
||||
|
||||
public GameLanguage language() {
|
||||
return language;
|
||||
}
|
||||
|
||||
private boolean russian() {
|
||||
return language == GameLanguage.RU;
|
||||
}
|
||||
|
||||
public String appContentDescription() {
|
||||
return russian()
|
||||
? "Project Sacrifice. Платформенная головоломка из десяти уровней."
|
||||
: "Project Sacrifice. Ten-level puzzle platform game.";
|
||||
}
|
||||
|
||||
public String projectTitle() { return "PROJECT SACRIFICE"; }
|
||||
public String tagline() {
|
||||
return russian() ? "Каждый конец оставляет путь вперёд"
|
||||
: "Every ending leaves a way forward";
|
||||
}
|
||||
public String locked() { return russian() ? "ЗАКРЫТО" : "LOCKED"; }
|
||||
public String tapToEnter() { return russian() ? "Нажмите, чтобы войти" : "Tap to enter"; }
|
||||
public String levelSelectFooter() {
|
||||
return russian()
|
||||
? "Альбомная ориентация · мультитач · прогресс сохраняется автоматически"
|
||||
: "Landscape · multitouch · progress saves automatically";
|
||||
}
|
||||
|
||||
public String settingsButton() {
|
||||
return russian() ? "Настройки" : "Settings";
|
||||
}
|
||||
|
||||
public String settingsTitle() { return russian() ? "НАСТРОЙКИ" : "SETTINGS"; }
|
||||
public String settingsInstruction() {
|
||||
return russian() ? "Нажмите на строку, чтобы изменить. Всё сохраняется автоматически."
|
||||
: "Tap a row to change it. Changes save automatically.";
|
||||
}
|
||||
public String controlSizeSetting() { return russian() ? "Размер управления" : "Control size"; }
|
||||
public String controlOpacitySetting() { return russian() ? "Прозрачность управления" : "Control opacity"; }
|
||||
public String vibrationSetting() { return russian() ? "Вибрация" : "Vibration"; }
|
||||
public String languageSetting() { return russian() ? "Язык" : "Language"; }
|
||||
public String languageName() { return russian() ? "Русский" : "English"; }
|
||||
public String controlSizeValue(int index) {
|
||||
String[] values = russian()
|
||||
? new String[]{"Маленький", "Средний", "Большой"}
|
||||
: new String[]{"Small", "Medium", "Large"};
|
||||
return values[validPreset(index)];
|
||||
}
|
||||
public String controlOpacityValue(int index) {
|
||||
String[] values = russian()
|
||||
? new String[]{"Низкая", "Средняя", "Высокая"}
|
||||
: new String[]{"Low", "Medium", "High"};
|
||||
return values[validPreset(index)];
|
||||
}
|
||||
public String hapticsValue(boolean enabled) {
|
||||
if (russian()) return enabled ? "Вкл." : "Выкл.";
|
||||
return enabled ? "On" : "Off";
|
||||
}
|
||||
public String accessibilityTitle() { return russian() ? "ДОСТУПНОСТЬ" : "ACCESSIBILITY"; }
|
||||
public String textSizeSetting() { return russian() ? "Размер текста" : "Text size"; }
|
||||
public String textSizeValue(float scale) {
|
||||
return scale > 1f ? (russian() ? "Крупный" : "Large")
|
||||
: (russian() ? "Обычный" : "Standard");
|
||||
}
|
||||
public String highContrastSetting() { return russian() ? "Контраст" : "High contrast"; }
|
||||
public String highContrastValue(boolean enabled) {
|
||||
return hapticsValue(enabled);
|
||||
}
|
||||
public String back() { return russian() ? "Назад" : "Back"; }
|
||||
|
||||
public String hudStats(int deaths, long elapsedMilliseconds) {
|
||||
if (russian()) {
|
||||
return "Добровольцы " + deaths + " Время " + GameMath.formatTime(elapsedMilliseconds);
|
||||
}
|
||||
return "Volunteers " + deaths + " Time " + GameMath.formatTime(elapsedMilliseconds);
|
||||
}
|
||||
public String restart() { return russian() ? "Заново" : "Restart"; }
|
||||
public String respawning() {
|
||||
return russian() ? "Следующий доброволец уже в пути…"
|
||||
: "Another volunteer is on the way…";
|
||||
}
|
||||
|
||||
public String levelBestStats(int deaths, long elapsedMilliseconds) {
|
||||
return "✓ " + deaths + " " + volunteerWord(deaths) + " · "
|
||||
+ GameMath.formatTime(elapsedMilliseconds);
|
||||
}
|
||||
public String relicClaimed() { return russian() ? "РЕЛИКВИЯ ПОЛУЧЕНА" : "RELIC CLAIMED"; }
|
||||
public String completion() { return russian() ? "Завершение ★" : "Completion ★"; }
|
||||
public String completionDeaths(boolean earned, int deaths, int targetDeaths) {
|
||||
return (earned ? "★" : "☆") + " " + deaths + " / " + targetDeaths + " "
|
||||
+ volunteerWord(deaths);
|
||||
}
|
||||
public String completionTime(boolean earned, long elapsedMilliseconds, long targetMilliseconds) {
|
||||
return (earned ? "★" : "☆") + " " + GameMath.formatTime(elapsedMilliseconds)
|
||||
+ " / " + GameMath.formatTime(targetMilliseconds);
|
||||
}
|
||||
public String levelSelect() { return russian() ? "Выбор уровня" : "Level select"; }
|
||||
public String replay() { return russian() ? "Повторить" : "Replay"; }
|
||||
public String nextLevel() { return russian() ? "Следующий уровень" : "Next level"; }
|
||||
public String paused() { return russian() ? "ПАУЗА" : "PAUSED"; }
|
||||
public String resume() { return russian() ? "Продолжить" : "Resume"; }
|
||||
|
||||
// Result-flow hooks used by the Stage 3 result screen.
|
||||
public String currentResult() { return russian() ? "Текущий результат" : "Current result"; }
|
||||
public String bestResult() { return russian() ? "Лучший результат" : "Best result"; }
|
||||
public String deathsStat() { return russian() ? "Добровольцы" : "Volunteers"; }
|
||||
public String timeStat() { return russian() ? "Время" : "Time"; }
|
||||
public String newBest() { return russian() ? "НОВЫЙ РЕКОРД" : "NEW BEST"; }
|
||||
|
||||
public String currentResultLine(int deaths, long timeMs) {
|
||||
return currentResult() + " · " + deaths + " " + volunteerWord(deaths)
|
||||
+ " · " + GameMath.formatTime(timeMs);
|
||||
}
|
||||
|
||||
public String bestResultLine(int deaths, long timeMs) {
|
||||
return bestResult() + " · " + deaths + " " + volunteerWord(deaths)
|
||||
+ " · " + GameMath.formatTime(timeMs);
|
||||
}
|
||||
|
||||
public String targetsResultLine(int deaths, long timeMs) {
|
||||
String label = russian() ? "Цели" : "Targets";
|
||||
return label + " · " + deaths + " " + volunteerWord(deaths)
|
||||
+ " · " + GameMath.formatTime(timeMs);
|
||||
}
|
||||
|
||||
public String nextGoalDeaths(long deaths) {
|
||||
return russian()
|
||||
? "Следующая цель · пройти с " + deaths + " добровольцами"
|
||||
: "Next goal · finish with " + deaths + " volunteers";
|
||||
}
|
||||
|
||||
public String nextGoalTime(long timeMs) {
|
||||
return (russian() ? "Следующая цель · пройти за " : "Next goal · finish in ")
|
||||
+ GameMath.formatTime(timeMs);
|
||||
}
|
||||
|
||||
public String nextGoalLevel(long level) {
|
||||
return (russian() ? "Следующая цель · перейти на уровень "
|
||||
: "Next goal · enter level ") + level;
|
||||
}
|
||||
|
||||
public String nextGoalPersonalBest() {
|
||||
return russian() ? "Следующая цель · улучшить личный рекорд"
|
||||
: "Next goal · improve your personal best";
|
||||
}
|
||||
|
||||
// Onboarding hooks keep new tutorial Canvas copy out of GameView conditionals.
|
||||
public String tutorialWelcomeTitle() {
|
||||
return russian() ? "Смерть — это инструмент" : "Death is a tool";
|
||||
}
|
||||
public String tutorialWelcomeBody() {
|
||||
return russian()
|
||||
? "Двигайтесь стрелками и прыгайте кнопкой с крылом. Доберитесь до реликвии."
|
||||
: "Move with the arrows and jump with the winged button. Reach the relic.";
|
||||
}
|
||||
public String tutorialSacrificeTitle() {
|
||||
return russian() ? "Оставьте путь вперёд" : "Leave a way forward";
|
||||
}
|
||||
public String tutorialSacrificeBody() {
|
||||
return russian()
|
||||
? "После смерти тело остаётся в мире: закрывает шипы, держит плиты и становится ступенью."
|
||||
: "A body stays in the world after death: cover spikes, hold plates, and make a step.";
|
||||
}
|
||||
public String tutorialSkip() { return russian() ? "Пропустить" : "Skip"; }
|
||||
public String tutorialNext() { return russian() ? "Далее" : "Next"; }
|
||||
public String tutorialStart() { return russian() ? "Начать" : "Start"; }
|
||||
public String tutorialDone() { return russian() ? "Готово" : "Done"; }
|
||||
public String tutorialMovementTitle() { return russian() ? "ДВИЖЕНИЕ" : "MOVE"; }
|
||||
public String tutorialMovementBody() {
|
||||
return russian() ? "Удерживайте левую или правую стрелку, чтобы двигаться."
|
||||
: "Hold the left or right arrow to explore.";
|
||||
}
|
||||
public String tutorialJumpTitle() { return russian() ? "ДВИЖЕНИЕ + ПРЫЖОК" : "MOVE + JUMP"; }
|
||||
public String tutorialJumpBody() {
|
||||
return russian() ? "Используйте два пальца: держите направление и нажмите Прыжок."
|
||||
: "Use two fingers: hold a direction and tap Jump.";
|
||||
}
|
||||
public String tutorialCorpseTitle() {
|
||||
return russian() ? "СМЕРТЬ ОСТАВЛЯЕТ ИНСТРУМЕНТ" : "DEATH LEAVES A TOOL";
|
||||
}
|
||||
public String tutorialCorpseBody() {
|
||||
return russian() ? "Павший доброволец остаётся. Используйте тело, чтобы открыть путь."
|
||||
: "A fallen volunteer remains. Use the body to open a path.";
|
||||
}
|
||||
public String tutorialProgress(int step, int total) {
|
||||
return step + " / " + total;
|
||||
}
|
||||
|
||||
public String accessibilityStatus(int activeSignals, int totalSignals,
|
||||
int openDoors, int totalDoors, boolean playerAlive) {
|
||||
if (russian()) {
|
||||
return "Сигналы " + activeSignals + "/" + totalSignals
|
||||
+ " · Двери " + openDoors + "/" + totalDoors
|
||||
+ " · Доброволец " + (playerAlive ? "активен" : "возрождается");
|
||||
}
|
||||
return "Signals " + activeSignals + "/" + totalSignals
|
||||
+ " · Doors " + openDoors + "/" + totalDoors
|
||||
+ " · Volunteer " + (playerAlive ? "active" : "respawning");
|
||||
}
|
||||
|
||||
public String levelSelectDescription(int unlocked, int total) {
|
||||
if (russian()) {
|
||||
return "Выбор уровня. Открыто " + unlocked + " из " + total
|
||||
+ ". Выберите открытый уровень или Настройки.";
|
||||
}
|
||||
return "Level select. " + unlocked + " of " + total
|
||||
+ " unlocked. Choose an unlocked level or open Settings.";
|
||||
}
|
||||
|
||||
public String settingsDescription(ControlSettings controls,
|
||||
AccessibilitySettings accessibility) {
|
||||
if (russian()) {
|
||||
return "Настройки. Размер управления "
|
||||
+ controlSizeValue(controls.controlSizeIndex())
|
||||
+ ". Прозрачность управления "
|
||||
+ controlOpacityValue(controls.controlOpacityIndex())
|
||||
+ ". Вибрация " + hapticsValue(controls.hapticsEnabled())
|
||||
+ ". Размер текста " + textSizeValue(accessibility.textScale())
|
||||
+ ". Контраст " + highContrastValue(accessibility.highContrastEnabled())
|
||||
+ ". Назад.";
|
||||
}
|
||||
return "Settings. Control size " + controls.controlSizeLabel()
|
||||
+ ". Control opacity " + controls.controlOpacityLabel()
|
||||
+ ". Vibration " + controls.hapticsLabel()
|
||||
+ ". Text size " + accessibility.textSizeLabel()
|
||||
+ ". High contrast " + accessibility.highContrastLabel()
|
||||
+ ". Back.";
|
||||
}
|
||||
|
||||
public String playingDescription(int levelNumber, int deaths, String elapsed,
|
||||
String criticalState, boolean paused) {
|
||||
if (paused) {
|
||||
return russian()
|
||||
? "Пауза на уровне " + levelNumber + ", " + levelTitle(levelNumber)
|
||||
+ ". Продолжить или вернуться к выбору уровня."
|
||||
: "Paused on level " + levelNumber + ", " + levelTitle(levelNumber)
|
||||
+ ". Resume or return to level select.";
|
||||
}
|
||||
if (russian()) {
|
||||
return "Идёт уровень " + levelNumber + ", " + levelTitle(levelNumber)
|
||||
+ ". Добровольцы " + deaths + ". Время " + elapsed + ". "
|
||||
+ criticalState + ". Движение влево и вправо, прыжок, заново, пауза.";
|
||||
}
|
||||
return "Playing level " + levelNumber + ", " + levelTitle(levelNumber)
|
||||
+ ". " + deaths + " volunteers. Time " + elapsed + ". " + criticalState
|
||||
+ ". Move left, move right, jump, restart, pause.";
|
||||
}
|
||||
|
||||
public String completeDescription(int levelNumber, int deaths, String elapsed,
|
||||
boolean hasNextLevel) {
|
||||
if (russian()) {
|
||||
return "Уровень завершён. " + levelTitle(levelNumber) + ". Добровольцы "
|
||||
+ deaths + ". Время " + elapsed
|
||||
+ ". Выбор уровня. Повторить."
|
||||
+ (hasNextLevel ? " Следующий уровень." : "");
|
||||
}
|
||||
return "Level complete. " + levelTitle(levelNumber) + ". " + deaths
|
||||
+ " volunteers. Time " + elapsed + ". Level select. Replay."
|
||||
+ (hasNextLevel ? " Next level." : "");
|
||||
}
|
||||
|
||||
public String levelTitle(int oneBasedLevelNumber) {
|
||||
return (russian() ? RU_TITLES : EN_TITLES)[levelIndex(oneBasedLevelNumber)];
|
||||
}
|
||||
|
||||
public String levelHint(int oneBasedLevelNumber) {
|
||||
return (russian() ? RU_HINTS : EN_HINTS)[levelIndex(oneBasedLevelNumber)];
|
||||
}
|
||||
|
||||
private String volunteerWord(int count) {
|
||||
if (!russian()) return count == 1 ? "volunteer" : "volunteers";
|
||||
int mod100 = Math.abs(count) % 100;
|
||||
int mod10 = Math.abs(count) % 10;
|
||||
if (mod100 >= 11 && mod100 <= 14) return "добровольцев";
|
||||
if (mod10 == 1) return "доброволец";
|
||||
if (mod10 >= 2 && mod10 <= 4) return "добровольца";
|
||||
return "добровольцев";
|
||||
}
|
||||
|
||||
private static int validPreset(int index) {
|
||||
return index >= 0 && index <= 2 ? index : 1;
|
||||
}
|
||||
|
||||
private static int levelIndex(int oneBasedLevelNumber) {
|
||||
int index = oneBasedLevelNumber - 1;
|
||||
return index >= 0 && index < EN_TITLES.length ? index : 0;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,10 @@ import java.util.List;
|
||||
|
||||
/** Runs black-box acceptance scenarios through the same input and state boundary as touch play. */
|
||||
public final class LevelPlaytestBot {
|
||||
private static final long DECISION_MILLIS = 16L;
|
||||
private static final long MIN_WALL_WATCHDOG_MILLIS = 120_000L;
|
||||
private static final long WALL_WATCHDOG_MULTIPLIER = 10L;
|
||||
|
||||
public interface Driver {
|
||||
void startLevel(int oneBasedLevelNumber);
|
||||
void setInput(boolean moveLeft, boolean moveRight, boolean jump);
|
||||
@@ -44,6 +48,27 @@ public final class LevelPlaytestBot {
|
||||
boolean reached(GameView.AutomationSnapshot snapshot);
|
||||
}
|
||||
|
||||
public static SnapshotCondition groundedAliveAtOrBeyond(float minimumX) {
|
||||
return snapshot -> snapshot.playerAlive()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.playerX() >= minimumX;
|
||||
}
|
||||
|
||||
public static SnapshotCondition whileDeathsAtMost(
|
||||
int maximumDeaths, SnapshotCondition completion) {
|
||||
return snapshot -> {
|
||||
if (snapshot.deaths() > maximumDeaths) {
|
||||
throw new AssertionError("Route expected at most " + maximumDeaths
|
||||
+ " deaths but observed " + snapshot.deaths()
|
||||
+ "; x=" + snapshot.playerX()
|
||||
+ ", y=" + snapshot.playerY()
|
||||
+ ", alive=" + snapshot.playerAlive()
|
||||
+ ", grounded=" + snapshot.playerOnGround());
|
||||
}
|
||||
return completion.reached(snapshot);
|
||||
};
|
||||
}
|
||||
|
||||
public static final class Scenario {
|
||||
private final int levelNumber;
|
||||
private final String name;
|
||||
@@ -123,22 +148,38 @@ public final class LevelPlaytestBot {
|
||||
|
||||
public Result run(Driver driver, Scenario scenario, long timeoutMillis) {
|
||||
driver.startLevel(scenario.levelNumber);
|
||||
long scenarioStartedAt = driver.currentTimeMillis();
|
||||
long stepStartedAt = scenarioStartedAt;
|
||||
long scenarioStartedAtWall = driver.currentTimeMillis();
|
||||
long wallWatchdogMillis = wallWatchdogMillis(timeoutMillis);
|
||||
int stepIndex = 0;
|
||||
String lastStep = scenario.steps.isEmpty() ? null : scenario.steps.get(0).name;
|
||||
boolean doorOpened = false;
|
||||
GameView.AutomationSnapshot last = driver.snapshot();
|
||||
long scenarioStartedAtSimulation = last.elapsedMilliseconds();
|
||||
long stepStartedAtSimulation = scenarioStartedAtSimulation;
|
||||
|
||||
while (driver.currentTimeMillis() - scenarioStartedAt < timeoutMillis) {
|
||||
while (true) {
|
||||
last = driver.snapshot();
|
||||
long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall);
|
||||
long simulationElapsed = elapsed(
|
||||
last.elapsedMilliseconds(), scenarioStartedAtSimulation);
|
||||
doorOpened |= last.allDoorsOpen();
|
||||
if (last.completed()) {
|
||||
driver.setInput(false, false, false);
|
||||
return new Result(true, doorOpened, last.deaths(), last.corpseCount(),
|
||||
scenario.name + " completed; step=" + lastStep + "; " + snapshotState(last),
|
||||
scenario.name + " completed; step=" + lastStep + "; "
|
||||
+ timingState(simulationElapsed, wallElapsed) + "; "
|
||||
+ snapshotState(last),
|
||||
lastStep);
|
||||
}
|
||||
if (wallElapsed >= wallWatchdogMillis) {
|
||||
driver.setInput(false, false, false);
|
||||
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
|
||||
scenario.name + " stopped by wall-clock watchdog; step=" + lastStep
|
||||
+ "; " + timingState(simulationElapsed, wallElapsed) + "; "
|
||||
+ snapshotState(last),
|
||||
lastStep);
|
||||
}
|
||||
if (simulationElapsed >= timeoutMillis) break;
|
||||
if (stepIndex >= scenario.steps.size()) break;
|
||||
|
||||
Step step = scenario.steps.get(stepIndex);
|
||||
@@ -146,26 +187,50 @@ public final class LevelPlaytestBot {
|
||||
if (step.completion.reached(last)) {
|
||||
driver.setInput(false, false, false);
|
||||
stepIndex++;
|
||||
stepStartedAt = driver.currentTimeMillis();
|
||||
stepStartedAtSimulation = last.elapsedMilliseconds();
|
||||
continue;
|
||||
}
|
||||
if (driver.currentTimeMillis() - stepStartedAt >= step.timeoutMillis) {
|
||||
if (elapsed(last.elapsedMilliseconds(), stepStartedAtSimulation)
|
||||
>= step.timeoutMillis) {
|
||||
driver.setInput(false, false, false);
|
||||
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
|
||||
scenario.name + " timed out; step=" + step.name + "; " + snapshotState(last),
|
||||
scenario.name + " timed out in simulation; step=" + step.name
|
||||
+ "; " + timingState(simulationElapsed, wallElapsed) + "; "
|
||||
+ snapshotState(last),
|
||||
step.name);
|
||||
}
|
||||
|
||||
driver.setInput(step.input.moveLeft, step.input.moveRight, step.input.jump);
|
||||
driver.waitForNextDecision(32L);
|
||||
driver.waitForNextDecision(DECISION_MILLIS);
|
||||
}
|
||||
|
||||
driver.setInput(false, false, false);
|
||||
long wallElapsed = elapsed(driver.currentTimeMillis(), scenarioStartedAtWall);
|
||||
long simulationElapsed = elapsed(
|
||||
last.elapsedMilliseconds(), scenarioStartedAtSimulation);
|
||||
return new Result(false, doorOpened, last.deaths(), last.corpseCount(),
|
||||
scenario.name + " did not complete; step=" + lastStep + "; " + snapshotState(last),
|
||||
scenario.name + " exhausted its simulation budget; step=" + lastStep
|
||||
+ "; " + timingState(simulationElapsed, wallElapsed) + "; "
|
||||
+ snapshotState(last),
|
||||
lastStep);
|
||||
}
|
||||
|
||||
private static long wallWatchdogMillis(long simulationBudgetMillis) {
|
||||
if (simulationBudgetMillis > Long.MAX_VALUE / WALL_WATCHDOG_MULTIPLIER) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
return Math.max(MIN_WALL_WATCHDOG_MILLIS,
|
||||
simulationBudgetMillis * WALL_WATCHDOG_MULTIPLIER);
|
||||
}
|
||||
|
||||
private static long elapsed(long current, long startedAt) {
|
||||
return Math.max(0L, current - startedAt);
|
||||
}
|
||||
|
||||
private static String timingState(long simulationElapsed, long wallElapsed) {
|
||||
return "simulationElapsed=" + simulationElapsed + ", wallElapsed=" + wallElapsed;
|
||||
}
|
||||
|
||||
private static String snapshotState(GameView.AutomationSnapshot snapshot) {
|
||||
return "x=" + snapshot.playerX()
|
||||
+ ", y=" + snapshot.playerY()
|
||||
@@ -190,8 +255,10 @@ public final class LevelPlaytestBot {
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() < 250f)
|
||||
.step("Approach the saw", 4_000L, Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 390f)
|
||||
.step("Jump over the saw", 2_000L, Input.rightJump(),
|
||||
snapshot -> snapshot.playerX() >= 430f)
|
||||
.step("Take off before the saw", 1_000L, Input.rightJump(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerY() <= 700f)
|
||||
.step("Clear the saw", 2_000L, Input.right(),
|
||||
snapshot -> snapshot.playerAlive() && snapshot.playerX() >= 680f)
|
||||
.step("Run to the ledge", 4_000L, Input.right(),
|
||||
snapshot -> snapshot.playerX() >= 750f)
|
||||
.step("Jump onto the ledge", 2_000L, Input.rightJump(),
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Stable public-state gate for a single moving-platform launch. */
|
||||
final class MovingPlatformLaunchPolicy {
|
||||
private MovingPlatformLaunchPolicy() {}
|
||||
|
||||
static boolean readyToBoard(GameView.AutomationSnapshot snapshot, float maximumPlatformX) {
|
||||
return snapshot.firstMovingPlatformPowered()
|
||||
&& snapshot.firstMovingPlatformMovingRight()
|
||||
&& snapshot.firstMovingPlatformX() <= maximumPlatformX;
|
||||
}
|
||||
|
||||
static boolean ready(GameView.AutomationSnapshot snapshot, float minimumPlatformX) {
|
||||
return snapshot.playerOnMovingPlatform()
|
||||
&& snapshot.playerOnGround()
|
||||
&& snapshot.firstMovingPlatformPowered()
|
||||
&& snapshot.firstMovingPlatformX() >= minimumPlatformX;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Small state machine for the optional, first-run gameplay hints. */
|
||||
public final class OnboardingFlow {
|
||||
public enum Step {
|
||||
MOVEMENT,
|
||||
MULTITOUCH_JUMP,
|
||||
CORPSES_ARE_TOOLS
|
||||
}
|
||||
|
||||
private Step step;
|
||||
private boolean complete;
|
||||
|
||||
private OnboardingFlow(boolean complete) {
|
||||
this.complete = complete;
|
||||
step = Step.MOVEMENT;
|
||||
}
|
||||
|
||||
public static OnboardingFlow firstRun(boolean alreadyComplete) {
|
||||
return new OnboardingFlow(alreadyComplete);
|
||||
}
|
||||
|
||||
public boolean isVisible() {
|
||||
return !complete;
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return complete;
|
||||
}
|
||||
|
||||
public Step step() {
|
||||
return step;
|
||||
}
|
||||
|
||||
public void next() {
|
||||
if (complete) return;
|
||||
if (step == Step.MOVEMENT) step = Step.MULTITOUCH_JUMP;
|
||||
else if (step == Step.MULTITOUCH_JUMP) step = Step.CORPSES_ARE_TOOLS;
|
||||
else complete = true;
|
||||
}
|
||||
|
||||
public void skip() {
|
||||
complete = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
/** Versioned preference boundary for the optional first-run tutorial. */
|
||||
public final class OnboardingStore {
|
||||
private static final String FILE = "project_sacrifice_onboarding_v1";
|
||||
private static final String KEY_COMPLETE = "complete";
|
||||
private static final String KEY_SCHEMA = "schema";
|
||||
private static final int SCHEMA_VERSION = 1;
|
||||
|
||||
private final SharedPreferences preferences;
|
||||
|
||||
public OnboardingStore(Context context) {
|
||||
preferences = context.getSharedPreferences(FILE, Context.MODE_PRIVATE);
|
||||
}
|
||||
|
||||
public boolean isComplete() {
|
||||
return preferences.getBoolean(KEY_COMPLETE, false);
|
||||
}
|
||||
|
||||
public void markComplete() {
|
||||
preferences.edit()
|
||||
.putInt(KEY_SCHEMA, SCHEMA_VERSION)
|
||||
.putBoolean(KEY_COMPLETE, true)
|
||||
.apply();
|
||||
}
|
||||
|
||||
public void resetForAutomation() {
|
||||
preferences.edit().clear().commit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
/** Simulation-first timeout with a separate watchdog for a stalled device. */
|
||||
final class PlaytestSimulationBudget {
|
||||
private static final long MIN_WALL_WATCHDOG_MILLIS = 120_000L;
|
||||
private static final long WALL_WATCHDOG_MULTIPLIER = 10L;
|
||||
|
||||
private final long simulationStartedAt;
|
||||
private final long wallStartedAt;
|
||||
private final long simulationBudgetMillis;
|
||||
private final long wallWatchdogMillis;
|
||||
|
||||
private PlaytestSimulationBudget(long simulationStartedAt, long wallStartedAt,
|
||||
long simulationBudgetMillis) {
|
||||
this.simulationStartedAt = simulationStartedAt;
|
||||
this.wallStartedAt = wallStartedAt;
|
||||
this.simulationBudgetMillis = simulationBudgetMillis;
|
||||
this.wallWatchdogMillis = wallWatchdogMillis(simulationBudgetMillis);
|
||||
}
|
||||
|
||||
static PlaytestSimulationBudget start(long simulationStartedAt, long wallStartedAt,
|
||||
long simulationBudgetMillis) {
|
||||
return new PlaytestSimulationBudget(
|
||||
simulationStartedAt, wallStartedAt, simulationBudgetMillis);
|
||||
}
|
||||
|
||||
boolean exhausted(long simulationNow, long wallNow) {
|
||||
return simulationElapsed(simulationNow) >= simulationBudgetMillis
|
||||
|| wallElapsed(wallNow) >= wallWatchdogMillis;
|
||||
}
|
||||
|
||||
String timingState(long simulationNow, long wallNow) {
|
||||
return "simulationElapsed=" + simulationElapsed(simulationNow)
|
||||
+ ", wallElapsed=" + wallElapsed(wallNow);
|
||||
}
|
||||
|
||||
private long simulationElapsed(long simulationNow) {
|
||||
return elapsed(simulationNow, simulationStartedAt);
|
||||
}
|
||||
|
||||
private long wallElapsed(long wallNow) {
|
||||
return elapsed(wallNow, wallStartedAt);
|
||||
}
|
||||
|
||||
private static long wallWatchdogMillis(long simulationBudgetMillis) {
|
||||
if (simulationBudgetMillis > Long.MAX_VALUE / WALL_WATCHDOG_MULTIPLIER) {
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
return Math.max(MIN_WALL_WATCHDOG_MILLIS,
|
||||
simulationBudgetMillis * WALL_WATCHDOG_MULTIPLIER);
|
||||
}
|
||||
|
||||
private static long elapsed(long current, long startedAt) {
|
||||
return Math.max(0L, current - startedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class AccessibilityScreenDescriptionTest {
|
||||
@Test
|
||||
public void everyPlayerFacingScreenNamesItsStateAndAvailableActions() {
|
||||
String levels = AccessibilityScreenDescription.levelSelect(3, 10);
|
||||
assertTrue(levels.contains("Level select"));
|
||||
assertTrue(levels.contains("3 of 10 unlocked"));
|
||||
assertTrue(levels.contains("Settings"));
|
||||
|
||||
String settings = AccessibilityScreenDescription.settings(
|
||||
ControlSettings.defaults(), AccessibilitySettings.defaults());
|
||||
assertTrue(settings.contains("Settings"));
|
||||
assertTrue(settings.contains("Text size Standard"));
|
||||
assertTrue(settings.contains("High contrast Off"));
|
||||
assertTrue(settings.contains("Back"));
|
||||
|
||||
String playing = AccessibilityScreenDescription.playing(
|
||||
LevelCatalog.get(0), 2, "01:23", "Signals 1/1", false);
|
||||
assertTrue(playing.contains("Playing level 1"));
|
||||
assertTrue(playing.contains("2 volunteers"));
|
||||
assertTrue(playing.contains("Signals 1/1"));
|
||||
assertTrue(playing.contains("Move left, move right, jump, restart, pause"));
|
||||
|
||||
String complete = AccessibilityScreenDescription.complete(
|
||||
LevelCatalog.get(0), 2, "01:23", true);
|
||||
assertTrue(complete.contains("Level complete"));
|
||||
assertTrue(complete.contains("Replay"));
|
||||
assertTrue(complete.contains("Next level"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import org.junit.Test;
|
||||
|
||||
public final class AccessibilitySettingsTest {
|
||||
@Test
|
||||
public void textSizeCyclesBetweenReadablePresetsAndWraps() {
|
||||
AccessibilitySettings settings = AccessibilitySettings.defaults();
|
||||
|
||||
assertEquals("Standard", settings.textSizeLabel());
|
||||
assertEquals(1f, settings.textScale(), 0f);
|
||||
|
||||
settings = settings.nextTextSize();
|
||||
assertEquals("Large", settings.textSizeLabel());
|
||||
assertEquals(1.25f, settings.textScale(), 0f);
|
||||
|
||||
assertEquals("Standard", settings.nextTextSize().textSizeLabel());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void highContrastIsExplicitAndReversible() {
|
||||
AccessibilitySettings settings = AccessibilitySettings.defaults();
|
||||
|
||||
assertFalse(settings.highContrastEnabled());
|
||||
assertEquals("Off", settings.highContrastLabel());
|
||||
|
||||
settings = settings.toggleHighContrast();
|
||||
assertTrue(settings.highContrastEnabled());
|
||||
assertEquals("On", settings.highContrastLabel());
|
||||
assertFalse(settings.toggleHighContrast().highContrastEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidPersistedPresetFallsBackWithoutChangingContrastChoice() {
|
||||
AccessibilitySettings settings = AccessibilitySettings.restore(99, true);
|
||||
|
||||
assertEquals("Standard", settings.textSizeLabel());
|
||||
assertTrue(settings.highContrastEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void largePresetScalesEveryCanvasTextRequestWithoutMovingLayout() {
|
||||
AccessibilitySettings large = AccessibilitySettings.defaults().nextTextSize();
|
||||
|
||||
assertEquals(30f, large.canvasTextSize(24f), 0f);
|
||||
assertEquals(80f, large.canvasTextSize(64f), 0f);
|
||||
assertEquals(24f, AccessibilitySettings.defaults().canvasTextSize(24f), 0f);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class AccessibilityStateCacheTest {
|
||||
@Test
|
||||
public void unchangedFramesReuseOneStatusAndOneDeduplicatedSignalSet() {
|
||||
AccessibilityStateCache cache = new AccessibilityStateCache();
|
||||
cache.configureSignals(Arrays.asList("lift", "lift", "door"));
|
||||
|
||||
assertEquals(Arrays.asList("lift", "door"), cache.signalIds());
|
||||
assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 0, 1, true));
|
||||
String first = cache.statusText();
|
||||
|
||||
for (int frame = 0; frame < 600; frame++) {
|
||||
assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 0, 1, true));
|
||||
assertSame(first, cache.statusText());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void criticalStateAndLanguageChangesInvalidateExactlyOnce() {
|
||||
AccessibilityStateCache cache = new AccessibilityStateCache();
|
||||
cache.configureSignals(Arrays.asList("plate"));
|
||||
cache.update(GameStrings.forLanguage(GameLanguage.EN), 0, 0, 1, true);
|
||||
String initial = cache.statusText();
|
||||
|
||||
assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 1, 1, false));
|
||||
String changed = cache.statusText();
|
||||
assertNotSame(initial, changed);
|
||||
assertTrue(changed.contains("Signals 1/1"));
|
||||
assertTrue(changed.contains("Volunteer respawning"));
|
||||
assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.EN), 1, 1, 1, false));
|
||||
|
||||
assertTrue(cache.update(GameStrings.forLanguage(GameLanguage.RU), 1, 1, 1, false));
|
||||
assertTrue(cache.statusText().contains("Сигналы 1/1"));
|
||||
assertFalse(cache.update(GameStrings.forLanguage(GameLanguage.RU), 1, 1, 1, false));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class AccessibilityStatusTest {
|
||||
@Test
|
||||
public void criticalGameplayStateIsRepresentedByTextNotOnlyColorOrVibration() {
|
||||
assertEquals(
|
||||
"Signals 1/2 · Doors 0/1 · Volunteer respawning",
|
||||
AccessibilityStatus.describe(1, 2, 0, 1, false)
|
||||
);
|
||||
assertEquals(
|
||||
"Signals 0/0 · Doors 0/0 · Volunteer active",
|
||||
AccessibilityStatus.describe(0, 0, 0, 0, true)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public final class CompletionResultTest {
|
||||
@Test
|
||||
public void resultKeepsThisRunAndSavedBestSeparateAndSuggestsTheMissingTarget() {
|
||||
LevelDefinition level = LevelCatalog.get(0);
|
||||
|
||||
CompletionResult result = CompletionResult.create(
|
||||
level,
|
||||
2,
|
||||
42_000L,
|
||||
0,
|
||||
25_000L,
|
||||
true
|
||||
);
|
||||
|
||||
assertEquals(2, result.currentDeaths());
|
||||
assertEquals(42_000L, result.currentTimeMs());
|
||||
assertEquals(0, result.bestDeaths());
|
||||
assertEquals(25_000L, result.bestTimeMs());
|
||||
assertEquals(CompletionResult.NextGoal.TARGET_DEATHS, result.nextGoal());
|
||||
assertEquals(0, result.nextGoalValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void masteredLevelPointsAtTheNextLevelOrPersonalBest() {
|
||||
LevelDefinition first = LevelCatalog.get(0);
|
||||
CompletionResult timeTarget = CompletionResult.create(first, 0, 31_000L,
|
||||
0, 25_000L, true);
|
||||
assertEquals(CompletionResult.NextGoal.TARGET_TIME, timeTarget.nextGoal());
|
||||
assertEquals(30_000L, timeTarget.nextGoalValue());
|
||||
|
||||
CompletionResult next = CompletionResult.create(first, 0, 25_000L,
|
||||
0, 25_000L, true);
|
||||
assertEquals(CompletionResult.NextGoal.NEXT_LEVEL, next.nextGoal());
|
||||
assertEquals(2, next.nextGoalValue());
|
||||
|
||||
LevelDefinition last = LevelCatalog.get(LevelCatalog.all().size() - 1);
|
||||
CompletionResult lastResult = CompletionResult.create(last,
|
||||
last.targetDeaths, last.targetSeconds * 900L,
|
||||
last.targetDeaths, last.targetSeconds * 900L, false);
|
||||
assertEquals(CompletionResult.NextGoal.PERSONAL_BEST, lastResult.nextGoal());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public final class GameStringsTest {
|
||||
@Test
|
||||
public void missingOrUnknownLanguageSafelyFallsBackToEnglish() {
|
||||
assertEquals(GameLanguage.EN, GameLanguage.fromCode(null));
|
||||
assertEquals(GameLanguage.EN, GameLanguage.fromCode("de"));
|
||||
|
||||
GameStrings strings = GameStrings.forLanguage(GameLanguage.fromCode("de"));
|
||||
assertEquals("Settings", strings.settingsButton());
|
||||
assertEquals("The Volunteer", strings.levelTitle(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void russianPackLocalizesTheCompletePlayerFacingContract() {
|
||||
GameStrings english = GameStrings.forLanguage(GameLanguage.EN);
|
||||
GameStrings russian = GameStrings.forLanguage(GameLanguage.RU);
|
||||
|
||||
assertEquals("Настройки", russian.settingsButton());
|
||||
assertEquals("Размер управления", russian.controlSizeSetting());
|
||||
assertEquals("Русский", russian.languageName());
|
||||
assertEquals("Рыцарь-доброволец", russian.levelTitle(1));
|
||||
assertEquals("Последнее шествие", russian.levelTitle(10));
|
||||
assertEquals("Добровольцы 2 Время 00:07", russian.hudStats(2, 7_000L));
|
||||
assertEquals("✓ 2 добровольца · 00:07", russian.levelBestStats(2, 7_000L));
|
||||
assertEquals("★ 2 / 3 добровольца", russian.completionDeaths(true, 2, 3));
|
||||
assertEquals("Смерть — это инструмент", russian.tutorialWelcomeTitle());
|
||||
assertFalse(russian.tutorialSacrificeBody().trim().isEmpty());
|
||||
|
||||
for (int level = 1; level <= 10; level++) {
|
||||
assertFalse(russian.levelTitle(level).trim().isEmpty());
|
||||
assertFalse(russian.levelHint(level).trim().isEmpty());
|
||||
assertNotEquals(english.levelTitle(level), russian.levelTitle(level));
|
||||
assertNotEquals(english.levelHint(level), russian.levelHint(level));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.projectsacrifice.game;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
@@ -68,6 +69,41 @@ public final class LevelPlaytestBotTest {
|
||||
assertTrue(result.diagnostic(), result.diagnostic().contains("allDoorsOpen=false"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void slowRenderingUsesSimulationElapsedBudgetsInsteadOfHostTime() {
|
||||
SlowRenderingDriver driver = new SlowRenderingDriver(true);
|
||||
LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario
|
||||
.builder(1, "Slow rendering")
|
||||
.step("Reach simulated checkpoint", 200L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 500L);
|
||||
|
||||
assertTrue(result.diagnostic(), result.completed());
|
||||
assertTrue("The host clock must be allowed to outrun simulated gameplay",
|
||||
driver.wallMillis > 500L);
|
||||
assertEquals(96L, driver.simulationMillis);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void frozenSimulationStopsAtWallClockWatchdogWithDiagnostic() {
|
||||
SlowRenderingDriver driver = new SlowRenderingDriver(false);
|
||||
LevelPlaytestBot.Scenario scenario = LevelPlaytestBot.Scenario
|
||||
.builder(1, "Frozen rendering")
|
||||
.step("Wait for simulation", 1_000L, LevelPlaytestBot.Input.right(),
|
||||
GameView.AutomationSnapshot::completed)
|
||||
.build();
|
||||
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().run(driver, scenario, 5_000L);
|
||||
|
||||
assertFalse(result.completed());
|
||||
assertEquals("Wait for simulation", result.lastStep());
|
||||
assertTrue(result.diagnostic(), result.diagnostic().contains("wall-clock watchdog"));
|
||||
assertTrue(result.diagnostic(), result.diagnostic().contains("simulationElapsed=0"));
|
||||
assertTrue(result.diagnostic(), result.diagnostic().contains("wallElapsed=120000"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void levelFiveUsesTheGenericNamedScenario() {
|
||||
LevelFiveDriver driver = new LevelFiveDriver();
|
||||
@@ -82,6 +118,17 @@ public final class LevelPlaytestBotTest {
|
||||
assertTrue(result.doorOpened());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void levelFiveDelayedDecisionStillClearsTheSawWithOneVolunteer() {
|
||||
LevelFiveDriver driver = new LevelFiveDriver(true);
|
||||
|
||||
LevelPlaytestBot.Result result = new LevelPlaytestBot().proveLevelFive(driver, 25_000L);
|
||||
|
||||
assertTrue(result.diagnostic(), result.completed());
|
||||
assertEquals(1, result.deaths());
|
||||
assertEquals(1, result.corpseCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void automationSnapshotDefaultsToNoMovingPlatform() {
|
||||
GameView.AutomationSnapshot snapshot = new GameView.AutomationSnapshot(
|
||||
@@ -90,6 +137,41 @@ public final class LevelPlaytestBotTest {
|
||||
|
||||
assertTrue(Float.isNaN(snapshot.firstMovingPlatformY()));
|
||||
assertFalse(snapshot.playerOnMovingPlatform());
|
||||
assertFalse(snapshot.firstMovingPlatformMovingDown());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void groundedApproachWaitsForAliveGroundedPlayerAtCheckpoint() {
|
||||
LevelPlaytestBot.SnapshotCondition checkpoint =
|
||||
LevelPlaytestBot.groundedAliveAtOrBeyond(280f);
|
||||
|
||||
assertFalse(checkpoint.reached(snapshot(true, true, 279f, 1)));
|
||||
assertFalse(checkpoint.reached(snapshot(false, true, 280f, 1)));
|
||||
assertFalse(checkpoint.reached(snapshot(true, false, 280f, 1)));
|
||||
assertTrue(checkpoint.reached(snapshot(true, true, 280f, 1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void expectedDeathGuardFailsBeforeAnAccidentalRetry() {
|
||||
LevelPlaytestBot.SnapshotCondition guarded = LevelPlaytestBot.whileDeathsAtMost(
|
||||
1, snapshot -> snapshot.playerX() >= 280f);
|
||||
|
||||
assertTrue(guarded.reached(snapshot(true, false, 320f, 1)));
|
||||
try {
|
||||
guarded.reached(snapshot(false, false, 470f, 2));
|
||||
fail("An accidental second death must fail the route immediately");
|
||||
} catch (AssertionError expected) {
|
||||
assertTrue(expected.getMessage(), expected.getMessage().contains("expected at most 1"));
|
||||
assertTrue(expected.getMessage(), expected.getMessage().contains("observed 2"));
|
||||
}
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot snapshot(
|
||||
boolean alive, boolean onGround, float x, int deaths) {
|
||||
return new GameView.AutomationSnapshot(
|
||||
false, alive, false, false, false,
|
||||
10, 0L, x, 760f, deaths, deaths,
|
||||
Float.NaN, Float.NaN, false, false, Float.NaN, onGround);
|
||||
}
|
||||
|
||||
private static final class ScriptedDriver implements LevelPlaytestBot.Driver {
|
||||
@@ -160,7 +242,52 @@ public final class LevelPlaytestBotTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static final class SlowRenderingDriver implements LevelPlaytestBot.Driver {
|
||||
private final boolean advancesSimulation;
|
||||
private long wallMillis;
|
||||
private long simulationMillis;
|
||||
private boolean movingRight;
|
||||
private boolean completed;
|
||||
|
||||
SlowRenderingDriver(boolean advancesSimulation) {
|
||||
this.advancesSimulation = advancesSimulation;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
wallMillis = 0L;
|
||||
simulationMillis = 0L;
|
||||
movingRight = false;
|
||||
completed = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInput(boolean moveLeft, boolean moveRight, boolean jump) {
|
||||
movingRight = moveRight;
|
||||
}
|
||||
|
||||
@Override
|
||||
public GameView.AutomationSnapshot snapshot() {
|
||||
return new GameView.AutomationSnapshot(
|
||||
completed, true, false, false, movingRight,
|
||||
1, simulationMillis, 130f, 760f, 0, 0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void waitForNextDecision(long millis) {
|
||||
wallMillis += advancesSimulation ? 1_000L : 60_000L;
|
||||
if (advancesSimulation) simulationMillis += millis;
|
||||
if (movingRight && simulationMillis >= 96L) completed = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long currentTimeMillis() {
|
||||
return wallMillis;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class LevelFiveDriver implements LevelPlaytestBot.Driver {
|
||||
private final boolean overshootApproach;
|
||||
private int startedLevel;
|
||||
private long nowMillis;
|
||||
private boolean moveRight;
|
||||
@@ -171,6 +298,16 @@ public final class LevelPlaytestBotTest {
|
||||
private int deaths;
|
||||
private int corpses;
|
||||
private float x = 130f;
|
||||
private float y = 760f;
|
||||
private boolean sawTakeoffObserved;
|
||||
|
||||
LevelFiveDriver() {
|
||||
this(false);
|
||||
}
|
||||
|
||||
LevelFiveDriver(boolean overshootApproach) {
|
||||
this.overshootApproach = overshootApproach;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLevel(int oneBasedLevelNumber) {
|
||||
@@ -187,7 +324,7 @@ public final class LevelPlaytestBotTest {
|
||||
public GameView.AutomationSnapshot snapshot() {
|
||||
return new GameView.AutomationSnapshot(
|
||||
completed, alive, doorOpen, false, moveRight || jump,
|
||||
startedLevel, nowMillis, x, 760f, deaths, corpses);
|
||||
startedLevel, nowMillis, x, y, deaths, corpses);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -196,6 +333,7 @@ public final class LevelPlaytestBotTest {
|
||||
if (!alive) {
|
||||
alive = true;
|
||||
x = 130f;
|
||||
y = 760f;
|
||||
} else if (deaths == 0 && moveRight && !jump) {
|
||||
deaths = 1;
|
||||
corpses = 1;
|
||||
@@ -203,11 +341,24 @@ public final class LevelPlaytestBotTest {
|
||||
doorOpen = true;
|
||||
x = 570f;
|
||||
} else if (moveRight && jump) {
|
||||
if (x < 750f) {
|
||||
sawTakeoffObserved = true;
|
||||
y = 690f;
|
||||
}
|
||||
x += 60f;
|
||||
} else if (moveRight && x < 390f) {
|
||||
x = 400f;
|
||||
x = overshootApproach ? 440f : 400f;
|
||||
} else if (moveRight && x < 750f) {
|
||||
if (overshootApproach && !sawTakeoffObserved) {
|
||||
deaths++;
|
||||
corpses++;
|
||||
alive = false;
|
||||
x = 510f;
|
||||
y = 780f;
|
||||
} else {
|
||||
x = 760f;
|
||||
y = 760f;
|
||||
}
|
||||
} else if (moveRight && x < 1_390f) {
|
||||
x = 1_500f;
|
||||
} else if (moveRight) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class MovingPlatformLaunchPolicyTest {
|
||||
@Test
|
||||
public void boardingWaitsForThePoweredOutboundPhaseNearTheLeftFloor() {
|
||||
assertFalse(MovingPlatformLaunchPolicy.readyToBoard(
|
||||
snapshot(1_000f, false, true, true, false), 1_050f));
|
||||
assertFalse(MovingPlatformLaunchPolicy.readyToBoard(
|
||||
snapshot(1_000f, false, true, false, true), 1_050f));
|
||||
assertFalse(MovingPlatformLaunchPolicy.readyToBoard(
|
||||
snapshot(1_100f, false, true, true, true), 1_050f));
|
||||
assertTrue(MovingPlatformLaunchPolicy.readyToBoard(
|
||||
snapshot(1_000f, false, true, true, true), 1_050f));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void launchRequiresStablePoweredSupportInTheLaunchZone() {
|
||||
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(910f, true, true, true, true), 1_150f));
|
||||
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, false, true, true, true), 1_150f));
|
||||
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, false, true, true), 1_150f));
|
||||
assertFalse(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, false, true), 1_150f));
|
||||
assertTrue(MovingPlatformLaunchPolicy.ready(snapshot(1_150f, true, true, true, true), 1_150f));
|
||||
}
|
||||
|
||||
private static GameView.AutomationSnapshot snapshot(
|
||||
float platformX, boolean supported, boolean grounded, boolean powered,
|
||||
boolean movingRight
|
||||
) {
|
||||
return new GameView.AutomationSnapshot(
|
||||
false, true, true, false, false, 8,
|
||||
4_000L, 1_100f, 670f, 0, 0,
|
||||
platformX, 770f, supported, powered, movingRight, Float.NaN, grounded
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class OnboardingFlowTest {
|
||||
@Test
|
||||
public void firstRunExplainsControlsAndCorpsesThenStaysDismissed() {
|
||||
OnboardingFlow flow = OnboardingFlow.firstRun(false);
|
||||
|
||||
assertTrue(flow.isVisible());
|
||||
assertEquals(OnboardingFlow.Step.MOVEMENT, flow.step());
|
||||
|
||||
flow.next();
|
||||
assertEquals(OnboardingFlow.Step.MULTITOUCH_JUMP, flow.step());
|
||||
|
||||
flow.next();
|
||||
assertEquals(OnboardingFlow.Step.CORPSES_ARE_TOOLS, flow.step());
|
||||
|
||||
flow.next();
|
||||
assertFalse(flow.isVisible());
|
||||
assertTrue(flow.isComplete());
|
||||
assertFalse(OnboardingFlow.firstRun(flow.isComplete()).isVisible());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void firstRunCanBeSkippedImmediately() {
|
||||
OnboardingFlow flow = OnboardingFlow.firstRun(false);
|
||||
|
||||
flow.skip();
|
||||
|
||||
assertFalse(flow.isVisible());
|
||||
assertTrue(flow.isComplete());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.projectsacrifice.game;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class PlaytestSimulationBudgetTest {
|
||||
@Test
|
||||
public void slowRenderingUsesSimulationBudgetWithASeparateWallWatchdog() {
|
||||
PlaytestSimulationBudget budget = PlaytestSimulationBudget.start(0L, 0L, 500L);
|
||||
|
||||
assertFalse("Host time may outrun active simulation", budget.exhausted(96L, 1_000L));
|
||||
assertTrue("Simulation budget remains authoritative", budget.exhausted(500L, 5_000L));
|
||||
|
||||
PlaytestSimulationBudget frozen = PlaytestSimulationBudget.start(0L, 0L, 5_000L);
|
||||
assertFalse(frozen.exhausted(0L, 119_999L));
|
||||
assertTrue("Frozen rendering stops at the same watchdog as the generic bot",
|
||||
frozen.exhausted(0L, 120_000L));
|
||||
}
|
||||
}
|
||||
+14
-3
@@ -6,7 +6,7 @@
|
||||
|
||||
## 1. Текущее состояние
|
||||
|
||||
Инженерная часть этапа 2 завершена; этап 3 начат с настроек мобильного управления:
|
||||
Инженерная часть этапов 2 и 3 завершена:
|
||||
|
||||
- реализованы 10 последовательных уровней;
|
||||
- работают движение, прыжок и мобильное multitouch-управление;
|
||||
@@ -19,6 +19,12 @@
|
||||
- тело, доставленное пилой на `PRESSURE` уровня 10, фиксируется после оседания и больше не соскальзывает с плиты;
|
||||
- restart из промежуточного состояния уровня 10 очищает тела, сигналы, таймер и удерживаемый ввод;
|
||||
- Settings screen сохраняет размер и opacity touch-controls и режим вибрации; три form-factor контракта и защита от повторного held-jump покрыты device-тестами;
|
||||
- добавлены сохраняемые Standard/Large text и high-contrast режимы, видимый статус сигналов/дверей/respawn и локализованные content descriptions;
|
||||
- короткое трёхшаговое обучение не блокирует gameplay и после Skip/Done не показывается повторно;
|
||||
- экран результата разделяет текущий и лучший результаты, показывает targets, следующую цель и рабочие Level select/Replay/Next level переходы;
|
||||
- весь Canvas UI, десять названий и подсказок доступны на русском и английском; язык хранится отдельно и не меняет progress/control settings;
|
||||
- Stage 3 focused matrix: 14/14 методов без retries; UI contracts GREEN на 20:9, 16:9 и логическом 16:10 tablet viewport;
|
||||
- final Stage 3 Android 16 matrix: `OK (30 tests)` без filters/retries за `1,409.366 s`; все intended routes 1–10, recovery/stress, lifecycle, restart и UI/persistence contracts GREEN;
|
||||
- final integrated Android 16 matrix: 16/16 методов, 36 игровых выполнений, lifecycle и restart; wall time `9:32.496`;
|
||||
- финальный debug APK проверен на Android 16: установка, холодный запуск, ZIP-структура и v1/v2-подписи корректны.
|
||||
|
||||
@@ -176,10 +182,15 @@ Performance-оговорка: исторический критерий этап
|
||||
8. холодный запуск и проверка crash-буфера;
|
||||
9. ручной smoke-тест уровня 1 и одного сложного комбинированного уровня.
|
||||
|
||||
## 5. Завершение этапа 2 и следующая итерация
|
||||
## 5. Завершение этапов 2–3 и следующая итерация
|
||||
|
||||
Инженерный scope этапа 2 закрыт: уровни 1–10 проходят intended route по три раза, уровень 8 имеет restart-free recovery, уровень 10 использует все три тела и сохраняет тело на lift-плите, а restart возвращает чистое состояние. Передаваемые результаты — обновлённый проект, проверенный APK и [`STAGE2_QA_REPORT.md`](STAGE2_QA_REPORT.md).
|
||||
|
||||
Этап 3 начат задачей `goon-game-controls-settings`: добавлены сохраняемые пресеты размера/opacity controls и вибрации, фактическое расширение touch target и защита от повторного прыжка при удержании. Проверки выполнены на 20:9, логическом 16:9 и tablet-sized экране; подробности находятся в [`STAGE3_CONTROLS_QA.md`](STAGE3_CONTROLS_QA.md). Следующие slices этапа 3 — accessibility, обучение/result screen и RU/EN локализация.
|
||||
Этап 3 завершён: к сохраняемым пресетам управления добавлены accessibility,
|
||||
неблокирующее обучение, полный result flow и RU/EN локализация. Совместный
|
||||
device-gate проверяет сохранность progress/settings, а UI contracts выполнены на
|
||||
20:9, 16:9 и tablet-sized экране. Подробный итог находится в
|
||||
[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md). Следующая инженерная итерация — этап
|
||||
4, перенос описаний уровней в валидируемый формат и инструменты контента.
|
||||
|
||||
Параллельно внешний QA должен выполнить `goon-game-human-playtest`: по две ручные сессии каждого уровня без подсказок разработчика. До получения этого протокола milestone Vertical Slice нельзя называть принятым внешними тестировщиками.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Stage 3 accessibility QA
|
||||
|
||||
Дата: 18 августа 2026 года. Slice: масштаб текста, high contrast, non-audio state
|
||||
и семантика Canvas-экранов.
|
||||
|
||||
## Реализованный контракт
|
||||
|
||||
- Settings содержит сохраняемые пресеты `Text size: Standard/Large`
|
||||
(`1.0×/1.25×`) и `High contrast: Off/On`;
|
||||
- настройки хранятся отдельно от control settings и progress, поэтому миграция не
|
||||
меняет существующие сохранения;
|
||||
- оба общих Canvas text helper применяют выбранный масштаб ко всему тексту;
|
||||
viewport и hit mapping остаются неизменными, а фиксированные HUD/card/button
|
||||
области обрезают переполнение внутри своих границ;
|
||||
- high contrast использует чёрный фон, непрозрачные кнопки, белые контуры и
|
||||
бело-жёлтый текст; активные механизмы не различаются только цветом;
|
||||
- `ON/OFF`, `UP/DOWN`, `OPEN/LOCKED` и постоянная строка
|
||||
`Signals · Doors · Volunteer` дают видимое представление критического состояния
|
||||
при выключенной вибрации;
|
||||
- content description меняется для level select, settings, playing, pause и
|
||||
complete; системный Back сохраняет прежний детерминированный контракт;
|
||||
- read-only automation surface: `textScaleForAutomation()`,
|
||||
`highContrastForAutomation()`, `screenDescriptionForAutomation()`,
|
||||
`criticalVisibleStatusForAutomation()` и `safeViewportForAutomation()`.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
- RED: `AccessibilitySettings` отсутствовал, focused JVM-test завершался шестью
|
||||
`cannot find symbol`;
|
||||
- GREEN: `Standard/Large`, `Off/On` и безопасное восстановление persisted index;
|
||||
- RED → GREEN отдельными циклами: `highContrastEnabled`, `restore`,
|
||||
`canvasTextSize`, `AccessibilityStatus.describe` и
|
||||
`AccessibilityScreenDescription`;
|
||||
- RED device compile: отсутствовали persisted/public Settings surface,
|
||||
screen transition и safe-viewport hooks;
|
||||
- GREEN device compile: пять focused Android acceptance методов компилируются и
|
||||
проверяют recreation, реальные Settings taps, screen/Back semantics,
|
||||
неизменный safe viewport и textual state при `Vibration: Off`.
|
||||
|
||||
## Проверки этого isolated slice
|
||||
|
||||
- focused accessibility JVM tests — GREEN;
|
||||
- все JVM tests — обязательный gate перед коммитом;
|
||||
- `lintDebug`, `assembleDebug`, `compileDebugAndroidTestJavaWithJavac` — обязательный
|
||||
gate перед коммитом;
|
||||
- device execution намеренно оставлен финальной интеграционной ветке: этот stream
|
||||
не запускает и не меняет общий emulator.
|
||||
|
||||
## Интеграционные замечания
|
||||
|
||||
- `GameView` одновременно изменяется onboarding/result и localization slices;
|
||||
при merge нужно сохранить единые text helpers и вызывать refresh semantics после
|
||||
всех новых screen transitions;
|
||||
- localized строки должны заменить английский текст и в Canvas, и в content
|
||||
descriptions одним источником, не удаляя read-only acceptance hooks;
|
||||
- minSdk 23, control settings, progress format и Stage 2 physics/levels не менялись.
|
||||
|
||||
## Финальная интеграция
|
||||
|
||||
Все пять device-методов выполнены в составе unfiltered Android 16 gate:
|
||||
`OK (30 tests)`, `1,409.366 s`. Итоговая сборка и form-factor evidence описаны в
|
||||
[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md).
|
||||
@@ -0,0 +1,65 @@
|
||||
# Stage 3 RU/EN localization QA
|
||||
|
||||
Date: 2026-08-18
|
||||
Scope: persistent, explicit English/Russian language selection for the Canvas UI.
|
||||
|
||||
## Delivered contract
|
||||
|
||||
- English is the default for missing, null, or unknown stored language codes.
|
||||
- The Settings screen exposes a dedicated English/Russian switch without moving the
|
||||
existing control-size, opacity, vibration, or Back touch targets.
|
||||
- The selected language is stored separately from progress and control preferences
|
||||
and is restored when `MainActivity` is recreated.
|
||||
- `GameStrings` owns all player-facing copy: level select, settings, HUD, respawn,
|
||||
pause, completion, buttons, statistics, ten level titles and hints.
|
||||
- Result-flow and onboarding copy has typed RU/EN hooks ready for the other Stage 3
|
||||
slices; new Canvas code does not need language conditionals.
|
||||
- Long localized hints, card titles, and best-result summaries are fitted to their
|
||||
existing Canvas bounds. Gameplay geometry and Android `minSdk 23` are unchanged.
|
||||
- Automation exposes current/set/toggle language and a read-only snapshot of the
|
||||
localized copy rendered by the current screen.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
1. RED: the fallback test failed compilation with seven missing `GameLanguage` /
|
||||
`GameStrings` symbols. GREEN: English default and safe unknown/null fallback.
|
||||
2. RED: the complete Russian-pack test failed compilation with ten missing copy
|
||||
methods. GREEN: complete RU pack, ten titles/hints, statistics, plural forms,
|
||||
result hooks, and onboarding hooks.
|
||||
3. RED: the persistence device test failed compilation with four missing GameView
|
||||
language hooks. GREEN: Settings toggle plus a dedicated preference store.
|
||||
4. RED: the integrated acceptance extension failed compilation with three missing
|
||||
setter/snapshot hooks. GREEN: explicit language setter and current-screen copy
|
||||
snapshot.
|
||||
|
||||
## Automated checks
|
||||
|
||||
| Gate | Result |
|
||||
| --- | --- |
|
||||
| `testDebugUnitTest` | GREEN, 22/22 JVM tests |
|
||||
| `lintDebug` | GREEN |
|
||||
| `assembleDebug` | GREEN |
|
||||
| `compileDebugAndroidTestJavaWithJavac` | GREEN, including `GameLanguagePersistenceTest` |
|
||||
| Canvas raw-copy audit | GREEN; only language-neutral `II` and numeric formats remain in draw calls |
|
||||
|
||||
`GameLanguagePersistenceTest` is compiled here but intentionally not executed in
|
||||
this isolated stream because emulator ownership belongs to final Stage 3 integration.
|
||||
It performs real Settings taps and verifies Russian visible-copy snapshot, Activity
|
||||
recreation, progress, control size, opacity, and vibration preservation.
|
||||
|
||||
## Integration notes
|
||||
|
||||
- `GameView.java` will conflict with accessibility/onboarding/result branches. Keep
|
||||
their UI behavior and route every new visible string through the existing
|
||||
`GameStrings` result/onboarding hooks.
|
||||
- `ControlSettings.java` is not modified; localized preset labels are derived from
|
||||
its stable package-visible preset indexes.
|
||||
- `LevelCatalog.java` and level geometry are not modified. Canvas rendering now uses
|
||||
`GameStrings.levelTitle/levelHint`; the existing English fields remain compatible
|
||||
with gameplay tests and older callers.
|
||||
|
||||
## Final integration
|
||||
|
||||
`GameLanguagePersistenceTest` and the combined Stage 3 RU/EN acceptance tests passed
|
||||
inside the unfiltered Android 16 gate: `OK (30 tests)`, `1,409.366 s`. See
|
||||
[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md) for the release evidence.
|
||||
@@ -0,0 +1,64 @@
|
||||
# Stage 3 onboarding and result QA
|
||||
|
||||
Дата: 18 августа 2026 года. Базовая сборка: `0.5.0-stage3-controls-debug`
|
||||
(`minSdk=23`). Этот документ описывает независимый slice обучения и результата;
|
||||
версия финального Stage 3 APK будет назначена после интеграции всех веток.
|
||||
|
||||
## Реализовано
|
||||
|
||||
- при первом входе в уровень показывается компактная карточка из трёх подсказок:
|
||||
движение, одновременные движение и прыжок двумя пальцами, использование тел;
|
||||
- карточка не останавливает физику и не отключает экранное управление;
|
||||
- доступны `Skip`, пошаговый `Next` и финальный `Done`;
|
||||
- закрытие сохраняется в отдельном versioned storage
|
||||
`project_sacrifice_onboarding_v1` и не изменяет progress/control settings;
|
||||
- результат разделяет показатели текущего прохождения и сохранённые лучшие время
|
||||
и число добровольцев;
|
||||
- результат показывает оба target и выбирает понятную следующую цель: улучшить
|
||||
число добровольцев, время, перейти на следующий уровень либо улучшить personal best;
|
||||
- `Level select`, `Replay` и `Next level` используют общий navigation contract и
|
||||
не очищают прогресс или настройки управления.
|
||||
|
||||
## TDD evidence
|
||||
|
||||
- `OnboardingFlowTest`: RED — `cannot find symbol: class OnboardingFlow`; GREEN — 2/2;
|
||||
- `CompletionResultTest`: RED — `cannot find symbol: class CompletionResult`; GREEN — 2/2;
|
||||
- `OnboardingStoreTest`: RED — `cannot find symbol: class OnboardingStore`; после
|
||||
реализации AndroidTest compile GREEN;
|
||||
- `OnboardingAndResultFlowTest`: RED — отсутствовали публичные GameView contracts
|
||||
обучения/result navigation; после интеграции AndroidTest compile GREEN.
|
||||
|
||||
## Выполненные проверки
|
||||
|
||||
```text
|
||||
./gradlew testDebugUnitTest lintDebug assembleDebug compileDebugAndroidTestJavaWithJavac
|
||||
BUILD SUCCESSFUL
|
||||
```
|
||||
|
||||
- JVM: 24/24, failures=0, errors=0;
|
||||
- `lintDebug`: GREEN;
|
||||
- `assembleDebug`: GREEN;
|
||||
- все Android instrumentation sources: compile GREEN;
|
||||
- `git diff --check`: GREEN.
|
||||
|
||||
По ограничению stream-задачи emulator/connected tests в этой ветке не запускались.
|
||||
После интеграции необходимо выполнить `OnboardingStoreTest` и
|
||||
`OnboardingAndResultFlowTest` на Android 16 вместе с полным Stage 2 regression gate.
|
||||
|
||||
## Приёмка на устройстве после интеграции
|
||||
|
||||
1. Очистить данные приложения, открыть уровень 1 и убедиться, что карточка не
|
||||
мешает одновременно удерживать направление и нажимать Jump.
|
||||
2. Проверить отдельно `Skip` и полный путь `Next` → `Next` → `Done`, затем cold
|
||||
launch: обучение не должно появиться повторно.
|
||||
3. Завершить уровень хуже сохранённого результата: `This run` не должен подменять
|
||||
`Saved best`.
|
||||
4. По очереди проверить `Level select`, `Replay`, `Next level`; завершённость
|
||||
уровня, unlocked level и control preset должны сохраниться.
|
||||
|
||||
## Финальная интеграция
|
||||
|
||||
`OnboardingStoreTest`, оба `OnboardingAndResultFlowTest` и объединённый RU/EN
|
||||
acceptance прошли в составе unfiltered Android 16 gate: `OK (30 tests)`,
|
||||
`1,409.366 s`. Релизная версия назначена `0.6.0-stage3-complete`; общий отчёт:
|
||||
[`STAGE3_QA_REPORT.md`](STAGE3_QA_REPORT.md).
|
||||
@@ -0,0 +1,93 @@
|
||||
# Stage 3 QA report
|
||||
|
||||
Дата: 18 августа 2026 года.
|
||||
|
||||
Версия: `0.6.0-stage3-complete` (`versionCode=7`).
|
||||
|
||||
## Результат
|
||||
|
||||
Инженерный scope этапа 3 завершён. В общую сборку интегрированы:
|
||||
|
||||
- сохраняемые размер и прозрачность touch-controls и режим вибрации;
|
||||
- Standard/Large text, high contrast, видимые состояния сигналов, дверей и respawn;
|
||||
- локализованные accessibility descriptions;
|
||||
- полная RU/EN локализация Canvas UI, десяти названий и подсказок уровней;
|
||||
- неблокирующее трёхшаговое обучение с постоянным Skip/Done;
|
||||
- result screen с текущим и лучшим результатом, targets, следующей целью и
|
||||
рабочими переходами Levels/Replay/Next;
|
||||
- совместная сохранность языка, accessibility, controls и прогресса после recreate;
|
||||
- state-driven боты уровней 1–10, restart-free recovery уровня 8 и проверка
|
||||
неподвижного тела на lift-плите уровня 10.
|
||||
|
||||
## Автоматические проверки
|
||||
|
||||
Финальный clean gate:
|
||||
|
||||
```text
|
||||
./gradlew clean testDebugUnitTest lintDebug assembleDebug assembleDebugAndroidTest
|
||||
BUILD SUCCESSFUL
|
||||
66 actionable tasks
|
||||
42/42 JVM tests
|
||||
```
|
||||
|
||||
Финальный unfiltered device gate выполнен на холодно запущенном Android 16 AVD,
|
||||
физический размер `1080x2400` (20:9), после установки точных APK clean-сборки и
|
||||
`pm clear com.projectsacrifice.game.debug`:
|
||||
|
||||
```text
|
||||
AndroidJUnitRunner
|
||||
Tests run: 30
|
||||
Time: 1,409.366 s
|
||||
OK (30 tests)
|
||||
```
|
||||
|
||||
Runner запускался без `-e class`, retries и повторного запуска отдельных методов.
|
||||
Матрица включает все intended routes уровней 1–10 по три раза, дополнительные
|
||||
stressed/recovery маршруты уровня 8, throttled mastery и sliding-body regression
|
||||
уровня 10, lifecycle, restart и все Stage 3 persistence/UI contracts.
|
||||
|
||||
До финального объединения UI contracts также прошли на логических viewport:
|
||||
|
||||
- 16:9 safe viewport — GREEN, `66.208 s`;
|
||||
- 16:10 tablet-sized `2560x1600` landscape — GREEN, `167.823 s`;
|
||||
- focused Stage 3 matrix — 14/14 GREEN, `576.355 s`.
|
||||
|
||||
Последующие изменения касались automation/read-only snapshot и не меняли UI
|
||||
геометрию; финальный 20:9 gate повторно проверил весь интегрированный интерфейс.
|
||||
|
||||
## RED → GREEN evidence
|
||||
|
||||
Первый полный 30-тестовый прогон был завершён полностью и дал 27/30 GREEN. Три
|
||||
нагрузочные границы были исправлены без изменения физики уровней или retry:
|
||||
|
||||
1. Level 3: takeoff начинался на несколько пикселей поздно (`x=505.67633`,
|
||||
`y=664.41797`, лишняя смерть). State-driven точка старта получила запас и
|
||||
повторно прошла 3/3 внутри полного load-order.
|
||||
2. Level 6: координатный checkpoint не доказывал опору на frozen block; добавлен
|
||||
обязательный `playerOnGround`, после чего landing route прошёл 3/3.
|
||||
3. Level 10: бот мог прыгнуть на лифт во время его восходящей фазы и упасть в PIT
|
||||
(`x=1942.7063`, deaths=4). Snapshot расширен read-only направлением движения;
|
||||
посадка разрешается на нисходящей фазе, normal 3/3 и throttled route GREEN.
|
||||
|
||||
Level 8 отдельно получил исходящую фазу горизонтальной платформы, безопасное
|
||||
позиционирование под recovery relay и перед финальным прыжком. Финальный suite
|
||||
подтвердил stressed 1/1, intended 3/3 и recovery 3/3 без смерти/restart.
|
||||
|
||||
## Проверка APK
|
||||
|
||||
Финальный `app-debug.apk` проверен как самостоятельный устанавливаемый артефакт:
|
||||
|
||||
- ZIP/APK-структура: `unzip -t` без ошибок;
|
||||
- package: `com.projectsacrifice.game.debug`;
|
||||
- версия: `versionCode=7`, `versionName=0.6.0-stage3-complete-debug`;
|
||||
- `minSdkVersion=23`, `targetSdkVersion=34`;
|
||||
- подпись: `apksigner verify` GREEN, схемы v1 и v2;
|
||||
- SHA-256: `7be25ea96cf0de7ea1930335b3570bd9def93cdb48029e24e4c9c14078e4195d`;
|
||||
- clean install и cold launch на Android 16: `Status: ok`, `LaunchState: COLD`,
|
||||
`TotalTime: 1401 ms`; crash-буфер пуст.
|
||||
|
||||
## Границы приёмки
|
||||
|
||||
Инженерный Stage 3 gate закрыт. Отдельная задача ручного внешнего playtest остаётся
|
||||
открытой: реальные тестировщики должны пройти каждый уровень без подсказок
|
||||
разработчика. Этот внешний протокол не подменяется автоматическими ботами.
|
||||
Reference in New Issue
Block a user