Skip to content

feat: add companion tuning controls and desktop pet visibility toggle - #209

Closed
issachar-vin wants to merge 1 commit into
chattymin:mainfrom
issachar-vin:main
Closed

issachar-vin wants to merge 1 commit into
chattymin:mainfrom
issachar-vin:main

Conversation

@issachar-vin

Copy link
Copy Markdown
Contributor

Four related controls for tuning and inspecting the companion, plus the plumbing each needed.

Difficulty multipliers (Settings)

Two independent multipliers — growth (egg incubation, evolution, graduation) and shop prices — applied live, no restart.

  • Scaling happens at the consumption sites in CompanionStore, not on the constant tables. This matters: graded egg prices derive from a ratio of graduationTotal, so scaling that table in place would let the growth slider silently move shop pricing. Consumption-site scaling keeps the two independent and leaves every existing balance test valid.
  • Persisted in UserDefaults with the other Settings preferences, not in CompanionState. Difficulty is a preference, not save data — keeping it out means save export/import can't carry someone else's difficulty, and SaveTransfer is untouched.
  • Lowering growth re-evaluates evolution and hatching immediately rather than waiting for the next usage poll.
  • RareCandy.xp is deliberately not scaled. Scaling it would cancel against the thresholds and make candy the one thing immune to difficulty.
  • The slider is log-mapped. The range spans several orders of magnitude, where a linear track puts the default at ~5% of the width and makes exactly 1.0 unreachable. Values snap to two significant figures, and a position-space window keeps 100% selectable by drag.

Spawn egg (Settings)

Releases the current companion and pins the next hatch to a chosen species, using the existing pendingHatchID pre-roll — no new hatch path.

  • Hatching still follows normal rules: incubation must be refilled, and shiny/nature/branch still roll at hatch. It pins the species only.
  • Discard semantics match buying an egg: no Pokédex credit, no effect on branch weighting.
  • buyEgg's discard transition is extracted into resetToFreshEgg so both paths share it. Duplicating it invites drift in exactly the easy-to-forget parts (generation bump, representative reconcile) that cause async-overwrite bugs.
  • The base index GraphQL query now carries localized names in the same request, so the picker shows real names for all 328 base species with no extra round trips. 329 flat menu items is unusable, so they're grouped into Gen 1–5 submenus.
  • Snapshots written before that field are treated as expired. Lenient decoding kept them readable but nameless, which would have left the picker showing bare numbers until the 30-day TTL elapsed — decodable is not the same as correct.

Floating pet animation (Settings)

The pet was capped at 0.4s/frame (2.5fps) while sprites run at 10–16fps natively, with sleep-tolerance jitter on top — so it read as stuttering next to the popover, which runs uncapped.

  • The cap is now a toggle, defaulting to smooth. Turning it off restores the original battery cap from the energy work in feat: add floating desktop pet (opt-in, static position, size control) #102.
  • minFrameDelay is included in SpriteView's .task(id:). The frame loop holds the floor captured at start, so without this the setting would appear dead until the next sprite change.
  • Low-power mode still freezes the pet regardless of this setting.

Popover footer

An eye button toggles desktop pet visibility. It reads and writes the same floatingPetEnabled property the Settings switch is bound to, so the two cannot diverge — no mirrored state.

Tests

DifficultyTests (30 tests) covers scaling behaviourally — same token input to stores differing only in multiplier, asserting on state transitions rather than displayed strings — plus slider mapping and egg spawning.

Verified by mutation rather than by passing: stripping the multipliers out of the seams fails 8 tests, and making the species pin a no-op fails 4. That check mattered — the spawn stub returns a fixed line, so one test could have passed vacuously.

The pet frame-floor guard no longer proves the cap is applied now that it's conditional, so the applied branch is covered separately and the original test notes its narrowed scope rather than reading stronger than it is.

docs/reference/defect-log.md records the fps cap becoming user-selectable and the task-id trap, so the documented invariant doesn't go stale.

Notes

  • LocalAdditionalUsageTests.testHermesAcceptsMillisecondStartedAt fails on this branch, but it also fails on a clean main — a timezone-dependent date assertion, unrelated to these changes. Everything else passes (765 tests, 8 skipped), with zero build warnings.
  • Eggs disappear from the Shop while you're in egg state. That's pre-existing (hasActive gate) and unchanged here, but Spawn Egg makes egg state far easier to reach, so it surfaces more often.

한국어

컴패니언을 조절하고 들여다보기 위한 네 가지 컨트롤과, 각각에 필요한 배선을 더한다.

난이도 배율 (설정)

성장(알 인큐베이션·진화·졸업)과 상점 가격, 서로 독립인 두 배율. 재시작 없이 즉시 반영된다.

  • 스케일은 상수표가 아니라 CompanionStore 의 소비 지점에서 곱한다. 이게 중요한 이유: 등급 알 가격이 graduationTotal 의 비율에서 파생되므로, 표를 직접 스케일하면 성장 배율이 상점 가격까지 조용히 끌고 간다. 소비 지점 스케일이라야 두 배율이 독립으로 남고, 기존 밸런스 테스트도 전부 그대로 유효하다.
  • CompanionState 가 아니라 다른 설정값들과 함께 UserDefaults 에 저장한다. 난이도는 진행 상황이 아니라 취향 설정이다 — 세이브에서 빼두면 남의 세이브를 불러올 때 내 난이도가 바뀌는 일이 없고, SaveTransfer 는 아예 건드리지 않는다.
  • 성장 배율을 내리면 다음 사용량 폴링을 기다리지 않고 그 자리에서 진화·부화를 다시 판정한다.
  • RareCandy.xp 는 의도적으로 스케일하지 않는다. 함께 곱하면 임계와 서로 상쇄돼 사탕만 난이도를 안 타게 된다.
  • 슬라이더는 로그 매핑이다. 범위가 여러 자릿수를 넘어서 선형 트랙이면 기본값이 폭의 5% 지점에 몰리고 정확히 1.0 을 고를 수 없다. 값은 유효숫자 2자리로 스냅하고, 위치 기준 창을 둬서 드래그로 100% 를 집을 수 있게 했다.

알 스폰 (설정)

현재 컴패니언을 놓아주고, 기존 pendingHatchID pre-roll 을 이용해 다음 부화 종을 고정한다 — 새 부화 경로를 만들지 않는다.

  • 부화 자체는 평소 규칙 그대로다: 인큐베이션을 다시 채워야 하고, 이로치·성격·분기는 여전히 부화 순간 롤된다. 종만 고정할 뿐이다.
  • 폐기 의미는 알 구매와 동일하다 — 도감에 남지 않고, 분기 가중에도 영향이 없다.
  • buyEgg 의 폐기 전이를 resetToFreshEgg 로 뽑아 두 경로가 공유하게 했다. 복제해 두면 빠뜨리기 쉬운 부분(세대 증가, 대표 종 복구)에서 어긋나고, 그게 비동기 덮어쓰기 결함의 원인이 된다.
  • base 인덱스 GraphQL 쿼리가 같은 요청에 다국어 이름을 함께 실어 온다 — 추가 왕복 없이 328종 전부 실제 이름으로 보인다. 329개를 평면 메뉴에 쏟으면 고를 수 없어서 1~5세대 서브메뉴로 나눴다.
  • 이 필드 이전에 저장된 스냅샷은 만료로 취급한다. 관대 디코딩 덕에 읽히기는 하지만 이름이 없어서, 그대로 두면 30일 TTL 이 끝날 때까지 목록이 번호만 나온다 — 디코딩이 된다는 것과 맞다는 것은 다르다.

플로팅 펫 애니메이션 (설정)

펫은 0.4s/프레임(2.5fps)로 묶여 있었는데 스프라이트 원본은 10~16fps 라, 여기에 sleep tolerance 지터까지 겹쳐 캡이 없는 팝오버 옆에 두면 버벅이는 것처럼 보였다.

  • 이제 캡이 토글이고 기본은 부드러움이다. 끄면 feat: add floating desktop pet (opt-in, static position, size control) #102 에너지 작업에서 온 원래 배터리 캡으로 돌아간다.
  • minFrameDelay 를 SpriteView 의 .task(id:) 에 포함했다. 프레임 루프는 시작 시점의 하한을 들고 돌기 때문에, 이게 없으면 다음 스프라이트 교체 전까지 설정이 안 먹는 것처럼 보인다.
  • 저전력 모드의 정적화는 이 설정과 무관하게 항상 우선한다.

팝오버 푸터

눈 아이콘으로 데스크톱 펫 표시를 토글한다. 설정창 스위치가 묶여 있는 것과 같은 floatingPetEnabled 를 읽고 쓰므로 둘이 어긋날 수 없다 — 별도 미러 상태가 없다.

테스트

DifficultyTests(30개)는 스케일을 동작으로 검증한다 — 배율만 다른 스토어에 같은 토큰을 넣고, 표시 문자열이 아니라 상태 전이를 단언한다. 슬라이더 매핑과 알 스폰도 함께 덮는다.

통과가 아니라 변이(mutation)로 검증했다: 배율을 빼면 8개가, 종 고정을 무력화하면 4개가 깨진다. 이 확인이 실제로 의미가 있었다 — 스폰 스텁은 고정된 라인을 돌려주므로 한 테스트는 그냥 통과해 버릴 수도 있었다.

펫 프레임 하한 가드는 캡이 조건부가 된 지금 "실제로 적용된다"를 더는 증명하지 못한다. 그래서 적용 분기를 따로 덮고, 원래 테스트에는 실제보다 강해 보이지 않도록 범위가 좁아졌음을 명시했다.

docs/reference/defect-log.md 에 fps 캡이 사용자 선택이 된 사실과 task-id 함정을 기록해, 문서에 적힌 불변식이 낡지 않게 했다.

참고

  • LocalAdditionalUsageTests.testHermesAcceptsMillisecondStartedAt 가 이 브랜치에서 실패하지만, 깨끗한 main 에서도 똑같이 실패한다 — 타임존에 의존하는 날짜 단언이고 이번 변경과 무관하다. 그 외에는 전부 통과한다(765개, 8개 skip). 빌드 경고 0.
  • 알 상태에서는 상점에서 알이 사라진다. 이건 기존 동작(hasActive 게이트)이고 여기서 바꾸지 않았지만, 알 스폰 때문에 알 상태에 훨씬 쉽게 들어가게 돼서 더 자주 눈에 띈다.

Adds four related controls for tuning and inspecting the companion, plus
the plumbing each one needed.

Difficulty multipliers (Settings)
- Two independent multipliers: growth (egg incubation, evolution and
  graduation thresholds) and shop prices. Applied at the consumption
  sites in CompanionStore rather than to the constant tables, so the two
  stay independent: graded egg prices derive from a ratio of
  graduationTotal, and scaling that table in place would let the growth
  slider move shop pricing.
- Persisted in UserDefaults alongside the other Settings preferences, not
  in CompanionState. Difficulty is a preference, not save data, so it
  stays out of save export/import and SaveTransfer is untouched.
- Applied live: lowering growth re-evaluates evolution and hatching
  immediately instead of waiting for the next usage poll.
- RareCandy.xp is deliberately not scaled; scaling it would cancel
  against the thresholds and make candy immune to difficulty.
- The slider is log-mapped, since the range spans several orders of
  magnitude and a linear track makes the default unreachable. Values snap
  to two significant figures, and a position-space window keeps 100%
  selectable by drag.

Spawn egg (Settings)
- Releases the current companion and pins the next hatch to a chosen
  species via the existing pendingHatchID pre-roll. Hatching still
  follows the normal rules: incubation must be refilled, and shiny,
  nature and branch still roll at hatch.
- buyEgg's discard transition is extracted into resetToFreshEgg so both
  paths share it; duplicating it invites drift in the easy-to-forget
  parts (generation bump, representative reconcile).
- The base index query now carries localized names in the same request,
  so the picker shows real names without extra round trips. Snapshots
  written before that field are treated as expired: lenient decoding kept
  them readable but nameless, which would have left the list showing bare
  numbers until the 30-day TTL elapsed.

Floating pet animation (Settings)
- The pet was capped at 0.4s per frame (2.5fps) while sprites run at
  10-16fps natively, with sleep tolerance jitter on top, so it read as
  stuttering next to the popover. The cap is now a toggle defaulting to
  smooth; turning it off restores the original battery cap.
- minFrameDelay is included in SpriteView's task id, because the frame
  loop holds the floor captured at start and would otherwise ignore the
  setting until the next sprite change.
- Low-power mode still freezes the pet regardless of this setting.

Popover footer
- An eye button toggles desktop pet visibility, reading and writing the
  same floatingPetEnabled property as the Settings switch, so the two
  cannot diverge.

Tests
- DifficultyTests covers scaling behaviourally (same token input,
  different outcome), slider mapping, and egg spawning. Verified by
  mutation: removing the multipliers or the species pin fails them.
- The pet frame-floor guard no longer proves the cap is applied now that
  it is conditional, so the applied branch is covered separately and the
  original test notes its narrowed scope.
- defect-log.md records the fps cap becoming user-selectable and the
  task-id trap, so the documented invariant does not go stale.
@chattymin

Copy link
Copy Markdown
Owner

Thanks for this, and in particular for how the tests are written — verifying by mutation rather than by passing (stripping the multipliers fails 8 tests, making the species pin a no-op fails 4) is the right instinct, and noting that the spawn stub could have let a test pass vacuously is the kind of detail most PRs don't catch.

I'm closing it, for three separate reasons.

The PR is too large. Four independent controls in one change means I can't take the part I want without the parts I don't.

Spawn egg is a no from me, permanently. Letting the user pick the next species removes the randomness, and the randomness is the appeal. Not knowing what hatches, and a legendary being a rare event rather than a menu choice, is what makes the Pokédex worth filling. Pinning the species keeps the incubation cost but removes the reason to care about it.

The animation toggle is already resolved. #212 landed and went further: the stutter wasn't only the cap, it was that the cap was applied by holding each frame instead of decimating, so a 55-frame sprite played at 1/8 speed. That's fixed, and the cap is now a user-selectable preset. This part of the PR would undo it.

What I would take, as a separate and much smaller PR: the difficulty multipliers. They answer a real request (#226 — light users waiting far too long for progress), and the design is sound — scaling at the consumption sites rather than the constant tables so growth and shop pricing stay independent, keeping the setting in UserDefaults rather than CompanionState so an imported save can't carry someone else's difficulty, and deliberately not scaling RareCandy.xp so candy doesn't become the one thing immune to the setting. The popover eye button is fine too, since it binds to the same property as the Settings switch and can't diverge.

If you open that as its own PR I'll review it promptly. Thanks again for the work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants