Skip to content

Tiro al Trofeo — camera-based AR podium mini-game (+ LAN backend probe, floor data map) - #14

Open
acedward wants to merge 10 commits into
mainfrom
00001-ipp-ar-game
Open

Tiro al Trofeo — camera-based AR podium mini-game (+ LAN backend probe, floor data map)#14
acedward wants to merge 10 commits into
mainfrom
00001-ipp-ar-game

Conversation

@acedward

Copy link
Copy Markdown
Contributor

What this adds

Tiro al Trofeo, a camera-based AR mini-game reachable from the leaderboard's new "Jugar" row — IPP's first ARKit/RealityKit surface. It is a game and nothing else: it reads no clinical record, awards no leaderboard points, and writes nothing but one integer.

  • Placement. Horizontal-plane detection with a coaching overlay; tap a desk or the floor and a procedural podium appears — three steps in the leaderboard's exact gold/silver/bronze medal colours plus a trophy cup. No bundled 3D assets; even the cylinders are MeshDescriptors, because generateCylinder is iOS 18+ and the app targets 17.0.
  • The toss. A ball spawns from where the finger starts the flick (projected through ARView.ray(through:)), aimed where the phone points, with power from flick speed. Fully simulated: gravity, bounce, continuous collision detection, rate limiting and a live-ball cap.
  • Two-tier scoring. Touching the cup = +1, landing inside = +10 (the make absorbs the hit, so a made ball is worth 10, not 11). "Inside" is a geometric fact about the ball's centre in the cup's own frame, held for 0.10 s — not a trigger-volume contact — which is what killed the false-positive makes an earlier device gate found on exterior hits.
  • Rounds. 60 s with a countdown and score HUD, an end-of-round summary with replay, and a device-local best score in UserDefaults (the game's only persistence). The round clock pauses on tracking loss and on backgrounding. Free practice before "Comenzar" throws balls but can never touch a round's score or clock.
  • Difficulty ramp. The cup hops to a different podium step after every make.
  • Dressing. The steps slowly breathe (mesh and collision shape swap together, so balls never float or sink), the top-3 places float above their steps as billboarded labels from a seeded synthetic generator, and a field of dots on the floor maps where the app's records are — pulsing, with a few fading in and out, so it reads as live data.
  • Permissions. The camera is requested the first time the game opens — never at app launch — with a Spanish explanation and a shortcut to Ajustes when denied. On devices without ARKit world tracking (and in the Simulator) the "Jugar" row is disabled with a label saying why; the rest of the app is untouched.

⚠️ One app-wide behaviour change (not breaking)

The backend base URL is now resolved at launch instead of read straight from Info.plist.

localhost is the phone itself, so a device build could never use BackendURL as-is. At launch the app asks each host in the new Info.plist array BackendCandidates for /health (1.5 s each, in order) and points every client — login, patients, field stats, schema, leaderboard, map pins — at the first that answers, falling back to BackendURL when none does (ios/IPP/Services/BackendLocator.swift). In the Simulator the candidate list is skipped entirely and BackendURL is used exactly as before. Pointing a phone at your own Mac is a plist edit, not a source change; an empty or missing array means "only ever use BackendURL".

Requests now await that resolution rather than racing it (AppEnvironment.backendReady()), so nothing can leave with a half-resolved URL — previously a tap into Ranking within the probe window could send one doomed request to localhost and self-heal on refresh. The cost, stated plainly: when nothing is reachable, the first request pays the probe once (bounded ≤ 5 s, asserted by a test that computes the bound from the plist's own candidate count) instead of failing instantly; every later request is unaffected.

This is not a breaking change. It is additive: no API changes, no schema changes, no behaviour change in the Simulator, and the fallback is the previous behaviour.

Testing

270 unit tests, 0 failures, in a new permanent IPPTests target (ios/IPPTests/, wired into the IPP scheme's test action so the command below works on a fresh checkout):

cd ios && xcodegen generate
xcodebuild test -scheme IPP -destination 'platform=iOS Simulator,name=iPhone 17 Pro'

Every suite is offline and deterministic — no test needs a running backend. Coverage is the logic that does not need a camera: the toss rulebook (speed curve, aim basis, spawn clamps, culling, the rim rescue, both scoring tiers), the round state machine, best-score persistence, podium/cup/breathing geometry against the real built entities, the floor-map projection and animation, the backend locator's ordering and plist parsing, and map-pin decoding. The launch-race fix is tested by blocking the probe on an actor gate and asserting the negative — a request that has still not completed after 20 scheduler hops — rather than hoping a race comes out right.

ARKit does not run in the Simulator, so the AR behaviour was verified by the owner on a physical iPhone through a human device gate at the end of every phase (Gates 2, 3, 4, 5, 5B and 5C — placement stability, physics, scoring reliability including deliberate exterior-hit attempts, round/timer/best-score behaviour, tracking-loss and backgrounding, the LAN backend over Wi-Fi, and the live-vs-offline floor map). Each gate's findings were carried forward as tasks in the next phase. The final end-to-end regression pass was waived by the owner in favour of opening this PR.

Scope and safety

  • ios/ only, plus README.md. No backend/, web/ or cardano/ changes.
  • No signing configuration: project.yml still carries only CODE_SIGN_STYLE: Automatic — no DEVELOPMENT_TEAM, no team ID anywhere in the tree. Build for a device with your own team (⌘R after picking it in Signing & Capabilities, or xcodebuild DEVELOPMENT_TEAM=<yours> -allowProvisioningUpdates).
  • No secrets, no .env, no credentials. The only new configuration values are the two private-range LAN hosts in BackendCandidates, which exist so a demo phone can find a demo Mac.
  • The game module makes zero network requests. ios/IPP/Game/ contains no URLSession, no AppEnvironment, no URL literal — the floor map's anonymized pins are fetched by the app layer (GET /api/v1/map-pins, read-only, public) and handed to the game as plain coordinates, with a seeded synthetic array substituted when the backend is unreachable. The game cannot tell the difference and stays fully playable offline; the caption reads "Datos en vivo · N ubicaciones" or "Datos de ejemplo · N ubicaciones" accordingly.
  • New app capability: camera (NSCameraUsageDescription) and local networking (NSLocalNetworkUsageDescription), both in Spanish, both explained in the plist strings.

Docs

README.md gains a section for the mini-game (with a screenshot/GIF placeholder), the launch-time host probe, the xcodebuild test line, and a corrected "intentionally not done" bullet — the old "there is no ARKit/RealityKit overlay" line is no longer true and now reads that the clinical AR remains GPS-anchored while the mini-game is the one camera-based piece.

Follow-up (not in this PR)

Standing up the backend against a local Postgres surfaced three pre-existing defects, unrelated to this feature. They are now documented in README.md ("Running against a local Postgres instead of Neon") but deliberately not fixed here, since one of them touches the production Neon path and none of them belongs in an iOS feature PR:

  1. backend/src/db.ts hardcodes ssl: "require" as a postgres.js client option, which overrides any sslmode in DATABASE_URL and never falls back to plaintext — so a stock docker run postgres:16 fails the handshake.
  2. backend/scripts/seed-cities.ts POSTs /api/v1/patients unsigned and gets 401 missing auth headers on every row; it predates the signed-request auth.
  3. backend/scripts/seed-year.ts still INSERTs the legacy plaintext passcode column, which initSchema no longer creates.

Suggested follow-up issue: "Local-dev path: backend cannot use a plain local Postgres, and both seed scripts are broken."

Phase 1 of the "Tiro al Trofeo" AR podium mini-game: the game is reachable
from the leaderboard on devices that support ARKit world tracking, and is
disabled with a Spanish explanation everywhere else (Simulator, unsupported
hardware). The AR scene itself lands in a later phase.

- ARSupport: world-tracking capability gate plus a read-only camera
  authorization helper. requestCameraAccess() is the only call that can
  raise the system prompt.
- TrophyTossView: Spanish, themed entry screen owning the permission flow —
  asks for the camera when the game opens (never at app launch), renders a
  denied and a restricted state with a shortcut to Ajustes, re-reads the
  answer when the app returns to the foreground, and holds the placeholder
  the AR view will replace.
- LeaderboardView: "Jugar" row (trophy icon) opening the game as a
  fullScreenCover, disabled with an explanatory footer when unsupported.
- Info.plist: Spanish NSCameraUsageDescription scoped to the mini-game.

The game makes no network request and does not touch leaderboard data or
points. No new dependencies; XcodeGen picks up ios/IPP/Game/ with no
project.yml change.
Phase 2 of the Trophy Toss AR mini-game (spec FR-002/FR-003/FR-011, SC-003).

- PodiumBuilder: pure, ARView-free entity assembly. Three box steps at
  12/9/6 cm in the leaderboard's exact gold/silver/bronze medal colors, a
  trophy (base + stem + open cup built from a 12-segment wall ring over a
  floor disc) on the #1 step, an invisible trigger volume in the cup mouth
  for Phase 3 scoring, and an invisible static floor collision plane at
  anchor height. Everything static-collidable so Phase 3 balls bounce.
  MeshResource.generateCylinder is iOS 18+, so the cylinders are generated
  from a MeshDescriptor here — still procedural, still no asset files.

- PodiumARViewContainer: UIViewRepresentable over a RealityKit ARView with
  ARWorldTrackingConfiguration + horizontal plane detection and an
  ARCoachingOverlayView for the scan hint. Tap raycasts to a horizontal
  plane, anchors the podium with AnchorEntity(world:) and turns it to face
  the player; placement locks until Reubicar. Tracking-state, interruption
  and background/foreground handling never reset tracking or drop anchors,
  so the podium keeps its original spot. Full teardown on dismiss.

- TrophyTossView: the readyState placeholder is replaced by the AR view
  plus a minimal Spanish overlay (hint, Reubicar, close).

Verified: xcodebuild simulator build succeeds with zero warnings; the
PodiumBuilder structure was exercised by 7 XCTest cases on an iPhone 17
Pro simulator (temporary target, not committed), all passing, including
PodiumBuilder.selfCheck() reporting no problems.
Phase 3 of the Trophy Toss AR mini-game (spec FR-004/FR-005/FR-006,
SC-002, SC-006), which completes spec User Story 1.

- TossController: the rules of the throw as a pure, ARKit-free type —
  swipe + duration -> clamped launch impulse (camera aim + world-up arc +
  sideways deflection), 0.3 s rate limit, 8-ball cap, scored-once state
  and the culling predicates. Launch speeds (1.6-4.5 m/s) are derived
  from the podium's real geometry; every feel constant is a Tuning
  property so Gate 3 feedback is a one-line edit.
- PodiumBuilder.makeBall: 3.5 cm dynamic sphere in the brand teal, with
  continuous collision detection so a hard throw cannot tunnel through
  the cup wall.
- PodiumARViewContainer: pan-to-throw, balls parented to the podium's own
  anchor (RealityKit simulates physics per anchor), CollisionEvents.Began
  on the cup trigger for scoring, a success haptic plus a trophy pulse,
  and a per-frame culler for balls at rest, out of bounds or over 5 s.
- Person occlusion (.personSegmentationWithDepth where supported), from
  the Gate 2 finding that the podium drew over the player's hand.
- TrophyTossView: session score pill, and a hint that teaches the swipe.

Verified: 39 unit tests green on the iPhone 17 Pro simulator via a
temporary, uncommitted test target; simulator build clean; installed on
a physical iPhone for HUMAN DEVICE GATE 3.
Phase 4 of the Trophy Toss AR mini-game.

Gate 3 findings:
- 4.0a: larger power range. maxLaunchSpeed 4.5 -> 6.8 m/s, re-derived
  including the phone's downward tilt (the loft is added along world up,
  so aiming down eats the launch angle). The flick -> speed mapping is
  now shaped by a new powerCurve = 1.8 exponent, so mid flicks keep the
  0.5-1 m sweet spot and only hard flicks reach the new ceiling.
  fastFlick 2400 -> 2200 pt/s so the ceiling is actually reachable.
- 4.0b: balls can no longer rest on the cup rim and be silently culled.
  The cup wall now flares 15 degrees outward (a shallow cone) with a
  slippery rim, so a rim rest is physically unstable; on top of that a
  ball caught resting on the rim is shoved in a random direction and
  given its time back instead of being removed, up to 3 times.
- 4.0c: placed-state hint now says 'adentro de la copa'.

Rounds (FR-007, FR-008):
- GameRound: pure, tick-driven state machine (idle -> running -> ended),
  60 s rounds, pause reasons for limited tracking and backgrounding so
  paused time is never charged to the player.
- BestScoreStore: UserDefaults-backed single integer, injectable suite.
  The game's only persistence.
- HUD: Comenzar button once the podium is placed, countdown + live score
  during play, end-of-round summary with round score, best score, new
  record badge, Jugar de nuevo / Seguir practicando / Salir. Reubicar is
  disabled while a round runs.
- Free practice before Comenzar still throws balls; those never touch a
  round score or its clock.
Gate 4 defect + the owner's two rule changes, plus the US3 difficulty ramp.

5.0a A make is no longer a sensor contact. The cup's trigger volume is gone;
     a ball counts as inside only when its centre is below the rim, above the
     cup floor and fully within the flared wall at its own height, and stays
     there for 0.10 s. Contact from outside the wall puts the centre ~9 cm from
     the axis, where the rule allows ~2, so a front-wall hit cannot be a make.

5.0b Two tiers: touching the cup anywhere pays +1 once, landing inside pays
     +10 once and absorbs the hit (10 total, not 11). Light impact haptic and a
     small trophy pulse for a hit; the full success haptic and swell for a make.

5.0c The ball spawns under the finger: the swipe's touch-down point is
     projected through ARView.ray(through:) onto the usual spawn plane, clamped
     in depth and sideways offset. Aim and power are unchanged.

US3  After a make the cup slides to a randomly chosen different step over
     0.4 s, on the podium's own anchor; scoring is suspended for the move and
     balls left in the cup leave with it.
…tandings

Phase 5B, from the owner's Gate 5 feedback.

5B.0 (Q4, confirmed at gate row 5-g5) — diagonal flicks launched straight.
ARKit expresses the camera transform in landscape-right axes whatever the
device is doing, so a portrait-locked app reading `columns.0` as "right" was
steering along the phone's long axis. `CameraBasis` now maps the transform
onto the real interface orientation, `CameraBasis.sideAxis` strips whatever
vertical part is left so steering can never trade itself for loft, and the AR
side prefers to *measure* screen-right through `ARView.ray(through:)` — the
same projection the touch-anchored spawn has been using correctly since 5-g3.

5B.1 (FR-012) — the three steps grow and shrink on their own periods and
phases, so the cup's height keeps changing. Nothing is ever scaled: each step
swaps its mesh and its collision shape together for a pre-built pair from a
17-rung ladder, which keeps the collider exactly where the faces are whatever
RealityKit does with entity scale. The breathing clock stops while an
animation owns the trophy, so a celebration freezes the podium and resumes
from the same phase.

5B.2/5B.3/5B.4 (FR-013) — `SyntheticStandings` invents ten unmistakably
fictional Spanish doctors and their scores from a seeded generator, entirely
on device: the game still reads no leaderboard and makes no request. The top
three get billboarded name-and-score labels in their step's medal colour that
ride the breathing steps; places four and down march away and down through
the floor as a Star Wars opening crawl, one recycled text entity per line,
fading over a pre-built material ramp, with no collider anywhere in it.

Verified: 184 unit tests on the iPhone 17 Pro simulator (68 new), simulator
clean build with no new warnings, SC-005 grep still clean.
…podium

Phase 5C. Two things, one at the app layer and one in the game.

App layer (ios/IPP/Services/):
- BackendLocator finds the backend at launch. On device it asks
  192.168.100.15 then 192.168.100.11 for /health and keeps the first
  that answers; in the Simulator it keeps the configured localhost, so
  nothing there changes. The candidate ordering is a pure function and
  is unit-tested; only the probe touches the network.
- AppEnvironment.resolveBackend() runs that probe once, before the
  schema refresh, and re-points the patient store, the anchor client
  and the schema service. The whole app - login, patients, field
  stats, leaderboard - therefore works against the real backend from
  the phone.
- MapPinsService does one read-only GET of /api/v1/map-pins and drops
  everything but the coordinates. Its resolve() is the pure fallback
  rule: an unreachable, empty or unusable answer means the offline
  sample, and the map's own caption says which it got.
- Info.plist gains a Spanish NSLocalNetworkUsageDescription.
  NSAllowsLocalNetworking already covers plain HTTP to private-range
  IPs, so no new ATS exception was needed.

Game (ios/IPP/Game/) - still makes no request of any kind:
- FloorMap draws a 1 m plate on the floor under the podium with one
  dot per location, fitted by bounding box with the longitude
  compressed by cos(latitude) so the shape is not stretched, tinted by
  local density from the brand teal to the podium gold, and captioned
  "Datos en vivo / de ejemplo - N ubicaciones". Procedural: no tiles,
  no imagery, no external provider. No collider and no physics body
  anywhere in it, so a ball flies straight through onto the floor
  collider as before.
- The pins arrive as a plain array from the app layer, so nothing
  under Game/ knows what a URL is (question Q5, option A). The SC-005
  grep over the folder is still clean.
- SyntheticMapPins is the seeded offline sample: four invented
  clusters in open water, well away from any real record.
- The Star Wars crawl is deleted - its band, fade ramp and per-frame
  update are gone, along with SyntheticStandings.crawlLine. The three
  step labels are untouched and still ride the breathing steps.

Tests: 230 XCTest cases green on the iPhone 17 Pro simulator (169 kept
from Phases 2-5B, 62 new for the projection, the probe ordering, the
fallback rule, the sample and the built map; 16 crawl cases removed
with the crawl). The test target is temporary as always and is not
committed.
Owner rework at Gate 5C (task 5C.3): "remove the green bounding box;
just leave the data points. Make them slightly animated (randomly) —
blink/grow so it looks alive; some can disappear for a few seconds, so
it looks like data is changing."

- The plate and its border are gone. The real desk, seen through the
  camera, is now the map's background; only the dots and the caption
  float above it. Dot opacity went up (0.75-1.0) to carry the map
  without a slab behind it.
- New FloorMapAnimation: pure, deterministic per-dot constants drawn
  from the dot's own index, so the field shimmers organically and the
  same map always animates the same way. Each dot pulses 1.5-4 s with
  a random phase and a 20-40 % amplitude; 12 % of them also fade out
  for 2-4 s once every 16-26 s and fade back, staggered so the field
  never guttters.
- Cost is one sin and one transform write per dot per frame. Materials
  are a pre-built densityStops x (fadeSteps+1) ramp, reassigned only
  for the ~12 % of dots that fade and only when their rung changes; a
  fully faded dot is disabled rather than drawn at zero. Still no
  collider and no physics body anywhere in the subtree, so the
  animation cannot touch a ball.
- The shimmer clock runs continuously and is deliberately not paused
  with the trophy: the dots are on the ground and touch nothing, so
  freezing them during a celebration would read as a glitch.

One tuning bug caught by the tests rather than by eye: with the first
cycle range a blinking dot was absent 44 % of the time, which flickers
instead of occasionally vanishing. minDropoutCycle is now 16 s, which
keeps every dot present for at least three quarters of its cycle, and
a test pins that ratio so the two constants must be tuned together.

Tests: 257 XCTest cases green on the iPhone 17 Pro simulator (+27 for
the animation, and FloorMapTests reshaped for the plate's removal).
Temporary target as always, not committed.
Phase 6 close-out for the Tiro al Trofeo AR mini-game.

- IPPTests is now a real xcodegen target hosted by the app, with the
  scheme wired for `xcodebuild test`. The 270 cases that were written and
  run per-phase against a throwaway target are committed: toss physics,
  rounds, best score, podium and floor-map geometry, plus the backend
  locator and map-pin decoding. Every suite is offline and deterministic;
  none needs a running backend.
- The candidate backend hosts move out of Swift source into Info.plist
  (`BackendCandidates`), next to BackendURL/WebURL. Behaviour is
  unchanged; pointing a phone at another Mac is now a plist edit. A
  missing, empty or malformed list degrades to "use BackendURL".
- Requests can no longer outrun the launch probe: resolution is a single
  awaitable task (`AppEnvironment.backendReady()`) and every request path
  awaits it, including the two views that talk to a client directly. A
  tap into Ranking straight from a cold launch waits ~10ms instead of
  sending one doomed request to localhost and self-healing on refresh.
- README: a section for the mini-game (entry point, rules, offline
  behaviour and the live floor map), the launch-time host probe, a note
  on running the backend against a local Postgres, and the now-false
  "no ARKit/RealityKit overlay" line rewritten.
…ith camera AR

- intro + pairing paragraph now name both AR modes (location-based + ARKit/RealityKit)
- 'Why GPS + AR' gains the third, camera-based surface consuming the same GPS data
- demo section and game section link the published camera-AR video
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.

1 participant