diff --git a/README.md b/README.md index 33b1893..49ab5c9 100644 --- a/README.md +++ b/README.md @@ -2,19 +2,24 @@ IPP is a location-based, on-chain-verifiable clinical-records app for Chilean doctors and medical staff (Spanish UI). It is built as a **prototype / -demonstrator** of a reusable template - GPS + location-based AR capture on iOS, -backed by a Bun service on Neon Postgres, with each record's hash anchored to +demonstrator** of a reusable template - GPS + AR on iOS (location-based AR at +capture, plus a camera-based ARKit/RealityKit scene), backed by a Bun service on +Neon Postgres, with each record's hash anchored to **Cardano** (through the **EffectStream** packages) so the data can be cryptographically verified later. The app pairs **GPS** (where each patient lives) with **location-based AR** -(live population context augmenting what the clinician sees as they work) and a +(live population context augmenting what the clinician sees as they work), +**camera-based AR** (an ARKit/RealityKit scene that anchors a podium to the real +world and renders the geolocated records as a living data map on the floor - +see [Tiro al Trofeo](#tiro-al-trofeo---camera-based-ar-mini-game)) and a **gamified** contribution layer that makes the dataset grow. ## What this is - **An iOS app** (SwiftUI) for capturing a ~70-question women's-health intake - form across four sections. + form across four sections, plus a camera-based AR mini-game on the + leaderboard (ARKit/RealityKit). - **A web dashboard** (Vite + React + Leaflet) for population maps, filters, feedback, and on-chain verification - also embedded inside the iOS app. - **A Bun + Fastify backend** on Neon Postgres, with a swappable chain adapter. @@ -27,7 +32,7 @@ The app pairs **GPS** (where each patient lives) with **location-based AR** GPS is the backbone: every patient has an address that geocodes to a latitude/longitude. That location unlocks **augmented reality anchored to place** - augmenting what the clinician sees about the physical world in front -of them, in two places: +of them, in three places: 1. **At capture.** As you enter a value, the field shows the population context for it - the **local** (the patient's own locality), **país** (country), and @@ -36,11 +41,17 @@ of them, in two places: 2. **On the map.** Doctors draw **notes and named areas** over the filtered population layer, turning patterns into plans - e.g. *"many patients in this zone need X, assign a specialist and schedule exams here."* - -> This is **location-based AR**: the augmentation is anchored to physical place -> through GPS rather than to a camera feed. The reality being augmented is the -> clinician's view of the patient and population in front of them, keyed to -> where the patient actually lives. +3. **Through the camera.** The + [Tiro al Trofeo](#tiro-al-trofeo---camera-based-ar-mini-game) scene uses + ARKit/RealityKit world tracking to anchor a virtual podium onto the real + surface in front of the user and projects the anonymized record locations as + a living data map on the actual floor around it. + +> The first two are **location-based AR** - the augmentation is anchored to +> physical place through GPS rather than to a camera feed, and the reality being +> augmented is the clinician's view of the patient and population in front of +> them, keyed to where the patient actually lives. The third is **camera-based +> AR** consuming the same GPS data through the phone's camera. **Why we prioritized it:** women's-health and pelvic-floor risk cluster geographically. Location-aware context at the point of capture (and on the map) @@ -77,7 +88,9 @@ size the radius of affectation, and act locally. │ ├── Models/ # Patient, FormSchema, FieldStats, ... │ ├── Services/ # APIPatientStore, SessionService, Wallet, SchemaService, AppEnvironment │ ├── Components/ # AddressPicker (CoreLocation), MultiSelectAutocomplete +│ ├── Game/ # Tiro al Trofeo - ARKit/RealityKit podium mini-game │ └── Views/ # Home, DynamicForm (StatCaption), Leaderboard, WebDashboard, Theme +├── ios/IPPTests/ # unit tests - game logic, geometry, backend locator (no backend needed) ├── web/src/ # Vite + React dashboard │ └── components/ # MapView, MapFilters, AnnotationsLayer, DrawingController, Feedback, PinVerify └── cardano/ # EffectStream workspace - local Cardano devnet + sync @@ -112,7 +125,9 @@ areas** ([AnnotationsLayer](web/src/components/AnnotationsLayer.tsx), [DrawingController](web/src/components/DrawingController.tsx), [AnnotationsList](web/src/components/AnnotationsList.tsx)) to mark interventions - the manual planning layer that turns a cluster into "assign a specialist here." -(Again: location-based AR anchored through GPS, not a camera overlay.) +(Again: this layer is anchored through GPS, not through the camera - the one +camera-based piece in IPP is the +[Tiro al Trofeo](#tiro-al-trofeo---camera-based-ar-mini-game) mini-game.) ### Engine @@ -147,6 +162,48 @@ Search actions are logged via `POST /api/v1/events`; field/record counts are computed from the stored data. Each account also has a Cardano wallet address shown in the app, tying the contributor to an on-chain identity. +### Tiro al Trofeo - camera-based AR mini-game + +The leaderboard has a **"Jugar"** entry point that opens **Tiro al Trofeo**, a +single-player ARKit/RealityKit mini-game built around the podium the ranking +already draws in gold/silver/bronze +([ios/IPP/Game/](ios/IPP/Game/), opened from +[LeaderboardView.swift](ios/IPP/Views/LeaderboardView.swift)). + +> **Video:** - a full round on +> device: place the podium → flick → make → summary. + +- **Place it.** Scan a desk or the floor, tap a detected horizontal plane, and a + procedural podium appears - three steps in the leaderboard's exact medal + colours plus a trophy cup. No 3D asset files: everything is generated in code. +- **Play it.** Flick upward from anywhere on the screen; the ball launches from + where your finger started, aimed where the phone points. Touching the cup + scores **+1**, landing inside scores **+10**, and the cup hops to a different + step after every make. Rounds are 60 s with a countdown, an end-of-round + summary, and a **device-local** best score. +- **Watch it.** The steps slowly breathe, the top-3 places float above them as + labels, and a field of dots on the floor around the podium maps where the + app's records are, pulsing so it reads as live data. +- **The game awards no leaderboard points and writes nothing.** It never touches + the ranking, never posts an event, and its only persistence is one integer in + `UserDefaults`. The game module itself makes **zero** network requests. +- **Offline behaviour.** The one thing that is live is the floor map: the app + layer (not the game) reads the public, anonymized `GET /api/v1/map-pins` and + hands the game plain coordinates. With the backend up the caption reads + **"Datos en vivo · N ubicaciones"**; with it down or unreachable the app + substitutes a synthetic offline sample and the caption reads **"Datos de + ejemplo · N ubicaciones"**. The game is fully playable either way. +- **Permissions.** The camera is requested the first time you open the game - + never at app launch - and denying it shows a Spanish explanation with a + shortcut to Ajustes. 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 unaffected. + +Game logic that does not need a camera - toss physics, rounds, best score, +podium and floor-map geometry - is covered by unit tests in +[ios/IPPTests/](ios/IPPTests/) (`xcodebuild test`, see +[iOS app](#ios-app)). + ## Cardano anchor The [`cardano/`](cardano/) workspace runs a local Cardano devnet on the @@ -297,6 +354,32 @@ curl http://localhost:3334/health Schema is created on startup (idempotent `CREATE TABLE IF NOT EXISTS`). +#### Running against a local Postgres instead of Neon + +The backend targets Neon, and three things bite if you point it at a local +database instead. None of them is fixed in code (a fix would touch the +production path); they are written down here so the next person does not +rediscover them: + +- **Postgres must serve TLS.** [backend/src/db.ts](backend/src/db.ts) passes + `ssl: "require"` as a postgres.js *client option*, which overrides any + `sslmode` in `DATABASE_URL`; unlike `prefer`, `require` never falls back to + plaintext, so a stock `docker run postgres:16` fails the handshake. A + self-signed certificate is enough (`require` does not verify the chain): + generate `server.crt`/`server.key`, copy them into the container's `PGDATA` + (owner `postgres`, key mode `600`), `ALTER SYSTEM SET ssl = on`, and restart + the container. +- **`scripts/seed-cities.ts` no longer works.** It POSTs `/api/v1/patients` + with only a `Content-Type` header, but that route is behind `requireDoctor` + and every row comes back `401 missing auth headers`. The script predates the + signed-request auth ([backend/src/auth.ts](backend/src/auth.ts)). +- **`scripts/seed-year.ts` needs a column the schema no longer creates.** Its + INSERT still lists the legacy plaintext `passcode`, while `initSchema` now + creates only `passcode_hash`. One + `ALTER TABLE patients ADD COLUMN IF NOT EXISTS passcode TEXT` before seeding + makes it run; it writes straight to Postgres, so it needs no auth and is the + richer data set anyway. + ### Cardano devnet (for `CHAIN=cardano`) ```bash @@ -324,12 +407,28 @@ brew install xcodegen # one-time cd ios xcodegen generate open IPP.xcodeproj # pick an iPhone simulator, ⌘R + +# unit tests (no backend needed - every suite is offline and deterministic) +xcodebuild test -scheme IPP -destination 'platform=iOS Simulator,name=iPhone 17 Pro' ``` The app talks to `http://localhost:3334` and embeds the web dashboard at `http://localhost:5174` (both set via `Info.plist`: `BackendURL`, `WebURL`). Demo logins: `user01`…`user10` / `pass01`…`pass10`. +**Finding the backend from a real iPhone.** `localhost` is the phone itself, so +a device build cannot use `BackendURL` as-is. At launch the app asks each host +in the `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 one that answers, falling back to +`BackendURL` if none does +([ios/IPP/Services/BackendLocator.swift](ios/IPP/Services/BackendLocator.swift)). +To run against your own Mac, put its LAN address in that array - a plist edit, +no Swift change. In the Simulator the list is skipped and `BackendURL` is used +directly. Requests wait for that resolution, so nothing can leave with a +half-resolved URL; when nothing is reachable the first request pays the probe +timeout once and then the app behaves offline as before. + ## Doctor authentication Doctor-scope endpoints require a **signed request**: the client signs @@ -343,7 +442,11 @@ deterministically from the account seed, sending `X-IPP-PubKey` / ## Demo - video & screenshots -> A screen recording of the end-to-end flow (capture → location-based AR stats → save → +> **Camera-AR demo (published):** - +> a single on-device take: leaderboard → Jugar → plane detection → podium +> anchored on the real table → floor data map → physics toss gameplay. +> +> A screen recording of the end-to-end clinical flow (capture → location-based AR stats → save → > on-chain verify → population map → gamified leaderboard) and screenshots will > be added here / in the [EffectStream blog post](https://effectstream.github.io/docs/blog/ipp-clinical-records-cardano). @@ -362,9 +465,12 @@ is the long-form write-up of the engineering and use-cases. ## What's intentionally not done -- **AR is location-based, not camera-based.** There is no ARKit/RealityKit - overlay; the augmentation is anchored to place through GPS - the - location-aware stats and map planning layer. +- **The clinical AR is location-based, not camera-based.** Everything that + augments the *work* - the local/país/mundo stat lines and the map planning + layer - is anchored to place through GPS, not to a camera feed. The only + camera-based AR in IPP is the [Tiro al Trofeo](#tiro-al-trofeo---camera-based-ar-mini-game) + mini-game on the leaderboard, which is a game and nothing else: it reads no + clinical record, awards no points and writes nothing. - **Demo accounts ship fixed seeds** - fine for a demo, but a real deployment needs per-user generated keys (see Roadmap). - **No smart-contract token mint** - the chain layer is metadata anchoring only. diff --git a/ios/IPP/Game/ARSupport.swift b/ios/IPP/Game/ARSupport.swift new file mode 100644 index 0000000..4699691 --- /dev/null +++ b/ios/IPP/Game/ARSupport.swift @@ -0,0 +1,71 @@ +import ARKit +import AVFoundation +import Foundation + +/// Capability and camera-permission gate for the "Tiro al Trofeo" AR mini-game. +/// +/// Nothing here starts a camera on its own: +/// - `isWorldTrackingSupported` and `cameraPermission` only *read* state iOS +/// already knows, so they are safe to call from the leaderboard while +/// deciding whether to show the entry point. +/// - `requestCameraAccess()` is the only call that can raise the system prompt, +/// and it is invoked exclusively from `TrophyTossView` — i.e. when the player +/// opens the game, never at app launch (FR-010). +/// +/// This type is offline by construction: it performs no networking (FR-008). +enum ARSupport { + + // MARK: - Device capability + + /// `true` when the device can run ARKit world tracking. + /// + /// Returns `false` in the iOS Simulator and on hardware without + /// world-tracking support, which is what hides/disables the game entry + /// point (FR-001). + static var isWorldTrackingSupported: Bool { + ARWorldTrackingConfiguration.isSupported + } + + /// Short Spanish explanation shown next to the disabled entry point. + static var unsupportedMessage: String { + #if targetEnvironment(simulator) + return "El mini-juego usa la cámara: solo funciona en un iPhone real." + #else + return "Este iPhone no admite el seguimiento de realidad aumentada." + #endif + } + + // MARK: - Camera permission + + /// The camera answer the player has already given, if any. + enum CameraPermission: Equatable { + /// The player has not been asked yet — asking is up to the game view. + case notDetermined + case granted + case denied + /// Blocked by the device itself (parental controls, MDM profile). + case restricted + } + + /// Current camera authorization, read without touching the camera. + static var cameraPermission: CameraPermission { + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: return .granted + case .denied: return .denied + case .restricted: return .restricted + case .notDetermined: return .notDetermined + @unknown default: return .denied + } + } + + /// Raises the system camera prompt when the answer is still + /// `.notDetermined`, and reports the resulting permission. + /// + /// Call this **only** when the game view appears (FR-010). + @discardableResult + static func requestCameraAccess() async -> CameraPermission { + guard cameraPermission == .notDetermined else { return cameraPermission } + _ = await AVCaptureDevice.requestAccess(for: .video) + return cameraPermission + } +} diff --git a/ios/IPP/Game/BestScoreStore.swift b/ios/IPP/Game/BestScoreStore.swift new file mode 100644 index 0000000..0ea8a34 --- /dev/null +++ b/ios/IPP/Game/BestScoreStore.swift @@ -0,0 +1,52 @@ +import Foundation + +/// The one thing "Tiro al Trofeo" remembers between launches: a single integer +/// best score (FR-008, US2). +/// +/// That is the whole persistence story for the game — no round history, no +/// player profile, nothing that leaves the device, and nothing that touches the +/// app's own data or the leaderboard. The spec is explicit that a device-local +/// best score is the *only* persistence allowed, so this type is deliberately +/// small enough to audit at a glance. +/// +/// `UserDefaults` is injectable so tests can run against their own suite +/// instead of the app's, and so two instances can be pointed at the same suite +/// to prove the value really survives (which is the part that matters — the +/// store holds no cached copy, every read goes to disk). +struct BestScoreStore { + + /// Key under which the best score lives. Namespaced so it cannot collide + /// with anything the rest of the app stores. + static let defaultKey = "com.nonturing.ipp.trophyToss.bestScore" + + private let defaults: UserDefaults + private let key: String + + init(defaults: UserDefaults = .standard, key: String = BestScoreStore.defaultKey) { + self.defaults = defaults + self.key = key + } + + /// The stored best, or 0 when nothing has been stored yet — `integer(forKey:)` + /// already returns 0 for a missing key, and the `max` guards against a value + /// some other writer left negative. + var best: Int { max(defaults.integer(forKey: key), 0) } + + /// Records the score of a finished round. + /// + /// - Returns: `true` only when this round beat the stored best, which is + /// also the cue the summary uses to say "¡Nuevo récord!". Equal scores do + /// not count as a new record, and a zero-point round never writes at all. + @discardableResult + func submit(_ score: Int) -> Bool { + guard score > 0, score > best else { return false } + defaults.set(score, forKey: key) + return true + } + + /// Forgets the best score. Not reachable from the UI; it exists so tests + /// (and a future debug menu) can start from a clean slate. + func clear() { + defaults.removeObject(forKey: key) + } +} diff --git a/ios/IPP/Game/FloorMap.swift b/ios/IPP/Game/FloorMap.swift new file mode 100644 index 0000000..11ddc1a --- /dev/null +++ b/ios/IPP/Game/FloorMap.swift @@ -0,0 +1,584 @@ +import Foundation +import RealityKit +import UIKit +import simd + +/// Turns latitude/longitude into points on a square field. Pure, no RealityKit, +/// no UIKit — every rule the floor map's layout depends on is here so it can be +/// tested off-device (Phase 5C testing item 1). +enum FloorMapProjection { + + /// A bounding-box fit of one pin set onto one field. + /// + /// Equirectangular, which is the right projection for a map a metre across + /// covering a few kilometres: longitude is compressed by `cos(latitude)` so + /// the shape is not stretched, and a single uniform `metresPerDegree` + /// scales both axes so the set keeps its real aspect ratio instead of being + /// squashed to fill the square. + struct Fit: Equatable { + let centerLatitude: Double + let centerLongitude: Double + /// `cos(centerLatitude)` — how much a degree of longitude is worth + /// against a degree of latitude at this latitude. + let longitudeScale: Double + /// Plate metres per degree of latitude. **Zero** when the pin set has + /// no extent (one pin, or every pin at the same coordinate), which + /// collapses the whole set onto the centre of the field — the sane + /// answer, and the one that cannot divide by zero. + let metresPerDegree: Double + /// Side of the square the pins are fitted into, in metres. + let extent: Float + + /// Where a pin lands on the field, in metres, as `(x, z)` in the + /// podium's own frame. + /// + /// North is **−Z**: the podium is turned to face the player at + /// placement and +Z is the side they stand on, so higher latitudes + /// belong further away from them. + /// + /// Always finite and always inside the field: an unusable pin gives the + /// centre, and anything the arithmetic produces is clamped to the + /// half-extent, so a stray coordinate cannot fling a dot across the + /// room. + func project(_ pin: GeoPin) -> SIMD2 { + guard pin.isUsable, metresPerDegree.isFinite else { return .zero } + let x = (pin.longitude - centerLongitude) * longitudeScale * metresPerDegree + let z = -(pin.latitude - centerLatitude) * metresPerDegree + guard x.isFinite, z.isFinite else { return .zero } + let half = Double(extent) / 2 + return SIMD2( + Float(min(max(x, -half), half)), + Float(min(max(z, -half), half)) + ) + } + } + + /// The fit that puts `pins` inside a square of side `extent`, centred. + /// + /// Degenerate inputs are answered rather than rejected: an empty list, a + /// single pin and a list where every pin is identical all produce a fit + /// whose `metresPerDegree` is zero, so every pin projects to the field's + /// centre and the map shows one dot in the middle instead of `NaN`. + static func fit(_ pins: [GeoPin], extent: Float) -> Fit { + let usable = pins.filter(\.isUsable) + guard !usable.isEmpty, extent > 0 else { + return Fit( + centerLatitude: 0, + centerLongitude: 0, + longitudeScale: 1, + metresPerDegree: 0, + extent: max(extent, 0) + ) + } + + var minLatitude = usable[0].latitude + var maxLatitude = usable[0].latitude + var minLongitude = usable[0].longitude + var maxLongitude = usable[0].longitude + for pin in usable.dropFirst() { + minLatitude = min(minLatitude, pin.latitude) + maxLatitude = max(maxLatitude, pin.latitude) + minLongitude = min(minLongitude, pin.longitude) + maxLongitude = max(maxLongitude, pin.longitude) + } + + let centerLatitude = (minLatitude + maxLatitude) / 2 + let centerLongitude = (minLongitude + maxLongitude) / 2 + // Floored so a pin set near a pole cannot drive the scale to infinity. + let longitudeScale = max(cos(centerLatitude * .pi / 180), 0.05) + + let spanLatitude = maxLatitude - minLatitude + let spanLongitude = (maxLongitude - minLongitude) * longitudeScale + let span = max(spanLatitude, spanLongitude) + // 1e-9° is about 0.1 mm on the ground: below this the pins are one + // point as far as a metre-wide field is concerned. + let metresPerDegree = span > 1e-9 ? Double(extent) / span : 0 + + return Fit( + centerLatitude: centerLatitude, + centerLongitude: centerLongitude, + longitudeScale: longitudeScale, + metresPerDegree: metresPerDegree, + extent: extent + ) + } + + /// Evenly thins a pin list down to at most `limit` entries, deterministically. + /// + /// The backend can return a thousand pins and the scene should not grow a + /// thousand entities for a field a metre across, where they would overlap + /// into a solid blob anyway. Sampling by *stride over the whole list* + /// rather than by taking the first `limit` keeps the geographic spread — + /// the seeded data arrives grouped by month and city, so a prefix would + /// show only the first few cities. + static func sample(_ pins: [GeoPin], limit: Int) -> [GeoPin] { + guard limit > 0 else { return [] } + guard pins.count > limit else { return pins } + let step = Double(pins.count) / Double(limit) + return (0..], radius: Float, stops: Int) -> [Int] { + guard stops > 1, !points.isEmpty, radius > 0 else { + return Array(repeating: 0, count: points.count) + } + + let radiusSquared = radius * radius + var counts = [Int](repeating: 0, count: points.count) + for i in points.indices { + for j in points.index(after: i).. 0 else { + return Array(repeating: 0, count: points.count) + } + let top = Float(stops - 1) + return counts.map { count in + min(max(Int((Float(count) / Float(busiest) * top).rounded()), 0), stops - 1) + } + } + + /// The line printed on the field's near edge. + /// + /// It names the source on purpose: with the backend up the owner must be + /// able to see "en vivo" under the podium, and with it stopped the same + /// glance must show "de ejemplo" (gate rows 5C-g1 and 5C-g3). + static func caption(pinCount: Int, isLive: Bool) -> String { + let source = isLive ? "Datos en vivo" : "Datos de ejemplo" + let places = pinCount == 1 ? "1 ubicación" : "\(pinCount) ubicaciones" + return "\(source) · \(places)" + } +} + +/// How a single dot on the floor map behaves over time (owner request at Gate +/// 5C: "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"). +/// +/// Pure and deterministic: every dot's whole life is a handful of numbers +/// derived from its index, so the field shimmers organically without anything +/// being stored, randomised at runtime, or allocated per frame. Two dots never +/// share a phase, so the map never pulses in unison — which is the difference +/// between "alive" and "a strobe". +enum FloorMapAnimation { + + /// Same one-line-edit spirit as `TossController.Tuning`: if the owner says + /// "too fast" or "too many vanishing", it is one number here. + enum Tuning { + /// A dot's pulse takes between these, in seconds. Range chosen so no + /// two neighbouring dots visibly beat together. + static let minPulsePeriod: TimeInterval = 1.5 + static let maxPulsePeriod: TimeInterval = 4.0 + /// …and grows/shrinks by this fraction of its size. + static let minPulseAmplitude: Float = 0.20 + static let maxPulseAmplitude: Float = 0.40 + + /// What share of the dots ever vanish. The rest are always on, so the + /// map cannot thin out no matter how the phases line up. + static let dropoutFraction: Double = 0.12 + /// How often a vanishing dot vanishes, in seconds. The floor of 16 s is + /// not arbitrary: against a dropout of up to `maxDropoutDuration` it + /// keeps every dot present for at least three quarters of its cycle, + /// so a blinking dot reads as *occasionally* missing rather than as + /// flickering. A test asserts that ratio, so lowering this without + /// also lowering the duration will fail rather than quietly turn the + /// map into a strobe. + static let minDropoutCycle: TimeInterval = 16 + static let maxDropoutCycle: TimeInterval = 26 + /// …and how long it stays gone. + static let minDropoutDuration: TimeInterval = 2.0 + static let maxDropoutDuration: TimeInterval = 4.0 + /// How long the fade out and back takes. Never more than half the + /// dropout, or the dot would never actually reach zero. + static let fadeDuration: TimeInterval = 0.45 + + /// How many discrete opacities the fade uses. Pre-built materials, one + /// per level per density tint, so a fading dot allocates nothing. + static let fadeSteps = 8 + + /// Seed for the per-dot parameters. Fixed, so the same map always + /// animates the same way. The bytes spell "IPPLIVE". + static let defaultSeed: UInt64 = 0x4950_504C_4956_45 + } + + /// One dot's constants, drawn once from its index. + struct Dot: Equatable { + var pulsePeriod: TimeInterval + var pulsePhase: Float + var pulseAmplitude: Float + var dropsOut: Bool + var dropoutCycle: TimeInterval + var dropoutStart: TimeInterval + var dropoutDuration: TimeInterval + } + + /// The parameters for dot `index`. Same index and seed, same dot, always — + /// so the field is reproducible and the tests can assert on it. + /// + /// The generator is re-seeded per dot rather than run as one stream, so a + /// dot's behaviour depends only on its own index. Adding a pin to the + /// middle of the list therefore does not reshuffle every dot after it. + static func dot(index: Int, seed: UInt64 = Tuning.defaultSeed) -> Dot { + var generator = SyntheticStandings.Generator(seed: seed &+ UInt64(bitPattern: Int64(index))) + + let period = TimeInterval.random( + in: Tuning.minPulsePeriod...Tuning.maxPulsePeriod, + using: &generator + ) + let phase = Float.random(in: 0..<(2 * .pi), using: &generator) + let amplitude = Float.random( + in: Tuning.minPulseAmplitude...Tuning.maxPulseAmplitude, + using: &generator + ) + let dropsOut = Double.random(in: 0..<1, using: &generator) < Tuning.dropoutFraction + let cycle = TimeInterval.random( + in: Tuning.minDropoutCycle...Tuning.maxDropoutCycle, + using: &generator + ) + // Staggered over the whole cycle, so the vanishing dots take turns + // instead of blinking out together. + let start = TimeInterval.random(in: 0.. Float { + guard time.isFinite, dot.pulsePeriod > 0 else { return 1 } + let angle = Float(time.truncatingRemainder(dividingBy: dot.pulsePeriod) / dot.pulsePeriod) + * 2 * .pi + dot.pulsePhase + return 1 + dot.pulseAmplitude * sin(angle) + } + + /// How visible the dot is right now, 0…1. Always 1 for a dot that never + /// drops out, which is the great majority of them. + static func visibility(_ dot: Dot, at time: TimeInterval) -> Float { + guard dot.dropsOut, time.isFinite, dot.dropoutCycle > 0, dot.dropoutDuration > 0 else { + return 1 + } + + let offset = (time - dot.dropoutStart).truncatingRemainder(dividingBy: dot.dropoutCycle) + let phase = offset < 0 ? offset + dot.dropoutCycle : offset + guard phase < dot.dropoutDuration else { return 1 } + + let fade = min(Tuning.fadeDuration, dot.dropoutDuration / 2) + guard fade > 0 else { return 0 } + if phase < fade { + return Float(1 - phase / fade) + } + if phase > dot.dropoutDuration - fade { + return Float((phase - (dot.dropoutDuration - fade)) / fade) + } + return 0 + } + + /// The rung of the pre-built fade ramp a visibility lands on. + /// + /// The `isFinite` guard is not decoration — `Swift.min`/`max` propagate a + /// NaN rather than clamping it and `Int(nan)` traps, the crash Phase 5B's + /// crawl fade found the hard way. + static func fadeLevel(forVisibility visibility: Float) -> Int { + guard visibility.isFinite else { return Tuning.fadeSteps } + let clamped = min(max(visibility, 0), 1) + return min(max(Int((clamped * Float(Tuning.fadeSteps)).rounded()), 0), Tuning.fadeSteps) + } +} + +/// The map of the app's data locations, on the floor under the podium (FR-013, +/// Phase 5C — it replaces the Star Wars crawl of Phase 5B). +/// +/// A field of dots floating just above the surface, one per (already +/// anonymized) data location, fitted to a metre-wide square centred on the +/// podium, tinted by how crowded each neighbourhood is, with a caption on the +/// near edge naming the source. The dots pulse and occasionally blink out and +/// back, so the field reads as live data rather than as a printed chart +/// (owner rework at Gate 5C, task 5C.3). +/// +/// > There is deliberately **no plate and no border**. An earlier version drew +/// > the dots on a translucent slab; the owner asked for the box to go, so the +/// > floor itself — the real desk, seen through the camera — is the map's +/// > background. +/// +/// **The game does no networking.** The pins arrive as a plain `FloorMapData` +/// value built by the app layer (`MapPinsService`), which substitutes an +/// offline sample when no backend answers. Nothing in this file — or anywhere +/// under `ios/IPP/Game/` — knows what a URL is (FR-008, SC-005, question Q5). +/// +/// No tiles, no imagery, no external map provider: the dots are procedural +/// cylinders, so the feature adds no asset files (FR-003) and contacts nobody +/// (SC-005). +/// +/// ## Cost, and why it cannot touch the game +/// +/// The field is built once, when the podium is placed: one shared dot mesh and +/// a pre-built ramp of `densityStops × (fadeSteps + 1)` materials, so 260 dots +/// allocate one mesh and 45 materials between them. A frame is then one `sin` +/// and one transform write per dot, plus a material assignment **only** for the +/// ~12 % of dots that ever fade, and only when their rung actually changes — +/// the other 88 % never touch their materials at all. +/// +/// Nothing in the subtree carries a `CollisionComponent` or a +/// `PhysicsBodyComponent`, so a ball flies straight through the field and lands +/// on the invisible floor collider underneath, exactly as it did before the map +/// existed. The animation therefore cannot affect play however it moves. +@MainActor +enum FloorMap { + + enum Name { + static let root = "floor_map" + static let caption = "floor_map_caption" + static let pinPrefix = "floor_map_pin_" + static func pin(_ index: Int) -> String { "\(pinPrefix)\(index)" } + } + + /// Same one-line-edit spirit as `TossController.Tuning`, `PodiumBreathing` + /// and `StandingsDisplay.Look`. + enum Look { + /// Side of the square the dots are fitted into, in metres. A metre + /// across puts the field well outside the 30 cm podium and still fits + /// on a desk. + static let side: Float = 1.00 + /// Margin between that square's edge and the outermost dot. + static let inset: Float = 0.06 + /// How far the field floats above the anchor plane, so a dot never + /// z-fights the invisible floor collider whose top face is y = 0. + static let lift: Float = 0.001 + + /// Diameter of one location dot. 12 mm on a 1 m field subtends ~0.7° + /// at a metre — a clearly separate dot, not a pixel. + static let dotDiameter: Float = 0.012 + static let dotHeight: Float = 0.0035 + static let dotSegments = 10 + /// Hard cap on rendered dots. The seeded backend returns ~960. + static let maxDots = 260 + /// Neighbourhood radius for the density tint, in field metres. + static let densityRadius: Float = 0.05 + /// How many colours the tint ramp has, coolest first. + static let densityStops = 5 + + /// Em size of the caption on the near edge. + static let captionSize: Float = 0.020 + /// How far the caption's face is tipped up toward the player from flat, + /// in radians. Flat text on a floor is hard to read from standing + /// height; 20° is enough to help without it looking like a signpost. + static let captionLean: Float = 20 * .pi / 180 + + /// Opacity of the sparsest dot, and of the most crowded one. Higher + /// than the plated version was — without a slab behind them the dots + /// carry the whole map. + static let minDotOpacity: Float = 0.75 + static let maxDotOpacity: Float = 1.0 + static let captionOpacity: Float = 0.85 + } + + // MARK: - What the update loop holds on to + + /// The built field: the entities, their constants, and the material ramp + /// they share. Built once at placement, then only read. + final class Display { + let root: Entity + let dots: [ModelEntity] + let motion: [FloorMapAnimation.Dot] + /// `ramp[densityLevel][fadeLevel]`, pre-built so a fading dot never + /// allocates a material. + let ramp: [[UnlitMaterial]] + let density: [Int] + /// Which rung each dot is showing, so an unchanged frame costs nothing. + var levels: [Int] + + init( + root: Entity, + dots: [ModelEntity], + motion: [FloorMapAnimation.Dot], + ramp: [[UnlitMaterial]], + density: [Int] + ) { + self.root = root + self.dots = dots + self.motion = motion + self.ramp = ramp + self.density = density + self.levels = Array(repeating: -1, count: dots.count) + } + } + + // MARK: - Building + + /// Builds the whole field. The returned `root` goes on the podium's scene + /// root, where it inherits the placement yaw. + static func make(_ data: FloorMapData, seed: UInt64 = FloorMapAnimation.Tuning.defaultSeed) -> Display { + let root = Entity() + root.name = Name.root + root.position = [0, Look.lift, 0] + + let sampled = FloorMapProjection.sample(data.pins, limit: Look.maxDots) + let fit = FloorMapProjection.fit(sampled, extent: Look.side - 2 * Look.inset) + let points = sampled.map(fit.project) + let density = FloorMapProjection.densityLevels( + for: points, + radius: Look.densityRadius, + stops: Look.densityStops + ) + + let ramp = densityRamp() + // One mesh for every dot — 260 entities sharing a single 10-segment + // cylinder rather than 260 meshes. + let dotMesh = PodiumBuilder.cylinderMesh( + height: Look.dotHeight, + radius: Look.dotDiameter / 2, + segments: Look.dotSegments + ) + + var dots: [ModelEntity] = [] + var motion: [FloorMapAnimation.Dot] = [] + dots.reserveCapacity(points.count) + motion.reserveCapacity(points.count) + + for (index, point) in points.enumerated() { + let tint = ramp[min(max(density[index], 0), ramp.count - 1)] + let dot = ModelEntity(mesh: dotMesh, materials: [tint[tint.count - 1]]) + dot.name = Name.pin(index) + dot.position = [point.x, Look.dotHeight / 2, point.y] + root.addChild(dot) + dots.append(dot) + motion.append(FloorMapAnimation.dot(index: index, seed: seed)) + } + + root.addChild(makeCaption(pinCount: sampled.count, isLive: data.isLive)) + + let display = Display( + root: root, + dots: dots, + motion: motion, + ramp: ramp, + density: density + ) + // Put every dot on its phase-zero size and opacity now, so the field + // appears already alive instead of snapping into motion on the frame + // after the podium is placed. + update(display, at: 0) + return display + } + + /// The caption, lying on the field's near edge and tipped up toward the + /// player. + /// + /// The rotation is the flat-on-the-floor case of the same construction the + /// Phase 5B crawl used: a text mesh is drawn in its own XY plane facing +Z, + /// so rotating about X by `-(π/2 − lean)` sends its up (+Y) away from the + /// player and its face (+Z) up out of the floor, leaning back toward them. + static func makeCaption(pinCount: Int, isLive: Bool) -> Entity { + let pivot = Entity() + pivot.name = Name.caption + pivot.position = [0, 0.002, Look.side / 2 - Look.inset / 2] + pivot.orientation = simd_quatf(angle: -(.pi / 2 - Look.captionLean), axis: [1, 0, 0]) + pivot.addChild( + StandingsDisplay.makeTextModel( + FloorMapProjection.caption(pinCount: pinCount, isLive: isLive), + size: Look.captionSize, + material: material(color: .white, opacity: Look.captionOpacity) + ) + ) + return pivot + } + + /// `ramp[densityLevel][fadeLevel]` — the tint ramp crossed with the fade + /// ramp, built once. Coolest (sparse) to hottest (crowded) is the app's + /// brand teal warming into the podium's gold, so the map is built from + /// colours the rest of the app already uses (FR-009); within each tint, + /// level 0 is invisible and the last level is the dot at full strength. + static func densityRamp() -> [[UnlitMaterial]] { + let stops = max(2, Look.densityStops) + let steps = max(1, FloorMapAnimation.Tuning.fadeSteps) + return (0.. UnlitMaterial { + var material = UnlitMaterial(color: color) + material.blending = .transparent(opacity: .init(floatLiteral: min(max(opacity, 0), 1))) + return material + } + + /// Straight RGB interpolation. `t` is clamped, so a level outside the ramp + /// cannot produce a colour outside it. + static func blend(_ from: UIColor, _ to: UIColor, _ t: Float) -> UIColor { + let amount = CGFloat(min(max(t, 0), 1)) + var fr: CGFloat = 0, fg: CGFloat = 0, fb: CGFloat = 0, fa: CGFloat = 0 + var tr: CGFloat = 0, tg: CGFloat = 0, tb: CGFloat = 0, ta: CGFloat = 0 + from.getRed(&fr, green: &fg, blue: &fb, alpha: &fa) + to.getRed(&tr, green: &tg, blue: &tb, alpha: &ta) + return UIColor( + red: fr + (tr - fr) * amount, + green: fg + (tg - fg) * amount, + blue: fb + (tb - fb) * amount, + alpha: fa + (ta - fa) * amount + ) + } + + // MARK: - Per-frame update + + /// One frame of the field: every dot takes its current size, and the ones + /// that are fading take their current rung. + /// + /// The material is only reassigned when the rung actually changes, and a + /// dot that never drops out sits at the top rung forever — so in a typical + /// frame this is `n` sines and `n` transform writes with **no** material + /// traffic at all. A fully faded dot is disabled outright rather than drawn + /// at zero opacity. + static func update(_ display: Display, at time: TimeInterval) { + for index in display.dots.indices { + let dot = display.dots[index] + let motion = display.motion[index] + + dot.scale = .init(repeating: FloorMapAnimation.scale(motion, at: time)) + + let level = FloorMapAnimation.fadeLevel( + forVisibility: FloorMapAnimation.visibility(motion, at: time) + ) + guard level != display.levels[index] else { continue } + display.levels[index] = level + + guard level > 0 else { + dot.isEnabled = false + continue + } + dot.isEnabled = true + let row = display.ramp[min(max(display.density[index], 0), display.ramp.count - 1)] + dot.model?.materials = [row[min(level, row.count - 1)]] + } + } +} diff --git a/ios/IPP/Game/GameRound.swift b/ios/IPP/Game/GameRound.swift new file mode 100644 index 0000000..9e67c97 --- /dev/null +++ b/ios/IPP/Game/GameRound.swift @@ -0,0 +1,219 @@ +import Foundation + +/// One timed round of "Tiro al Trofeo" (FR-007), as a value type with no view, +/// no timer object and no ARKit anywhere in sight. +/// +/// The round is **tick-driven**: something outside — in the app, the RealityKit +/// frame loop — hands it the elapsed time and it decides when the round is over. +/// Nothing here reads a clock, which is what makes every rule below testable in +/// the Simulator, where ARKit does not exist. +/// +/// ``` +/// idle ──start()──▶ running(remaining) ──tick() to 0──▶ ended(score) +/// ▲ │ │ +/// └───────reset()────────┴────────────reset()─────────────┘ +/// ``` +/// +/// # Pausing +/// +/// Two things can stop the clock, and they are tracked separately rather than +/// as one flag: AR tracking going bad, and the app leaving the foreground. A +/// player who covers the camera *and* takes a call must get both back before +/// play resumes, which a single boolean would get wrong. While any reason is +/// present, ``tick(_:)`` consumes no time and the round accepts nothing — so +/// paused seconds are never charged to the player (edge cases "tracking loss +/// mid-round" and "backgrounding mid-round"). +/// +/// # Free practice vs the round +/// +/// FR-007 says flicks are accepted only while a round runs. Taken literally +/// that also freezes the screen the player sees *before* they ever press +/// "Comenzar", which is where Phase 3's playable sandbox lived and where SC-001 +/// (first flick within 30 s of opening the game) is actually satisfied. The +/// rule implemented here keeps both: **balls may always be thrown in `idle`, +/// but only a running round counts them**. ``acceptsFlicks`` is the gesture +/// gate, ``countsScores`` is the scoring gate, and they differ exactly in +/// `idle`. Nothing thrown outside a round can touch a round's score or its +/// clock, which is the part FR-007 is protecting. +struct GameRound: Equatable { + + // MARK: - Rules + + /// The knobs, in one place, the way `TossController.Tuning` does it. + struct Rules: Equatable { + /// Length of a round in seconds. The spec allows 30–60 s (US2); 60 s is + /// long enough to recover from a bad start and matches SC-006's + /// "60-second round of continuous play". + var duration: TimeInterval = 60 + + init() {} + } + + // MARK: - State + + enum State: Equatable { + case idle + /// A round is under way; `remaining` counts down to zero. + case running(remaining: TimeInterval) + /// The clock ran out. Carries the final score so the summary cannot + /// drift from what the round actually recorded. + case ended(score: Int) + } + + /// Why the clock is stopped. A set, not a flag: reasons come from + /// independent sources and each must clear itself. + enum PauseReason: String, Hashable, CaseIterable { + /// ARKit reported limited tracking, or the session was interrupted. + case trackingLimited + /// The app is not the active scene. + case backgrounded + } + + var rules: Rules + private(set) var state: State = .idle + /// Points scored so far in the round in progress. Reset by ``start()``. + private(set) var score: Int = 0 + private(set) var pauseReasons: Set = [] + + init(rules: Rules = Rules()) { + self.rules = rules + } + + // MARK: - Queries + + var isIdle: Bool { state == .idle } + + var isRunning: Bool { + if case .running = state { return true } + return false + } + + var hasEnded: Bool { + if case .ended = state { return true } + return false + } + + /// A round is on the clock but the clock is stopped. + var isPaused: Bool { isRunning && !pauseReasons.isEmpty } + + /// A round is on the clock and the clock is moving. + var isTicking: Bool { isRunning && pauseReasons.isEmpty } + + /// Seconds left in the round in progress; zero when no round is running. + var remaining: TimeInterval { + if case .running(let remaining) = state { return remaining } + return 0 + } + + /// Final score of the round that just finished, or `nil` if none has. + var finalScore: Int? { + if case .ended(let score) = state { return score } + return nil + } + + /// May the player throw a ball right now? + /// + /// Yes in `idle` (free practice, see the type's doc comment) and while a + /// round is actually ticking. No while paused — gameplay stops with the + /// clock — and no while the summary is up, so a stray swipe over the + /// end-of-round card cannot fire a ball behind it. + var acceptsFlicks: Bool { + switch state { + case .idle: return true + case .running: return pauseReasons.isEmpty + case .ended: return false + } + } + + /// Does a ball landing in the cup add to a round's score right now? + /// Only while a round is ticking (FR-007). + var countsScores: Bool { isTicking } + + /// May the player press "Comenzar"? + var canStart: Bool { !isRunning } + + // MARK: - Transitions + + /// Begins a round, from `idle` or from a finished one ("Jugar de nuevo"). + /// + /// Existing pause reasons are deliberately **kept**: they are owned by the + /// outside world (tracking state, scene phase) and clearing them here would + /// leave the round believing it can tick while the camera is still covered. + /// A round started under a pause simply begins paused and starts counting + /// when the cause clears. + /// + /// - Returns: `true` if a round actually started. + @discardableResult + mutating func start() -> Bool { + guard canStart else { return false } + score = 0 + state = .running(remaining: rules.duration) + return true + } + + /// Adds or clears one pause reason. Idempotent, so callers can fire it on + /// every tracking-state change without bookkeeping of their own. + mutating func setPaused(_ paused: Bool, reason: PauseReason) { + if paused { + pauseReasons.insert(reason) + } else { + pauseReasons.remove(reason) + } + } + + /// Advances the clock. + /// + /// A no-op unless a round is running with no pause reason, so paused and + /// backgrounded time is never charged to the player. + /// + /// - Returns: `true` on the single tick that ends the round. + @discardableResult + mutating func tick(_ delta: TimeInterval) -> Bool { + guard case .running(let remaining) = state, + pauseReasons.isEmpty, + delta > 0 + else { return false } + + let left = remaining - delta + guard left > 0 else { + state = .ended(score: score) + return true + } + state = .running(remaining: left) + return false + } + + /// Credits a ball that earned points. + /// + /// The round does not care *which* tier earned them (FR-005: +1 for + /// touching the cup, the balance of +10 for landing in it) — that rule + /// lives in `TossController`, which hands the arithmetic down as a number. + /// + /// - Returns: `true` if the points went to a round. `false` means the throw + /// was free practice (or landed while paused), and the caller should tally + /// it somewhere that is not a round score. + @discardableResult + mutating func registerScore(_ points: Int = 1) -> Bool { + guard countsScores else { return false } + score += points + return true + } + + /// Back to `idle` — dismissing the summary, or relocating the podium. + mutating func reset() { + state = .idle + score = 0 + } + + // MARK: - Presentation helpers + + /// The countdown as `m:ss`, rounded **up** so the HUD shows "1:00" for a + /// round that has only just begun and only reaches "0:00" when time is + /// genuinely gone. + static func countdownText(_ remaining: TimeInterval) -> String { + let seconds = max(0, Int(remaining.rounded(.up))) + return String(format: "%d:%02d", seconds / 60, seconds % 60) + } + + var countdownText: String { Self.countdownText(remaining) } +} diff --git a/ios/IPP/Game/PodiumARViewContainer.swift b/ios/IPP/Game/PodiumARViewContainer.swift new file mode 100644 index 0000000..49e3ac7 --- /dev/null +++ b/ios/IPP/Game/PodiumARViewContainer.swift @@ -0,0 +1,1311 @@ +import ARKit +import Combine +import RealityKit +import SwiftUI +import UIKit + +/// Shared state between the RealityKit `ARView` and the SwiftUI overlay drawn +/// on top of it. +/// +/// The view reads the published values to decide what hint, HUD and buttons to +/// show and calls the round controls; `relocate()` is the one command that +/// travels the other way, into the coordinator that owns the AR session. +/// +/// The round itself is a `GameRound` value — every rule about time, pausing and +/// what counts lives there, tested off-device; this class only decides *when* +/// to poke it and republishes the result for SwiftUI (FR-007). +@MainActor +final class PodiumARModel: ObservableObject { + + /// What the session is doing right now, most severe first when it comes to + /// choosing a hint. + enum Phase: Equatable { + /// Looking for a horizontal plane — the coaching overlay is up. + case scanning + /// A plane exists; the player can tap to place the podium. + case readyToPlace + /// The podium is anchored in the world. + case placed + } + + @Published fileprivate(set) var phase: Phase = .scanning + /// Set while ARKit reports limited tracking or an interruption. The podium + /// keeps its anchor throughout — this only drives the hint (FR-002). + @Published fileprivate(set) var trackingIssue: String? + /// Unrecoverable session error; the player has to close and reopen. + @Published fileprivate(set) var failure: String? + /// Short-lived feedback, e.g. a tap that hit no surface. + @Published fileprivate(set) var transientHint: String? + + // MARK: Round state (FR-007, FR-008) + + /// The timed round. `private(set)` because every mutation has to go through + /// one of the methods below, which keep the persisted best score in step. + @Published private(set) var round = GameRound() + /// Balls landed in the cup outside a round. Free practice, reset whenever + /// the game returns to `idle` — never mixed into a round's score. + @Published private(set) var practiceScore: Int = 0 + /// Best round ever played on this device (FR-008). Read once at init and + /// kept in step by ``finishRound()``. + @Published private(set) var bestScore: Int + /// Whether the round that just ended set a new best, so the summary can say + /// so. Meaningless unless `round.hasEnded`. + @Published private(set) var didSetRecord = false + + /// The game's only persistence. + private let bestScores: BestScoreStore + + /// Installed by the coordinator so the overlay's "Reubicar" button can + /// reach the AR session. + fileprivate var relocateHandler: (() -> Void)? + + init(bestScores: BestScoreStore = BestScoreStore(), rules: GameRound.Rules = GameRound.Rules()) { + self.bestScores = bestScores + self.bestScore = bestScores.best + self.round = GameRound(rules: rules) + } + + var isPlaced: Bool { phase == .placed } + + /// What the score pill shows: the round's score once a round is under way + /// or finished, the free-practice tally before that. + var displayedScore: Int { round.isIdle ? practiceScore : round.score } + + /// "Comenzar" is only offered once there is something to throw at. + var canStartRound: Bool { isPlaced && round.canStart } + + /// Relocating mid-round would move the target out from under a running + /// clock, so the button is off while a round is on (anticipated by the + /// Phase 2 task list). + var canRelocate: Bool { isPlaced && !round.isRunning } + + /// The single line of Spanish shown at the bottom of the AR view. + var hint: String { + if let failure { return failure } + if let trackingIssue { return trackingIssue } + if let transientHint { return transientHint } + switch phase { + case .scanning: + return "Mueve el teléfono para detectar una superficie." + case .readyToPlace: + return "Toca la superficie para colocar el podio." + case .placed: + return "Desliza hacia arriba para lanzar la pelota adentro de la copa." + } + } + + // MARK: Round controls + + /// "Comenzar" / "Jugar de nuevo". + func startRound() { + guard canStartRound else { return } + didSetRecord = false + practiceScore = 0 + round.start() + } + + /// Dismissing the summary: back to free practice with a clean slate. + func returnToPractice() { + round.reset() + practiceScore = 0 + didSetRecord = false + } + + /// Stops or restarts the round clock. Called from the tracking-state + /// delegate and from the view's `scenePhase` observer; both fire + /// unconditionally, and `GameRound` makes that idempotent. + func setPaused(_ paused: Bool, reason: GameRound.PauseReason) { + round.setPaused(paused, reason: reason) + } + + /// Drops the placed podium and goes back to scanning so the player can pick + /// a new spot. Refused while a round is running. + func relocate() { + guard canRelocate else { return } + returnToPractice() + relocateHandler?() + } + + /// One frame of round time, driven by the RealityKit update loop. + fileprivate func tick(_ delta: TimeInterval) { + if round.tick(delta) { finishRound() } + } + + /// The clock hit zero: freeze the score and update the stored best. + private func finishRound() { + guard let score = round.finalScore else { return } + didSetRecord = bestScores.submit(score) + bestScore = bestScores.best + } + + /// A ball earned points — `TossController.Tuning.hitPoints` for touching + /// the cup, the rest of `makePoints` for landing in it (FR-005, amended at + /// Gate 4). They count for the round if one is running, and otherwise only + /// for the free-practice tally (see `GameRound`). + /// + /// - Returns: `false` when the points counted for nothing — a ball already + /// in flight when the round paused — so the caller can skip the fanfare + /// for a point the player did not get. + @discardableResult + fileprivate func registerScore(_ points: Int) -> Bool { + if round.registerScore(points) { return true } + guard round.isIdle else { return false } + practiceScore += points + return true + } + + fileprivate func flash(_ message: String) { + transientHint = message + Task { [weak self] in + try? await Task.sleep(nanoseconds: 2_500_000_000) + guard let self, self.transientHint == message else { return } + self.transientHint = nil + } + } +} + +/// The AR half of "Tiro al Trofeo": a RealityKit `ARView` running world +/// tracking with horizontal plane detection, an `ARCoachingOverlayView` for the +/// scan hint, tap-to-place for the procedural podium (FR-002, FR-003) and +/// swipe-to-throw for the balls, including cup scoring and ball culling +/// (FR-004, FR-005, FR-006). +/// +/// The rules of the throw live in `TossController` and the rules of a round in +/// `GameRound`, neither of which knows anything about ARKit; this file supplies +/// the camera pose, the entities and the frame clock they run on. +/// +/// The session is torn down completely when SwiftUI removes the view — paused, +/// un-delegated, anchors and subscriptions dropped — so closing the game leaves +/// nothing running behind the leaderboard (FR-011). +/// +/// Offline by construction: nothing here performs any networking (FR-008). The +/// floor map's pins arrive as a plain `FloorMapData` value fetched by the app +/// layer and handed down through `TrophyTossView` (FR-013, question Q5). +struct PodiumARViewContainer: UIViewRepresentable { + + @ObservedObject var model: PodiumARModel + /// Locations for the floor map under the podium. Already resolved by the + /// app layer to either live backend pins or the offline sample; this view + /// just draws whatever it is given. + var floorMap: FloorMapData + + func makeCoordinator() -> Coordinator { + Coordinator(model: model, floorMap: floorMap) + } + + func makeUIView(context: Context) -> ARView { + #if DEBUG + let problems = PodiumBuilder.selfCheck() + assert(problems.isEmpty, "PodiumBuilder produced a broken scene: \(problems)") + #endif + + let arView = ARView(frame: .zero) + // Our configuration, not ARView's guess. + arView.automaticallyConfigureSession = false + arView.session.delegate = context.coordinator + // Delegate callbacks land on the main queue, which is where the + // published state and the RealityKit scene both live. + arView.session.delegateQueue = .main + + context.coordinator.attach(to: arView) + return arView + } + + func updateUIView(_ uiView: ARView, context: Context) { + // The one thing that flows *in*: the pins, which the app layer may + // still have been fetching when this screen opened. + context.coordinator.updateFloorMap(floorMap) + } + + /// Full teardown when the game screen goes away (FR-011). + static func dismantleUIView(_ uiView: ARView, coordinator: Coordinator) { + coordinator.tearDown() + } + + // MARK: - Coordinator + + @MainActor + final class Coordinator: NSObject, ARSessionDelegate, ARCoachingOverlayViewDelegate { + + private let model: PodiumARModel + private weak var arView: ARView? + private let coachingOverlay = ARCoachingOverlayView() + private var tapRecognizer: UITapGestureRecognizer? + private var panRecognizer: UIPanGestureRecognizer? + + /// The one anchor the podium lives on. Kept so relocation can remove + /// exactly it, and so tracking recovery can be checked against it. + private var podiumAnchor: AnchorEntity? + private var subscriptions: [any Cancellable] = [] + /// Collision subscription for the +1 tier, held apart from the rest + /// because it is made and dropped with the podium rather than with the + /// view. + private var contactSubscription: (any Cancellable)? + private var lifecycleObservers: [NSObjectProtocol] = [] + + private var hasSeenPlane = false + private var isPausedForBackground = false + private var isTornDown = false + + // MARK: Toss state (Phase 3) + + /// Rules and tuning for the toss — pure, and unit-tested off-device. + private var toss = TossController() + /// Balls currently in the scene, with the bookkeeping the culler needs. + private var balls: [LiveBall] = [] + /// When the current swipe started, in `CACurrentMediaTime()` seconds. + private var swipeStart: TimeInterval? + private let successHaptics = UINotificationFeedbackGenerator() + /// The lighter cue for the +1 tier, so a graze off the cup does not + /// feel like a made shot (FR-005). + private let hitHaptics = UIImpactFeedbackGenerator(style: .light) + + /// The cup, cached at placement so the cup rules can measure a ball's + /// position in the cup's own frame without walking the hierarchy every + /// frame. + private weak var cupEntity: Entity? + /// The trophy, cached at placement. The difficulty ramp animates it + /// between steps inside its own parent, the steps container (US3). + private weak var trophyEntity: Entity? + /// Which step the cup is standing on right now. + private var currentStep: PodiumBuilder.Step = .gold + /// True from the moment a make is celebrated until the cup has finished + /// sliding to its new step. Nothing scores in that window: the trophy + /// is being scaled and moved, so every measurement taken in the cup's + /// frame is in motion (US3, and it keeps the Gate 4 defect from coming + /// back through the animation). + private var isCupMoving = false + /// Every collidable part of the cup, by identity. Touching any of them + /// is the +1 tier (FR-005). + private var cupContactIDs: Set = [] + + // MARK: Scenery state (Phase 5B — FR-012, FR-013) + + /// The three step entities, cached at placement so the breathing update + /// does not walk the hierarchy sixty times a second. + private var stepEntities: [PodiumBuilder.Step: ModelEntity] = [:] + /// Each step's height *right now*. Everything that rides a step — the + /// trophy, the name label — is placed from this rather than from the + /// step's resting height (FR-012). + private var stepHeights: [PodiumBuilder.Step: Float] = [:] + /// Which rung of the breathing ladder each step is currently showing, + /// so a frame that does not change the rung costs nothing. + private var stepRungs: [PodiumBuilder.Step: Int] = [:] + /// The breathing clock. Advances only while the trophy is still, so a + /// celebration freezes the podium and then resumes from the same phase + /// instead of jumping (FR-012). + private var breathTime: TimeInterval = 0 + /// The three name labels, built once at placement. + private var standings: StandingsDisplay.Display? + /// The pins the floor map is currently drawing. Set at init from the + /// SwiftUI value and replaced whenever the app layer's fetch lands + /// (FR-013). + private var floorMap: FloorMapData + /// The built map, kept so a late fetch can swap it without disturbing + /// anything else in the scene, and so the update loop can shimmer it. + private var floorMapDisplay: FloorMap.Display? + /// The floor map's clock. Never pauses — the dots are scenery on the + /// ground, they touch nothing, and freezing them during a celebration + /// would read as a glitch rather than as the podium holding its breath + /// (FR-013, task 5C.3). + private var floorMapTime: TimeInterval = 0 + /// The podium's scene root, cached so the map can be rebuilt into the + /// same parent the rest of the scenery hangs from. + private weak var podiumScene: Entity? + /// When the current +1 pulse finishes, in `CACurrentMediaTime()` + /// seconds. Zero when no pulse is running. + private var pulseEndsAt: TimeInterval = 0 + + /// True while any animation owns the trophy's transform — the light + /// pulse of a cup hit, the swell of a make, or the slide to a new step. + /// + /// The breathing update writes the trophy's transform every frame, so + /// it has to stand back while an animation is doing the same; that is + /// also exactly the pause FR-012 asks for around the celebration. It is + /// deliberately *not* `isCupMoving`, which additionally suspends + /// scoring: a +1 pulse must not stop the same ball going on to make. + private var isTrophyAnimating: Bool { + isCupMoving || CACurrentMediaTime() < pulseEndsAt + } + + /// How long the cup takes to slide to its new step. + private static let cupMoveDuration: TimeInterval = 0.4 + /// How long the trophy holds its celebratory swell before the move. + private static let makeSwellDuration: TimeInterval = 0.14 + + /// One ball in flight: the entity plus the timers the culling rules in + /// `TossController` are written against (FR-006), how long it has been + /// verifiably inside the cup (FR-005) and how many times it has been + /// shoved off the cup's rim (Gate 3 row 3.2). + private struct LiveBall { + let id: TossController.BallID + let entity: ModelEntity + var age: TimeInterval = 0 + var restingFor: TimeInterval = 0 + var insideFor: TimeInterval = 0 + var rimNudges: Int = 0 + } + + init(model: PodiumARModel, floorMap: FloorMapData) { + self.model = model + self.floorMap = floorMap + super.init() + model.relocateHandler = { [weak self] in self?.relocate() } + } + + // MARK: Session configuration + + /// World tracking with horizontal plane detection, plus person + /// occlusion where the hardware offers it. No scene mesh — the podium + /// only ever sits on a detected plane, so reconstruction would cost + /// frame rate for nothing. + /// + /// Person occlusion comes from Gate 2's one finding (row 2.4): with it + /// off, a hand passing in front of the phone is painted *behind* the + /// podium, which reads as broken. `.personSegmentationWithDepth` makes + /// people and hands occlude virtual content at the right depth. It needs + /// an A12 or newer device, so the capability is checked and the game + /// simply runs without it on older hardware. + private func makeConfiguration() -> ARWorldTrackingConfiguration { + let configuration = ARWorldTrackingConfiguration() + configuration.planeDetection = [.horizontal] + configuration.environmentTexturing = .automatic + configuration.isLightEstimationEnabled = true + if ARWorldTrackingConfiguration.supportsFrameSemantics(.personSegmentationWithDepth) { + configuration.frameSemantics.insert(.personSegmentationWithDepth) + } + return configuration + } + + func attach(to arView: ARView) { + self.arView = arView + + arView.session.run(makeConfiguration(), options: [.resetTracking, .removeExistingAnchors]) + + installCoachingOverlay(on: arView) + + let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) + arView.addGestureRecognizer(tap) + tapRecognizer = tap + + // Tap places the podium, swipe throws a ball. The two never fight: + // a pan only begins once the finger has moved, which a tap never + // does, and the swipe handler ignores everything until the podium + // is down. + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:))) + pan.maximumNumberOfTouches = 1 + arView.addGestureRecognizer(pan) + panRecognizer = pan + + subscriptions.append( + arView.scene.subscribe(to: SceneEvents.Update.self) { [weak self] event in + MainActor.assumeIsolated { self?.step(deltaTime: event.deltaTime) } + } + ) + + observeAppLifecycle() + } + + private func installCoachingOverlay(on arView: ARView) { + coachingOverlay.session = arView.session + coachingOverlay.goal = .horizontalPlane + coachingOverlay.activatesAutomatically = true + coachingOverlay.delegate = self + coachingOverlay.translatesAutoresizingMaskIntoConstraints = false + arView.addSubview(coachingOverlay) + NSLayoutConstraint.activate([ + coachingOverlay.leadingAnchor.constraint(equalTo: arView.leadingAnchor), + coachingOverlay.trailingAnchor.constraint(equalTo: arView.trailingAnchor), + coachingOverlay.topAnchor.constraint(equalTo: arView.topAnchor), + coachingOverlay.bottomAnchor.constraint(equalTo: arView.bottomAnchor) + ]) + } + + // MARK: App lifecycle + // + // Backgrounding stops the camera. We pause explicitly on the way out + // and re-run the *same* configuration with no options on the way back + // in — no `.resetTracking`, no `.removeExistingAnchors`, so the podium + // keeps the anchor it was placed on and ARKit relocalises to it + // instead of the podium jumping somewhere new. + + private func observeAppLifecycle() { + let center = NotificationCenter.default + lifecycleObservers.append( + center.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.pauseForBackground() } + } + ) + lifecycleObservers.append( + center.addObserver( + forName: UIApplication.willEnterForegroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.resumeFromBackground() } + } + ) + } + + private func pauseForBackground() { + guard !isTornDown, !isPausedForBackground else { return } + isPausedForBackground = true + arView?.session.pause() + } + + private func resumeFromBackground() { + guard !isTornDown, isPausedForBackground else { return } + isPausedForBackground = false + // No options: existing anchors survive, tracking relocalises. + arView?.session.run(makeConfiguration()) + applyTrackingIssue(nil) + } + + /// The one place tracking trouble is recorded: it drives both the hint + /// line and the round clock, and those two must never disagree + /// (FR-007, edge case "tracking loss mid-round"). + private func applyTrackingIssue(_ issue: String?) { + model.trackingIssue = issue + model.setPaused(issue != nil, reason: .trackingLimited) + } + + // MARK: Placement + + @objc + private func handleTap(_ gesture: UITapGestureRecognizer) { + guard let arView, podiumAnchor == nil else { return } + + let point = gesture.location(in: arView) + // Prefer a real detected plane; fall back to ARKit's estimate so a + // confident player is not blocked by a slow plane extension. + let hit = arView.raycast(from: point, allowing: .existingPlaneGeometry, alignment: .horizontal).first + ?? arView.raycast(from: point, allowing: .estimatedPlane, alignment: .horizontal).first + + guard let hit else { + model.flash("Ahí no vemos superficie. Prueba en otro punto de la mesa o el suelo.") + return + } + + place(at: hit.worldTransform, in: arView) + } + + private func place(at worldTransform: simd_float4x4, in arView: ARView) { + let position = SIMD3( + worldTransform.columns.3.x, + worldTransform.columns.3.y, + worldTransform.columns.3.z + ) + + // Anchor on the world position only — the raycast's own rotation + // follows the plane's arbitrary axes, which would spin the podium. + let anchor = AnchorEntity(world: position) + let scene = PodiumBuilder.makeScene() + scene.orientation = simd_quatf(angle: yaw(towardCameraFrom: position, in: arView), axis: [0, 1, 0]) + anchor.addChild(scene) + arView.scene.addAnchor(anchor) + + podiumAnchor = anchor + podiumScene = scene + model.phase = .placed + model.transientHint = nil + + trophyEntity = scene.findEntity(named: PodiumBuilder.Name.trophy) + cupEntity = scene.findEntity(named: PodiumBuilder.Name.cup) + currentStep = .gold + isCupMoving = false + pulseEndsAt = 0 + installScenery(in: scene) + subscribeToCupContacts(in: arView, anchor: anchor) + // Warms the Taptic Engine so the first score's haptic is immediate. + successHaptics.prepare() + hitHaptics.prepare() + + // From here the player is looking at the podium, so stop the + // full-screen coaching overlay from covering it; our own hint takes + // over if tracking degrades. + coachingOverlay.activatesAutomatically = false + coachingOverlay.setActive(false, animated: true) + } + + // MARK: - Scenery (FR-012, FR-013) + + /// Wires up everything the podium does for show: the breathing steps, + /// the three name labels and the floor map of data locations. + /// + /// The names are invented on the spot by `SyntheticStandings`. The + /// map's pins were fetched by the **app layer** and handed in as plain + /// coordinates — no request is made here or anywhere else under + /// `ios/IPP/Game/` (FR-008, SC-005, question Q5). + private func installScenery(in scene: Entity) { + stepEntities = [:] + stepHeights = [:] + stepRungs = [:] + breathTime = 0 + + for step in PodiumBuilder.Step.allCases { + guard let entity = scene.findEntity(named: step.entityName) as? ModelEntity else { + continue + } + stepEntities[step] = entity + stepHeights[step] = step.height + } + // Build the ladder now rather than on the first breathing frame, so + // the one-off mesh generation lands in the placement frame, which is + // already building a scene, instead of stuttering a second later. + _ = PodiumBreathing.ladders + + standings = StandingsDisplay.attach(to: scene, standings: SyntheticStandings.standings()) + rebuildFloorMap(in: scene) + + // Put the steps on their phase-zero rungs now, so the podium + // appears already breathing instead of snapping into shape on the + // frame after it is placed. + breathe() + } + + /// Drops the cached scenery handles. The entities themselves go with + /// the anchor, which the caller removes. + private func clearScenery() { + stepEntities = [:] + stepHeights = [:] + stepRungs = [:] + standings = nil + floorMapDisplay = nil + floorMapTime = 0 + podiumScene = nil + breathTime = 0 + } + + // MARK: Floor map (FR-013, Phase 5C) + + /// New pins from the app layer. + /// + /// Called from `updateUIView`, so it runs on every SwiftUI update of + /// the containing view — hence the equality guard, which makes all but + /// the one update that actually changes the data free. When the podium + /// is not down yet there is nothing to rebuild: `installScenery` will + /// use the stored value. + func updateFloorMap(_ data: FloorMapData) { + guard data != floorMap else { return } + floorMap = data + guard let podiumScene else { return } + rebuildFloorMap(in: podiumScene) + } + + /// Replaces the map under the podium with one drawn from the current + /// pins. The map carries no collider and no physics body, so removing + /// and re-adding it cannot disturb a ball in flight. + /// + /// The shimmer clock is deliberately **not** reset: a late fetch should + /// look like the data changing under a running animation, not like the + /// field restarting. + private func rebuildFloorMap(in scene: Entity) { + floorMapDisplay?.root.removeFromParent() + let display = FloorMap.make(floorMap) + scene.addChild(display.root) + floorMapDisplay = display + FloorMap.update(display, at: floorMapTime) + } + + /// One frame of scenery: the steps breathe, the labels follow them and + /// turn to the player, and the floor map's dots pulse and blink. + /// + /// None of it can affect play. The steps' colliders are swapped with + /// their meshes so a ball always rests on what it looks like it is + /// resting on; the labels and the floor map have no collider at all. + private func stepScenery(deltaTime: TimeInterval) { + guard podiumAnchor != nil else { return } + + // FR-012: hold the podium still while the trophy is mid-animation. + // The clock stops too, so the breath resumes where it left off. + if !isTrophyAnimating { + breathTime += deltaTime + breathe() + } + + if let floorMapDisplay { + floorMapTime += deltaTime + FloorMap.update(floorMapDisplay, at: floorMapTime) + } + + guard let standings else { return } + seatLabels(standings) + } + + /// Moves each step to the rung its height function asks for, mesh and + /// collider together, and re-seats the trophy on top of whichever step + /// it is standing on. + private func breathe() { + for step in PodiumBuilder.Step.allCases { + guard let entity = stepEntities[step], + let ladder = PodiumBreathing.ladder(for: step) + else { continue } + + let index = ladder.index(nearest: PodiumBreathing.height(for: step, at: breathTime)) + guard index != stepRungs[step] else { continue } + stepRungs[step] = index + + let rung = ladder.rungs[index] + PodiumBuilder.resize(entity, mesh: rung.mesh, shape: rung.shape, height: rung.height) + stepHeights[step] = rung.height + } + // Nothing else owns the trophy right now (`isTrophyAnimating` is + // false), so it simply stands on its step's current top face. + trophyEntity?.transform = trophyRestTransform + } + + /// Keeps the three name labels on their steps and facing the player + /// (FR-013). Runs even while the podium holds its breath — the player + /// can still walk around it. + private func seatLabels(_ display: StandingsDisplay.Display) { + guard let arView else { return } + let camera = arView.cameraTransform.translation + for label in display.labels { + StandingsDisplay.seat( + label.entity, + on: label.step, + height: stepHeights[label.step] ?? label.step.height + ) + StandingsDisplay.billboard(label.entity, toward: camera) + } + } + + /// Listens for balls touching the cup — the +1 tier (FR-005). + /// + /// One subscription for the whole scene rather than thirteen (twelve + /// wall segments and the floor disc): the collidable parts of the cup + /// are collected once, by identity, and every other contact in the + /// scene — the table, the steps, ball against ball — is discarded with + /// a set lookup. + /// + /// Note what this subscription is **not** used for: landing inside the + /// cup. That is decided per frame from the ball's position, because a + /// contact cannot tell which side of a 6 mm wall the ball is on — the + /// Gate 4 defect in one sentence. + private func subscribeToCupContacts(in arView: ARView, anchor: AnchorEntity) { + contactSubscription = nil + cupContactIDs = [] + guard let cup = anchor.findEntity(named: PodiumBuilder.Name.cup) else { return } + + var identifiers: Set = [] + collectColliders(of: cup, into: &identifiers) + cupContactIDs = identifiers + + contactSubscription = arView.scene.subscribe(to: CollisionEvents.Began.self) { [weak self] event in + MainActor.assumeIsolated { self?.handleContact(event) } + } + } + + private func collectColliders(of entity: Entity, into identifiers: inout Set) { + if entity.components[CollisionComponent.self] != nil { + identifiers.insert(ObjectIdentifier(entity)) + } + for child in entity.children { + collectColliders(of: child, into: &identifiers) + } + } + + /// Rotation about +Y that turns the podium's front (+Z) toward the + /// camera, so the steps face the player however they were standing. + private func yaw(towardCameraFrom position: SIMD3, in arView: ARView) -> Float { + guard let frame = arView.session.currentFrame else { return 0 } + let camera = frame.camera.transform.columns.3 + let dx = camera.x - position.x + let dz = camera.z - position.z + guard dx * dx + dz * dz > 1e-6 else { return 0 } + return atan2(dx, dz) + } + + private func relocate() { + guard !isTornDown, let arView, let anchor = podiumAnchor else { return } + // The balls are children of this anchor, so removing it takes them + // with it; the controller has to be told so its live-ball cap does + // not stay pinned at the balls that no longer exist. + balls.removeAll() + toss.retireAll() + contactSubscription = nil + cupContactIDs = [] + trophyEntity = nil + cupEntity = nil + currentStep = .gold + isCupMoving = false + pulseEndsAt = 0 + clearScenery() + arView.scene.removeAnchor(anchor) + podiumAnchor = nil + model.phase = hasSeenPlane ? .readyToPlace : .scanning + model.transientHint = nil + coachingOverlay.activatesAutomatically = true + } + + // MARK: - Tossing (FR-004) + + /// A swipe anywhere on the AR view throws a ball. Power comes from how + /// fast the finger travelled *upward*, aim from where the phone points, + /// a nudge left or right from the swipe's horizontal component, and — + /// since Gate 4 — the ball's starting point from where the finger went + /// down. All of it is decided by `TossController`, which this method + /// only feeds and obeys. + @objc + private func handlePan(_ gesture: UIPanGestureRecognizer) { + guard !isTornDown, let arView, podiumAnchor != nil else { return } + // FR-007: no throwing while the round is paused or the summary is + // up. Free practice before "Comenzar" is still allowed — see + // `GameRound`'s doc comment for why. + guard model.round.acceptsFlicks else { + swipeStart = nil + return + } + + switch gesture.state { + case .began: + swipeStart = CACurrentMediaTime() + case .ended: + let now = CACurrentMediaTime() + let started = swipeStart ?? now + swipeStart = nil + let translation = gesture.translation(in: arView) + // Where the finger went *down*: a pan's translation is measured + // from the touch-down point, so subtracting it from the current + // location recovers that point exactly — better than the + // location at `.began`, which UIKit only reports once the + // finger has already slid a few points (FR-004). + let current = gesture.location(in: arView) + let start = CGPoint(x: current.x - translation.x, y: current.y - translation.y) + throwBall( + TossController.Swipe( + translation: SIMD2(Float(translation.x), Float(translation.y)), + duration: now - started + ), + from: start, + at: now + ) + case .cancelled, .failed: + swipeStart = nil + default: + break + } + } + + private func throwBall(_ swipe: TossController.Swipe, from start: CGPoint, at now: TimeInterval) { + guard let arView, + let anchor = podiumAnchor, + let frame = arView.session.currentFrame + else { return } + + let camera = cameraBasis(in: arView, transform: frame.camera.transform) + // `ARView.ray(through:)` owns the projection matrix and the + // interface orientation, so the touch point lands in the world + // correctly without this file having to know either. A nil result + // (no valid camera yet) falls back to the fixed spawn. + let touch = arView.ray(through: start).map { + TossController.TouchRay(origin: $0.origin, direction: $0.direction) + } + + switch toss.flick(swipe, camera: camera, at: now, touch: touch) { + case .rejected(.tooManyLiveBalls): + model.flash("Demasiadas pelotas en juego. Espera un momento.") + case .rejected: + // A drag that was not a toss, or a flick inside the 0.3 s rate + // limit. Both are the player's normal behaviour, not errors, so + // they pass in silence. + break + case .launched(let launch): + spawn(launch, on: anchor) + } + } + + /// The camera pose the throw is computed from, with its sideways axis + /// **measured** rather than assumed (Q4, Gate 5 row 5-g5). + /// + /// `TossController.CameraBasis(transform:orientation:)` already knows + /// how to read ARKit's landscape-right transform in a portrait app, but + /// that is a convention this file would be trusting from documentation. + /// ``measuredScreenRight(in:)`` asks the view itself instead, through + /// the same `ARView.ray(through:)` that the touch-anchored spawn has + /// been using correctly since Gate 5 row 5-g3 — so the axis comes from + /// the projection that is actually on screen. The derived basis is only + /// the fallback, for the frames where the view has no valid camera yet. + private func cameraBasis( + in arView: ARView, + transform: simd_float4x4 + ) -> TossController.CameraBasis { + let derived = TossController.CameraBasis(transform: transform, orientation: .portrait) + guard let measured = measuredScreenRight(in: arView) else { return derived } + return TossController.CameraBasis( + position: derived.position, + forward: derived.forward, + right: measured + ) + } + + /// World-space direction of "one point further right on the screen", + /// read off the view's own projection. + /// + /// Two rays through points on the same screen row differ only by the + /// horizontal sweep of the projection, so the difference of their unit + /// directions points along screen-right — with the interface + /// orientation, the field of view and any lens distortion correction + /// already baked in by `ARView`. + private func measuredScreenRight(in arView: ARView) -> SIMD3? { + let bounds = arView.bounds + guard bounds.width > 4, bounds.height > 4 else { return nil } + let inset = bounds.width / 4 + guard let left = arView.ray(through: CGPoint(x: bounds.midX - inset, y: bounds.midY)), + let right = arView.ray(through: CGPoint(x: bounds.midX + inset, y: bounds.midY)), + simd_length_squared(left.direction) > 1e-12, + simd_length_squared(right.direction) > 1e-12 + else { return nil } + + let delta = simd_normalize(right.direction) - simd_normalize(left.direction) + guard simd_length_squared(delta) > 1e-8 else { return nil } + return simd_normalize(delta) + } + + /// Puts one ball into the scene and pushes it. + /// + /// The ball is parented to the **podium's own anchor** rather than to a + /// new one: RealityKit simulates physics per anchor, so a ball on any + /// other anchor would fall straight through the steps, the cup and the + /// floor plane. + private func spawn(_ launch: TossController.Launch, on anchor: AnchorEntity) { + let ball = PodiumBuilder.makeBall( + id: launch.ball, + radius: toss.tuning.ballRadius, + mass: toss.tuning.ballMass, + friction: toss.tuning.ballFriction, + restitution: toss.tuning.ballRestitution + ) + anchor.addChild(ball) + // The launch is computed in world space; the ball's transform is + // relative to the anchor it now hangs from. + ball.position = anchor.convert(position: launch.origin, from: nil) + ball.applyLinearImpulse(launch.impulse, relativeTo: nil) + balls.append(LiveBall(id: launch.ball, entity: ball)) + } + + // MARK: - Scoring (FR-005, SC-002) + + /// A ball touched something. The +1 tier fires if that something was + /// part of the cup. + private func handleContact(_ event: CollisionEvents.Began) { + guard !isTornDown, !cupContactIDs.isEmpty else { return } + + let hitCupWithA = cupContactIDs.contains(ObjectIdentifier(event.entityA)) + let hitCupWithB = cupContactIDs.contains(ObjectIdentifier(event.entityB)) + guard hitCupWithA != hitCupWithB else { return } + let other = hitCupWithA ? event.entityB : event.entityA + + guard let ball = balls.first(where: { $0.entity === other }) else { return } + creditHit(ball.id) + } + + /// The +1 tier: this ball touched the cup, wherever on it. + private func creditHit(_ ball: TossController.BallID) { + guard !isCupMoving else { return } + // nil unless this is the ball's *first* touch and it has not + // already been paid the make, which is worth the full amount. + guard let award = toss.registerHit(ball) else { return } + // False when the points counted for nobody — the round is paused — + // in which case there is nothing to acknowledge. + guard model.registerScore(award.points) else { return } + + hitHaptics.impactOccurred(intensity: 0.55) + hitHaptics.prepare() + pulse(scale: 1.08, rise: 0.09, fall: 0.12) + } + + /// The +10 tier: this ball is verifiably sitting in the cup. + private func creditMake(_ ball: TossController.BallID) { + guard !isCupMoving else { return } + guard let award = toss.registerMake(ball) else { return } + guard model.registerScore(award.points) else { return } + + celebrateMake() + } + + /// Success cue: the success haptic and a big swell of the trophy, so + /// the score reads even when the phone is at arm's length — followed by + /// the cup jumping to another step (US3). + /// + /// Scoring is suspended for the whole sequence. The trophy is being + /// scaled and then moved, so anything measured in the cup's frame + /// meanwhile is measured against a target that is not where it looks. + private func celebrateMake() { + successHaptics.notificationOccurred(.success) + successHaptics.prepare() + + isCupMoving = true + guard let trophy = trophyEntity else { + isCupMoving = false + return + } + + var swollen = trophyRestTransform + swollen.scale = trophyRestTransform.scale * 1.28 + _ = trophy.move( + to: swollen, + relativeTo: trophy.parent, + duration: Self.makeSwellDuration, + timingFunction: .easeOut + ) + + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(Self.makeSwellDuration * 1_000_000_000) + 10_000_000) + guard let self, !self.isTornDown else { return } + self.relocateCup() + } + } + + // MARK: - Difficulty ramp (spec US3) + + /// Slides the cup to a different podium step, so the next toss cannot + /// reuse the aim that just worked. + /// + /// The trophy is a child of the steps container, never of a step, so + /// this is one animation inside one parent — and the cup stays on the + /// podium's anchor, which is the anchor the balls are simulated on. + /// The move doubles as the return from the celebratory swell. + private func relocateCup() { + guard !isTornDown, let trophy = trophyEntity else { + isCupMoving = false + return + } + + // A ball lying in the cup would be left hanging in the air when the + // cup slides out from under it. It has already been paid, so it + // leaves with the cup. + clearBallsInsideCup() + + currentStep = PodiumBuilder.nextStep(after: currentStep) + let destination = trophyRestTransform + _ = trophy.move( + to: destination, + relativeTo: trophy.parent, + duration: Self.cupMoveDuration, + timingFunction: .easeInOut + ) + + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(Self.cupMoveDuration * 1_000_000_000) + 30_000_000) + guard let self, !self.isTornDown else { return } + // Land exactly on the target pose rather than wherever the + // animation stopped, so the next swell has a clean rest state. + self.trophyEntity?.transform = self.trophyRestTransform + self.isCupMoving = false + } + } + + /// The trophy's pose when nothing is animating: upright, unscaled, on + /// whichever step the cup currently belongs to — at that step's + /// **current** height, because the podium breathes (FR-012). + private var trophyRestTransform: Transform { + Transform( + scale: .one, + rotation: simd_quatf(angle: 0, axis: [0, 1, 0]), + translation: currentStep.trophyPosition( + atHeight: stepHeights[currentStep] ?? currentStep.height + ) + ) + } + + private func clearBallsInsideCup() { + guard cupEntity != nil else { return } + var survivors: [LiveBall] = [] + for ball in balls { + guard let placement = cupPlacement(of: ball.entity), + toss.isInsideCup(placement, ballRadius: toss.tuning.ballRadius) + else { + survivors.append(ball) + continue + } + ball.entity.removeFromParent() + toss.retire(ball.id) + } + balls = survivors + } + + /// A short swell of the trophy, used as the light cue for the +1 tier. + /// Skipped while a make is being celebrated — that animation owns the + /// trophy. + private func pulse(scale: Float, rise: TimeInterval, fall: TimeInterval) { + guard !isCupMoving, let trophy = trophyEntity else { return } + // Claim the trophy for the length of the animation, so the + // breathing update does not overwrite it mid-swell (FR-012). + pulseEndsAt = CACurrentMediaTime() + rise + fall + 0.05 + let rest = trophyRestTransform + var swollen = rest + swollen.scale = rest.scale * scale + _ = trophy.move(to: swollen, relativeTo: trophy.parent, duration: rise, timingFunction: .easeOut) + + Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: UInt64(rise * 1_000_000_000) + 10_000_000) + guard let self, !self.isTornDown, !self.isCupMoving, + let trophy = self.trophyEntity + else { return } + _ = trophy.move( + to: self.trophyRestTransform, + relativeTo: trophy.parent, + duration: fall, + timingFunction: .easeInOut + ) + } + } + + // MARK: - Culling (FR-006, SC-006) + + /// One frame of the game: the round clock, then the scenery, then the + /// balls. + /// + /// The scenery goes before the balls on purpose: a step that has grown + /// this frame has already grown by the time the culler measures where a + /// ball is sitting on it. + private func step(deltaTime: TimeInterval) { + guard !isTornDown else { return } + model.tick(deltaTime) + stepScenery(deltaTime: deltaTime) + stepBalls(deltaTime: deltaTime) + } + + /// Runs once per rendered frame: ages every live ball, tracks how long + /// it has been still, and removes it once `TossController` says it is + /// litter — at rest, off the table, or simply too old. This is what + /// keeps a minute of spam-flicking bounded (SC-006). + /// + /// The one exception is a ball balanced on the cup's rim: culling it + /// there is what Gate 3 row 3.2 saw as balls "disappearing", so instead + /// it gets shoved (``nudgeOffRim(_:)``) and given its time back, up to + /// `Tuning.maximumRimNudges` times, until it visibly drops in or out. + private func stepBalls(deltaTime: TimeInterval) { + guard !balls.isEmpty, let anchor = podiumAnchor else { return } + + var survivors: [LiveBall] = [] + survivors.reserveCapacity(balls.count) + // Collected rather than credited on the spot: a make relocates the + // cup, which culls balls, and that must not happen underneath this + // loop's own rebuild of `balls`. + var landed: [TossController.BallID] = [] + + for var ball in balls { + ball.age += deltaTime + let speed = simd_length(ball.entity.physicsMotion?.linearVelocity ?? .zero) + ball.restingFor = toss.restingDuration( + previous: ball.restingFor, + speed: speed, + delta: deltaTime + ) + let height = ball.entity.position(relativeTo: anchor).y + let placement = cupPlacement(of: ball.entity) + + // FR-005 / SC-002: the make is a geometric fact that has to + // hold for `insideDwell` seconds, not a sensor contact. A ball + // punched through the wall or skimming past crosses the + // interior in a frame or two and never gets there. + let inside = !isCupMoving && placement.map { + toss.isInsideCup($0, ballRadius: toss.tuning.ballRadius) + } ?? false + ball.insideFor = toss.containedDuration( + previous: ball.insideFor, + isInside: inside, + delta: deltaTime + ) + if toss.hasSettledInside(containedFor: ball.insideFor), !toss.hasMade(ball.id) { + landed.append(ball.id) + } + + let reason = toss.cullReason( + age: ball.age, + restingFor: ball.restingFor, + heightAboveAnchor: height + ) + + guard let reason else { + survivors.append(ball) + continue + } + + if reason != .outOfBounds, + toss.mayNudgeOffRim(nudgesSoFar: ball.rimNudges), + let placement, + toss.isPerchedOnRim( + placement, + ballRadius: toss.tuning.ballRadius, + cupOuterRadius: PodiumBuilder.Metrics.cupRimOuterRadius + ) { + nudgeOffRim(ball.entity) + ball.rimNudges += 1 + ball.restingFor = 0 + ball.age = max(0, ball.age - toss.tuning.rimNudgeGrace) + survivors.append(ball) + continue + } + + ball.entity.removeFromParent() + toss.retire(ball.id) + } + + balls = survivors + for ball in landed { + creditMake(ball) + } + } + + // MARK: - Where a ball is relative to the cup + + /// The ball's position in the cup's own frame, in the shape both cup + /// rules — inside (FR-005) and perched on the rim (Gate 3 row 3.2) — + /// are written against. `nil` before the podium is placed. + private func cupPlacement(of ball: ModelEntity) -> TossController.CupPlacement? { + guard let cup = cupEntity else { return nil } + return PodiumBuilder.cupPlacement(ofBallAt: ball.position(relativeTo: cup)) + } + + /// Tips a perched ball off the rim in a random direction, so it falls + /// in or out instead of sitting there until the culler deletes it. + /// + /// The velocity is written straight into `PhysicsMotionComponent` + /// rather than applied as an impulse: a ball that has been still for a + /// second may have been put to sleep by the solver, and setting the + /// motion component is the reliable way to get it moving again. + private func nudgeOffRim(_ ball: ModelEntity) { + let azimuth = Float.random(in: 0..<(2 * .pi)) + var motion = ball.components[PhysicsMotionComponent.self] ?? PhysicsMotionComponent() + motion.linearVelocity += toss.rimNudgeVelocity(azimuth: azimuth) + ball.components.set(motion) + } + + // MARK: ARSessionDelegate / ARCoachingOverlayViewDelegate + // + // The delegate methods themselves are `nonisolated` — ARKit's protocols + // make no isolation promise, and claiming otherwise is a data race in + // the Swift 6 language mode. Each one boils its payload down to a plain + // value and hops to the main actor, where the model and the RealityKit + // scene live. + + nonisolated func session(_ session: ARSession, didAdd anchors: [ARAnchor]) { + guard anchors.contains(where: { $0 is ARPlaneAnchor }) else { return } + Task { @MainActor in self.planeBecameAvailable() } + } + + nonisolated func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) { + let issue = Self.message(for: camera.trackingState) + Task { @MainActor in self.applyTrackingIssue(issue) } + } + + nonisolated func sessionWasInterrupted(_ session: ARSession) { + Task { @MainActor in + self.applyTrackingIssue("Sesión en pausa. Vuelve a apuntar a la superficie.") + } + } + + nonisolated func sessionInterruptionEnded(_ session: ARSession) { + // Deliberately no `run(_:options:)` here: ARKit resumes on its own + // and relocalises to the existing anchor. Resetting would move the + // podium, which the spec forbids. + Task { @MainActor in self.applyTrackingIssue(nil) } + } + + nonisolated func session(_ session: ARSession, didFailWithError error: Error) { + let failure = Self.message(for: error) + Task { @MainActor in self.model.failure = failure } + } + + nonisolated func coachingOverlayViewDidDeactivate(_ coachingOverlayView: ARCoachingOverlayView) { + Task { @MainActor in self.planeBecameAvailable() } + } + + /// A horizontal plane exists, so the player can tap to place. + private func planeBecameAvailable() { + hasSeenPlane = true + if model.phase == .scanning { + model.phase = .readyToPlace + } + } + + // MARK: Hint copy + + nonisolated private static func message(for state: ARCamera.TrackingState) -> String? { + switch state { + case .normal: + return nil + case .notAvailable: + return "Recupera la superficie: mueve el teléfono despacio." + case .limited(.initializing): + return "Preparando la cámara…" + case .limited(.relocalizing): + return "Recuperando la superficie: apunta al mismo sitio de antes." + case .limited(.excessiveMotion): + return "Mueve el teléfono más despacio." + case .limited(.insufficientFeatures): + return "Poca luz o superficie lisa: apunta a una zona con más detalle." + case .limited: + return "Recupera la superficie: mueve el teléfono despacio." + } + } + + nonisolated private static func message(for error: Error) -> String { + guard let arError = error as? ARError else { + return "La cámara falló. Cierra el juego y vuelve a abrirlo." + } + switch arError.code { + case .cameraUnauthorized: + return "Sin permiso de cámara. Actívalo en Ajustes › IPP › Cámara." + case .sensorUnavailable, .sensorFailed: + return "La cámara no está disponible ahora mismo." + default: + return "La sesión de realidad aumentada falló. Cierra el juego y vuelve a abrirlo." + } + } + + // MARK: Teardown (FR-011) + + func tearDown() { + guard !isTornDown else { return } + isTornDown = true + + model.relocateHandler = nil + + for observer in lifecycleObservers { + NotificationCenter.default.removeObserver(observer) + } + lifecycleObservers.removeAll() + + subscriptions.removeAll() + contactSubscription = nil + cupContactIDs = [] + + balls.removeAll() + toss.retireAll() + trophyEntity = nil + cupEntity = nil + isCupMoving = false + pulseEndsAt = 0 + clearScenery() + swipeStart = nil + + coachingOverlay.delegate = nil + coachingOverlay.session = nil + coachingOverlay.removeFromSuperview() + + if let arView { + if let tapRecognizer { + arView.removeGestureRecognizer(tapRecognizer) + } + if let panRecognizer { + arView.removeGestureRecognizer(panRecognizer) + } + arView.scene.anchors.removeAll() + arView.session.pause() + arView.session.delegate = nil + } + tapRecognizer = nil + panRecognizer = nil + podiumAnchor = nil + arView = nil + } + } +} diff --git a/ios/IPP/Game/PodiumBreathing.swift b/ios/IPP/Game/PodiumBreathing.swift new file mode 100644 index 0000000..a721cf9 --- /dev/null +++ b/ios/IPP/Game/PodiumBreathing.swift @@ -0,0 +1,177 @@ +import Foundation +import RealityKit +import simd + +/// The podium breathes (FR-012, added at Gate 5 by owner request). +/// +/// The three steps grow and shrink slowly and out of step with each other, so +/// the cup's height keeps changing and no two throws face the same target. Two +/// halves live here: +/// +/// - the **feel constants and the height function**, which are pure arithmetic +/// and unit-tested off-device, in the same one-line-edit spirit as +/// `TossController.Tuning`; +/// - the **rung ladder**, a set of box meshes and collision shapes built once +/// for a range of heights, which is how the animation is played back. +/// +/// ## Why a ladder rather than a scale +/// +/// Stretching a step with `Entity.scale.y` would be free and perfectly smooth, +/// but it puts the whole feature on one unverifiable assumption: that RealityKit +/// applies a non-uniform entity scale to the entity's `CollisionComponent` +/// shapes as well as to its mesh. If it does not, every ball rests on a +/// phantom step at the original height — the "balls float or sink" failure the +/// task explicitly rules out — and the agent cannot settle the question off +/// device, because ARKit and the physics solver need real hardware. +/// +/// So nothing is ever scaled. Each step's mesh *and* its collision shape are +/// swapped together for a pre-built pair of the right size, and the step is +/// re-seated so it still rests on the surface. The visual and the collider are +/// then the same object by construction, under any RealityKit behaviour. +/// +/// The cost of that choice is quantisation: the height moves in +/// ``rungCount`` discrete rungs rather than continuously. At the constants +/// below that is ~3 mm per rung, which is invisible at arm's length (0.17° at +/// 1 m) and about a tenth of a ball radius, so a resting ball is nudged rather +/// than punched. The pairs are built once, lazily, and shared by every +/// placement — the update loop allocates nothing at all. +@MainActor +enum PodiumBreathing { + + // MARK: - Feel constants + // + // Everything about how the podium breathes is here, so "too fast", "too + // subtle" or "too jumpy" is a one-line edit, exactly like + // `TossController.Tuning`. + + /// Peak deviation of a step's height from its resting value, in metres. + /// + /// 2.5 cm against the 6/9/12 cm steps: the shortest step swings between + /// 3.5 cm and 8.5 cm, so the movement is unmistakable and the podium never + /// approaches zero height. + static let amplitude: Float = 0.025 + + /// Seconds for one full grow-and-shrink cycle, per step. + /// + /// The three are deliberately unequal and not small multiples of each + /// other, so the steps drift in and out of phase for minutes instead of + /// locking into a single pulsing block. + static func period(for step: PodiumBuilder.Step) -> TimeInterval { + switch step { + case .gold: return 4.3 + case .silver: return 3.7 + case .bronze: return 5.1 + } + } + + /// Where in its cycle each step starts, in radians. Thirds of a turn, so + /// the podium is already asymmetric on the first frame. + static func phase(for step: PodiumBuilder.Step) -> Float { + switch step { + case .gold: return 0 + case .silver: return 2 * .pi / 3 + case .bronze: return 4 * .pi / 3 + } + } + + /// How many discrete heights each step can take. Odd, so the resting height + /// is one of them. + static let rungCount = 17 + + // MARK: - The height function (pure) + + /// A step's height at `time` seconds of breathing. + /// + /// `time` is the game's *breathing clock*, not wall time: the coordinator + /// stops advancing it while the trophy is being animated, so the podium + /// freezes for a celebration and then carries on from where it was rather + /// than jumping (FR-012: "the motion pauses during the make + /// celebration/relocation"). + static func height(for step: PodiumBuilder.Step, at time: TimeInterval) -> Float { + height( + base: step.height, + amplitude: amplitude, + period: period(for: step), + phase: phase(for: step), + at: time + ) + } + + /// The oscillator itself, with every input explicit so it can be asserted + /// without reference to the podium's constants. + static func height( + base: Float, + amplitude: Float, + period: TimeInterval, + phase: Float, + at time: TimeInterval + ) -> Float { + guard period > 0, amplitude != 0 else { return base } + let omega = 2 * Float.pi / Float(period) + return base + amplitude * sin(omega * Float(time) + phase) + } + + /// The band a step's height stays inside, for whatever `time`. + static func bounds(for step: PodiumBuilder.Step) -> ClosedRange { + (step.height - amplitude)...(step.height + amplitude) + } + + // MARK: - The rung ladder + + /// One height a step can actually be drawn and collided at. + struct Rung { + let height: Float + let mesh: MeshResource + let shape: ShapeResource + } + + /// The rungs for one step, evenly spaced across ``bounds(for:)``. + struct Ladder { + let rungs: [Rung] + let lowest: Float + let spacing: Float + + /// The rung closest to `height`, clamped to the ends. Pure — the tests + /// drive it directly. + func index(nearest height: Float) -> Int { + guard rungs.count > 1, spacing > 0 else { return 0 } + let raw = (height - lowest) / spacing + guard raw.isFinite else { return 0 } + return min(max(Int(raw.rounded()), 0), rungs.count - 1) + } + + func rung(nearest height: Float) -> Rung { + rungs[index(nearest: height)] + } + } + + /// One ladder per step, built on first use and then reused by every + /// placement — `PodiumBuilder.Step` is a fixed set and the geometry never + /// depends on where the podium was put. + static let ladders: [PodiumBuilder.Step: Ladder] = { + var built: [PodiumBuilder.Step: Ladder] = [:] + for step in PodiumBuilder.Step.allCases { + built[step] = makeLadder(for: step) + } + return built + }() + + static func ladder(for step: PodiumBuilder.Step) -> Ladder? { + ladders[step] + } + + private static func makeLadder(for step: PodiumBuilder.Step) -> Ladder { + let range = bounds(for: step) + let count = max(rungCount, 2) + let spacing = (range.upperBound - range.lowerBound) / Float(count - 1) + let rungs = (0.. Rung in + let height = range.lowerBound + spacing * Float(index) + return Rung( + height: height, + mesh: PodiumBuilder.stepMesh(height: height), + shape: PodiumBuilder.stepShape(height: height) + ) + } + return Ladder(rungs: rungs, lowest: range.lowerBound, spacing: spacing) + } +} diff --git a/ios/IPP/Game/PodiumBuilder.swift b/ios/IPP/Game/PodiumBuilder.swift new file mode 100644 index 0000000..ef3dc7d --- /dev/null +++ b/ios/IPP/Game/PodiumBuilder.swift @@ -0,0 +1,819 @@ +import Foundation +import RealityKit +import UIKit +import simd + +/// Procedural assembly of the "Tiro al Trofeo" podium scene (FR-003). +/// +/// Everything here is a pure function of constants: it builds and returns +/// `Entity` trees and never touches an `ARView`, an `ARSession` or any app +/// state. That is deliberate — RealityKit's entity/mesh layer works in the +/// Simulator even though ARKit does not, so the geometry can be built and +/// asserted on off-device. `selfCheck()` at the bottom is exactly that +/// assertion set; Phase 6a's test target will host it as real unit tests. +/// +/// Units are metres, RealityKit's convention: +X right, +Y up, −Z away from the +/// player. The scene's origin sits **on the surface the player tapped**, so the +/// podium's feet and the invisible floor plane both live at y = 0. +/// +/// No bundled 3D assets and no networking are involved (FR-003, FR-008). +@MainActor +enum PodiumBuilder { + + // MARK: - Entity names + // + // Stable names are the contract the game logic (and the tests) look things + // up by — `findEntity(named:)` rather than index arithmetic. + + enum Name { + static let root = "podium_scene" + static let steps = "podium_steps" + static let goldStep = "step_gold" + static let silverStep = "step_silver" + static let bronzeStep = "step_bronze" + static let trophy = "trophy" + static let cup = "cup" + static let cupWall = "cup_wall" + static let cupFloor = "cup_floor" + static let floor = "floor" + /// Balls are named `ball_` so a scene dump stays readable; the game + /// itself matches them by identity, not by name. + static let ballPrefix = "ball_" + } + + // MARK: - Dimensions + + enum Metrics { + /// Footprint of a single step (square, in metres). + static let stepWidth: Float = 0.10 + static let stepDepth: Float = 0.10 + + /// Step heights — 12 / 9 / 6 cm, per the plan. + static let goldHeight: Float = 0.12 + static let silverHeight: Float = 0.09 + static let bronzeHeight: Float = 0.06 + + /// Silver sits left of gold, bronze right of gold, flush against it. + static let goldX: Float = 0 + static let silverX: Float = -stepWidth + static let bronzeX: Float = stepWidth + + /// Trophy: a small base and stem carrying an open cup. + static let trophyBaseRadius: Float = 0.030 + static let trophyBaseHeight: Float = 0.010 + static let trophyStemRadius: Float = 0.008 + static let trophyStemHeight: Float = 0.030 + + /// Inner radius of the cup. A Phase 3 ball is ~3.5 cm across, so a + /// 10 cm mouth is a fair but not trivial target. + static let cupInnerRadius: Float = 0.050 + static let cupWallThickness: Float = 0.006 + static let cupWallHeight: Float = 0.060 + static let cupFloorThickness: Float = 0.006 + /// Number of box segments approximating the cup's cylindrical wall. + static let cupWallSegments = 12 + /// How far each wall segment leans **outward**, in radians (Gate 3 row + /// 3.2). The cup is therefore a shallow cone rather than a tube: its + /// mouth is wider than its floor, the inner face funnels a ball down + /// into the cup, and — the point of the change — the rim has no level + /// surface anywhere on it for a ball to balance on. + /// + /// 15° against the rim's friction of ``cupRimFriction`` (0.18, whose + /// friction angle is ≈ 10°) means a ball landing on the rim always + /// slides off instead of settling there and being silently culled. + static let cupWallFlare: Float = 15 * .pi / 180 + /// Friction of the rim and inner wall. Deliberately slippery, so the + /// flare above can do its job. + static let cupRimFriction: Float = 0.18 + static let cupRimRestitution: Float = 0.18 + + /// Invisible collision plane standing in for the real table or floor, + /// so missed balls bounce on the surface instead of falling forever. + static let floorExtent: Float = 3.0 + static let floorThickness: Float = 0.02 + + // Derived cup geometry. The game's rim rule (`TossController`) is + // written against these, so the numbers exist once. + + /// Radius of the circle the wall segments' centres sit on. + static var cupRingRadius: Float { cupInnerRadius + cupWallThickness / 2 } + /// That radius at the mouth, once the segments lean out. + static var cupRimRingRadius: Float { + cupRingRadius + (cupWallHeight / 2) * sin(cupWallFlare) + } + /// Outermost horizontal reach of the rim — the "is this ball anywhere + /// near the cup" radius. + static var cupRimOuterRadius: Float { cupRimRingRadius + cupWallThickness } + /// Height of the top of the wall above the cup entity's own origin + /// (which is the underside of the cup's floor disc). + static var cupRimHeight: Float { + cupFloorThickness + cupWallHeight / 2 + (cupWallHeight / 2) * cos(cupWallFlare) + } + + /// Inner radius of the flared wall at `height` above the cup's origin — + /// smallest at the floor, widest at the mouth. + static func cupInnerRadius(atHeight height: Float) -> Float { + let wallMidHeight = cupFloorThickness + cupWallHeight / 2 + return cupInnerRadius + (height - wallMidHeight) * tan(cupWallFlare) + } + + /// Height of the whole trophy above the step it stands on. + static var trophyHeight: Float { + trophyBaseHeight + trophyStemHeight + cupRimHeight + } + } + + // MARK: - Steps as a value (spec US3) + + /// The three podium steps, as something the difficulty ramp can reason + /// about without touching the scene graph. + /// + /// The trophy hangs off the **steps container**, not off a step, precisely + /// so relocation is one `move(to:relativeTo:)` inside a single parent + /// rather than a re-parent mid-animation — and so it never leaves the + /// podium's anchor, which is the anchor the balls are simulated on. + enum Step: String, CaseIterable { + case gold + case silver + case bronze + + var entityName: String { + switch self { + case .gold: return Name.goldStep + case .silver: return Name.silverStep + case .bronze: return Name.bronzeStep + } + } + + var height: Float { + switch self { + case .gold: return Metrics.goldHeight + case .silver: return Metrics.silverHeight + case .bronze: return Metrics.bronzeHeight + } + } + + var x: Float { + switch self { + case .gold: return Metrics.goldX + case .silver: return Metrics.silverX + case .bronze: return Metrics.bronzeX + } + } + + /// Where the trophy stands when it is on this step, in the steps + /// container's frame: centred on the step's top face. + var trophyPosition: SIMD3 { trophyPosition(atHeight: height) } + + /// The same thing for a step that is not at its resting height — + /// the podium breathes (FR-012), so the top face the trophy stands on + /// moves, and everything that rides the step has to be told where it + /// is right now. + func trophyPosition(atHeight height: Float) -> SIMD3 { + [x, height, 0] + } + } + + /// Picks the step the cup jumps to after a make (spec US3: "consecutive + /// scores require re-aiming"). + /// + /// It **cannot** return the step the cup is already on — that one is + /// removed from the pool before the draw rather than being re-rolled away, + /// so there is no unlucky path where the cup stays put. + static func nextStep( + after current: Step, + using generator: inout G + ) -> Step { + let others = Step.allCases.filter { $0 != current } + // `others` always has two elements, so the fallback is unreachable. + return others.randomElement(using: &generator) ?? current + } + + /// ``nextStep(after:using:)`` with the system generator. + static func nextStep(after current: Step) -> Step { + var generator = SystemRandomNumberGenerator() + return nextStep(after: current, using: &generator) + } + + // MARK: - Colors + // + // The exact `LeaderboardRow.medalColor` values, so the podium reads as the + // leaderboard's top three (FR-003, FR-009). + + enum Medal { + static let gold = UIColor(red: 0.95, green: 0.78, blue: 0.18, alpha: 1) + static let silver = UIColor(red: 0.75, green: 0.78, blue: 0.82, alpha: 1) + static let bronze = UIColor(red: 0.80, green: 0.50, blue: 0.20, alpha: 1) + /// The ball wears the brand teal (`LinearGradient.ippBrand`'s light + /// stop, `#13837E`) so it reads as the app's rather than as a stray + /// object, and stays legible against gold and against most desks. + static let ball = UIColor(red: 0x13 / 255, green: 0x83 / 255, blue: 0x7E / 255, alpha: 1) + } + + // MARK: - Public assembly + + /// The whole placeable scene: the three-step podium with its trophy, plus + /// the invisible floor collision plane. The root's origin is the point the + /// player tapped on the detected surface. + static func makeScene() -> Entity { + let root = Entity() + root.name = Name.root + root.addChild(makePodium()) + root.addChild(makeFloor()) + return root + } + + /// The visible podium: three steps plus the trophy on the tallest one. + static func makePodium() -> Entity { + let podium = Entity() + podium.name = Name.steps + + podium.addChild( + makeStep( + name: Name.goldStep, + color: Medal.gold, + height: Metrics.goldHeight, + x: Metrics.goldX + ) + ) + podium.addChild( + makeStep( + name: Name.silverStep, + color: Medal.silver, + height: Metrics.silverHeight, + x: Metrics.silverX + ) + ) + podium.addChild( + makeStep( + name: Name.bronzeStep, + color: Medal.bronze, + height: Metrics.bronzeHeight, + x: Metrics.bronzeX + ) + ) + + // The trophy starts on the gold step but hangs off the steps container + // rather than off the step itself, so the difficulty ramp (US3) can + // slide it to another step with a single `move(to:relativeTo:)` in a + // parent that never changes — and never leaves the podium's anchor, + // which is the anchor the balls' physics runs on. + let trophy = makeTrophy() + trophy.position = Step.gold.trophyPosition + podium.addChild(trophy) + + return podium + } + + /// The box a step of `height` is drawn as. Split out of ``makeStep`` so the + /// breathing ladder (FR-012) can pre-build one per rung. + static func stepMesh(height: Float) -> MeshResource { + MeshResource.generateBox( + width: Metrics.stepWidth, + height: max(height, 0.001), + depth: Metrics.stepDepth, + cornerRadius: 0.004 + ) + } + + /// The collision shape that goes with ``stepMesh(height:)``. The two are + /// always swapped together, which is what keeps a breathing step's collider + /// exactly where its faces are. + static func stepShape(height: Float) -> ShapeResource { + .generateBox( + width: Metrics.stepWidth, + height: max(height, 0.001), + depth: Metrics.stepDepth + ) + } + + /// One podium step, resting on y = 0 with its centre at `x`. + static func makeStep(name: String, color: UIColor, height: Float, x: Float) -> ModelEntity { + let step = ModelEntity(mesh: stepMesh(height: height), materials: [material(color)]) + step.name = name + step.position = [x, height / 2, 0] + addStaticPhysics(to: step, shape: stepShape(height: height)) + return step + } + + /// Re-sizes a step in place, mesh and collider together, and re-seats it so + /// it still rests on the anchor plane (FR-012). + /// + /// The pair comes from `PodiumBreathing`'s pre-built ladder, so this is + /// three assignments and no allocation. + static func resize( + _ step: ModelEntity, + mesh: MeshResource, + shape: ShapeResource, + height: Float + ) { + step.model?.mesh = mesh + step.collision?.shapes = [shape] + step.position.y = height / 2 + } + + /// The trophy: base + stem + an **open** cup. + /// + /// The cup is a ring of wall segments over a floor disc rather than a solid + /// cylinder on purpose — a solid mesh would make the ball bounce off the + /// target instead of settling into it, which is what the scoring rule has + /// to detect. Nothing here is a sensor: whether a ball is inside the cup is + /// decided from its position (``cupPlacement(ofBallAt:)``), not from a + /// contact. + static func makeTrophy() -> Entity { + let trophy = Entity() + trophy.name = Name.trophy + + let gold = material(Medal.gold, roughness: 0.25, metallic: true) + + // Base. + let base = ModelEntity( + mesh: cylinderMesh( + height: Metrics.trophyBaseHeight, + radius: Metrics.trophyBaseRadius + ), + materials: [gold] + ) + base.name = "trophy_base" + base.position = [0, Metrics.trophyBaseHeight / 2, 0] + addStaticPhysics( + to: base, + shape: .generateBox( + width: Metrics.trophyBaseRadius * 2, + height: Metrics.trophyBaseHeight, + depth: Metrics.trophyBaseRadius * 2 + ) + ) + trophy.addChild(base) + + // Stem. + let stem = ModelEntity( + mesh: cylinderMesh( + height: Metrics.trophyStemHeight, + radius: Metrics.trophyStemRadius + ), + materials: [gold] + ) + stem.name = "trophy_stem" + stem.position = [0, Metrics.trophyBaseHeight + Metrics.trophyStemHeight / 2, 0] + addStaticPhysics( + to: stem, + shape: .generateBox( + width: Metrics.trophyStemRadius * 2, + height: Metrics.trophyStemHeight, + depth: Metrics.trophyStemRadius * 2 + ) + ) + trophy.addChild(stem) + + // Cup, sitting on top of the stem. + let cup = makeCup() + cup.position = [0, Metrics.trophyBaseHeight + Metrics.trophyStemHeight, 0] + trophy.addChild(cup) + + return trophy + } + + /// The open cup. Its origin is the underside of its floor disc. + static func makeCup() -> Entity { + let cup = Entity() + cup.name = Name.cup + + let gold = material(Medal.gold, roughness: 0.2, metallic: true) + + // Floor disc. + let floorDisc = ModelEntity( + mesh: cylinderMesh( + height: Metrics.cupFloorThickness, + radius: Metrics.cupInnerRadius + ), + materials: [gold] + ) + floorDisc.name = Name.cupFloor + floorDisc.position = [0, Metrics.cupFloorThickness / 2, 0] + addStaticPhysics( + to: floorDisc, + shape: .generateBox( + width: Metrics.cupInnerRadius * 2, + height: Metrics.cupFloorThickness, + depth: Metrics.cupInnerRadius * 2 + ), + // A soft, grippy floor so balls settle instead of bouncing back out. + friction: 0.9, + restitution: 0.05 + ) + cup.addChild(floorDisc) + + // Wall: `cupWallSegments` thin boxes on a circle, forming a polygonal + // ring that reads as a cylinder and collides like a container. + // + // Each segment also leans outward by `cupWallFlare`, which turns the + // tube into a shallow cone (Gate 3 row 3.2). Two consequences, both + // wanted: the inner face now funnels a ball toward the cup floor, and + // the top face is a slope rather than a ledge, so a ball can no longer + // come to rest on the rim and be culled out of existence. + let segments = Metrics.cupWallSegments + let ringRadius = Metrics.cupRingRadius + // Chord length of one segment, measured at the **mouth**, where the + // flare has pushed the ring out furthest — sizing it at the mid radius + // would open gaps between neighbours at the top. Plus a hair of overlap. + let segmentWidth = 2 * Metrics.cupRimRingRadius * sin(.pi / Float(segments)) * 1.08 + let wallY = Metrics.cupFloorThickness + Metrics.cupWallHeight / 2 + + for index in 0..) -> TossController.CupPlacement { + TossController.CupPlacement( + heightAboveRim: centre.y - Metrics.cupRimHeight, + radialDistance: simd_length(SIMD2(centre.x, centre.z)), + heightAboveCupFloor: centre.y - Metrics.cupFloorThickness, + interiorRadius: Metrics.cupInnerRadius(atHeight: centre.y) + ) + } + + /// Invisible static plane at anchor height (y = 0) standing in for the real + /// table or floor, so missed balls bounce on the surface the player placed + /// the podium on instead of falling through the world. + static func makeFloor() -> Entity { + let floor = Entity() + floor.name = Name.floor + // Top face flush with y = 0. + floor.position = [0, -Metrics.floorThickness / 2, 0] + addStaticPhysics( + to: floor, + shape: .generateBox( + width: Metrics.floorExtent, + height: Metrics.floorThickness, + depth: Metrics.floorExtent + ), + friction: 0.8, + restitution: 0.25 + ) + return floor + } + + // MARK: - Ball (FR-004) + + /// One throwable ball: a small dynamic sphere in the brand teal. + /// + /// Dimensions and physics material come from `TossController.Tuning`, which + /// is where Gate 3's feel feedback gets applied — this function only turns + /// those numbers into an entity. + /// + /// Continuous collision detection is on: at the 6.8 m/s ceiling a 3.5 cm + /// ball moves ~11 cm per 60 Hz step, far further than the cup's 6 mm walls + /// are thick, so discrete stepping would let a hard throw tunnel straight + /// through the cup. (It mattered at Phase 3's 4.5 m/s; it matters more now.) + static func makeBall( + id: UInt64, + radius: Float, + mass: Float, + friction: Float, + restitution: Float + ) -> ModelEntity { + let ball = ModelEntity( + mesh: .generateSphere(radius: radius), + materials: [material(Medal.ball, roughness: 0.35)] + ) + ball.name = "\(Name.ballPrefix)\(id)" + ball.components.set(CollisionComponent(shapes: [.generateSphere(radius: radius)])) + var body = PhysicsBodyComponent( + massProperties: .init(mass: mass), + material: .generate(friction: friction, restitution: restitution), + mode: .dynamic + ) + body.isContinuousCollisionDetectionEnabled = true + ball.components.set(body) + // Present from the start so the culler can read the ball's speed on the + // very first frame instead of treating it as motionless. + ball.components.set(PhysicsMotionComponent()) + return ball + } + + // MARK: - Helpers + + static func material( + _ color: UIColor, + roughness: Float = 0.45, + metallic: Bool = false + ) -> SimpleMaterial { + SimpleMaterial(color: color, roughness: .float(roughness), isMetallic: metallic) + } + + /// Gives an entity a collision shape and an immovable physics body, so the + /// Phase 3 balls bounce off it rather than through it. + static func addStaticPhysics( + to entity: Entity, + shape: ShapeResource, + friction: Float = 0.7, + restitution: Float = 0.2 + ) { + entity.components.set(CollisionComponent(shapes: [shape])) + entity.components.set( + PhysicsBodyComponent( + massProperties: .default, + material: .generate(friction: friction, restitution: restitution), + mode: .static + ) + ) + } + + /// A Y-axis cylinder centred on its own origin, built from scratch. + /// + /// `MeshResource.generateCylinder` is iOS 18+, and the app deploys to + /// iOS 17, so the mesh is generated here instead — still procedural, still + /// no asset files (FR-003). + /// + /// Winding is counter-clockwise seen from outside, RealityKit's front-face + /// convention. With `p(θ) = (r·sin θ, y, r·cos θ)`, increasing θ runs + /// counter-clockwise when viewed from +Y, which fixes the order of every + /// triangle below. + static func cylinderMesh(height: Float, radius: Float, segments: Int = 24) -> MeshResource { + let n = max(3, segments) + let halfHeight = height / 2 + + var positions: [SIMD3] = [] + var normals: [SIMD3] = [] + positions.reserveCapacity(4 * n + 2) + normals.reserveCapacity(4 * n + 2) + + let ring: [SIMD2] = (0.. [String] { + var problems: [String] = [] + let scene = makeScene() + + func requireEntity(_ name: String) -> Entity? { + guard let found = scene.findEntity(named: name) else { + problems.append("missing entity '\(name)'") + return nil + } + return found + } + + func requireCollision(_ entity: Entity, _ label: String) { + guard let collision = entity.components[CollisionComponent.self] else { + problems.append("'\(label)' has no CollisionComponent") + return + } + if collision.shapes.isEmpty { + problems.append("'\(label)' has an empty collision shape list") + } + } + + func requireStaticBody(_ entity: Entity, _ label: String) { + guard let body = entity.components[PhysicsBodyComponent.self] else { + problems.append("'\(label)' has no PhysicsBodyComponent") + return + } + if body.mode != .static { + problems.append("'\(label)' physics body is not .static") + } + } + + // 1. Three steps: medal-coloured, resting on the surface, collidable. + let expectedSteps: [(String, Float, UIColor)] = [ + (Name.goldStep, Metrics.goldHeight, Medal.gold), + (Name.silverStep, Metrics.silverHeight, Medal.silver), + (Name.bronzeStep, Metrics.bronzeHeight, Medal.bronze) + ] + for (name, height, color) in expectedSteps { + guard let step = requireEntity(name) else { continue } + requireCollision(step, name) + requireStaticBody(step, name) + if abs(step.position.y - height / 2) > 0.0001 { + problems.append("'\(name)' does not rest on the anchor plane") + } + guard let model = step.components[ModelComponent.self] else { + problems.append("'\(name)' has no ModelComponent") + continue + } + guard let simple = model.materials.first as? SimpleMaterial else { + problems.append("'\(name)' is not using a SimpleMaterial") + continue + } + if !sameColor(simple.color.tint, color) { + problems.append("'\(name)' is not using its medal color") + } + } + + // 2. Trophy standing on the tallest (gold) step, hanging off the steps + // container so the ramp can slide it between steps (US3). + if let trophy = requireEntity(Name.trophy) { + if trophy.parent?.name != Name.steps { + problems.append("trophy is not parented to the steps container") + } + if simd_distance(trophy.position, Step.gold.trophyPosition) > 0.0001 { + problems.append("trophy does not start on the gold step's top face") + } + } + + // 3. Cup: a closed ring of wall segments over a floor disc. + if let cupFloor = requireEntity(Name.cupFloor) { + requireCollision(cupFloor, Name.cupFloor) + requireStaticBody(cupFloor, Name.cupFloor) + } + let wallSegments = (0..(0, Metrics.cupFloorThickness + ballRadius, 0) + if !toss.isInsideCup(cupPlacement(ofBallAt: restingCentre), ballRadius: ballRadius) { + problems.append("a ball resting on the cup floor does not register as inside") + } + for step in 0...12 { + let height = Float(step) / 12 * Metrics.cupRimHeight + // Centre of a ball pressed against the outer face of the wall. + let outside = SIMD3( + Metrics.cupInnerRadius(atHeight: height) + Metrics.cupWallThickness + ballRadius, + height, + 0 + ) + if toss.isInsideCup(cupPlacement(ofBallAt: outside), ballRadius: ballRadius) { + problems.append( + "a ball touching the cup's outside at \(height) m reads as inside" + ) + } + } + // A ball perched on the rim is neither inside nor allowed to score. + let perched = SIMD3(Metrics.cupRimRingRadius, Metrics.cupRimHeight + ballRadius, 0) + if toss.isInsideCup(cupPlacement(ofBallAt: perched), ballRadius: ballRadius) { + problems.append("a ball perched on the rim reads as inside") + } + + // 4b. Cup geometry after the Gate 3 rim fix: the wall must flare + // outward and still admit the ball at the bottom. + let mouthRadius = Metrics.cupInnerRadius(atHeight: Metrics.cupRimHeight) + let baseRadius = Metrics.cupInnerRadius(atHeight: Metrics.cupFloorThickness) + if mouthRadius <= baseRadius { + problems.append("the cup narrows toward its mouth — the rim flare is inverted") + } + let restingHeight = Metrics.cupFloorThickness + ballRadius + if Metrics.cupInnerRadius(atHeight: restingHeight) <= ballRadius { + problems.append("the flared wall is too tight for a ball to reach the cup floor") + } + + // 5. Floor plane: invisible, static, top face at the anchor height. + if let floor = requireEntity(Name.floor) { + requireCollision(floor, Name.floor) + requireStaticBody(floor, Name.floor) + if floor.components[ModelComponent.self] != nil { + problems.append("floor plane is visible — it must have no ModelComponent") + } + if abs(floor.position.y + Metrics.floorThickness / 2) > 0.0001 { + problems.append("floor plane's top face is not at the anchor height") + } + } + + return problems + } + + /// Compares two colours by sRGB components — `UIColor ==` also compares + /// colour spaces, which a round-trip through the material does not promise + /// to preserve. + private static func sameColor(_ lhs: UIColor, _ rhs: UIColor) -> Bool { + var lr: CGFloat = 0, lg: CGFloat = 0, lb: CGFloat = 0, la: CGFloat = 0 + var rr: CGFloat = 0, rg: CGFloat = 0, rb: CGFloat = 0, ra: CGFloat = 0 + guard lhs.getRed(&lr, green: &lg, blue: &lb, alpha: &la), + rhs.getRed(&rr, green: &rg, blue: &rb, alpha: &ra) + else { return false } + let tolerance: CGFloat = 0.01 + return abs(lr - rr) < tolerance + && abs(lg - rg) < tolerance + && abs(lb - rb) < tolerance + && abs(la - ra) < tolerance + } +} +#endif diff --git a/ios/IPP/Game/StandingsDisplay.swift b/ios/IPP/Game/StandingsDisplay.swift new file mode 100644 index 0000000..34ba8ec --- /dev/null +++ b/ios/IPP/Game/StandingsDisplay.swift @@ -0,0 +1,184 @@ +import CoreText +import Foundation +import RealityKit +import UIKit +import simd + +/// The podium's standings, as scenery (FR-013, added at Gate 5). +/// +/// **Podium labels** for places #1/#2/#3: a name and a score floating in front +/// of each step in that step's medal colour, turned toward the player every +/// frame and riding the step as it breathes (FR-012). The names come entirely +/// from `SyntheticStandings` — the game reads no leaderboard and makes no +/// network request (FR-008, SC-005). +/// +/// > The Star Wars crawl of places #4 and down that used to live here was +/// > **removed in Phase 5C** by owner decision: the space under the podium is +/// > now the floor map of data locations (`FloorMap`). Nothing of the crawl +/// > remains — its band, its fade ramp and its per-frame update are gone, and +/// > with them the only thing in this file that needed a clock. +/// +/// Everything is procedural — text meshes generated from strings, no bundled +/// assets (FR-003) — and none of it has a `CollisionComponent` or a +/// `PhysicsBodyComponent`, so however it moves it cannot touch a ball. +/// +/// ## Cost +/// +/// Six text meshes are built once when the podium is placed and then only ever +/// moved: the labels never change their text, so a frame costs three transform +/// writes and three billboard rotations. Nothing is allocated in the update +/// loop. +@MainActor +enum StandingsDisplay { + + // MARK: - Entity names + + enum Name { + static let labels = "standings_labels" + static func label(rank: Int) -> String { "standing_label_\(rank)" } + } + + // MARK: - Look constants + // + // Same spirit as `TossController.Tuning` and `PodiumBreathing`: if the + // owner says "too small" or "too fast", it is one line here. + + enum Look { + /// Em size of a podium name, in metres. ~9 mm of cap height, which + /// subtends about half a degree at 1 m — comfortably readable. + static let nameSize: Float = 0.013 + /// Em size of the score under the name. + static let pointsSize: Float = 0.0095 + /// Gap between the two lines of a label. + static let lineGap: Float = 0.003 + /// How far above the step's top face the label floats. + static let labelLift: Float = 0.022 + /// How far in front of the step's front face the label hangs, so it + /// never fights the trophy for the same air. + static let labelForward: Float = PodiumBuilder.Metrics.stepDepth / 2 + 0.015 + /// Depth of the text extrusion. Just enough to catch a highlight. + static let extrusion: Float = 0.0012 + /// Font size the meshes are generated at before being scaled down. + /// Core Text tessellates glyphs badly at hundredths of a point, so the + /// text is built big and shrunk. + static let designFontSize: CGFloat = 0.2 + } + + // MARK: - What the coordinator holds on to + + /// One podium label and the step it rides. + struct PodiumLabel { + let step: PodiumBuilder.Step + let entity: Entity + } + + /// Everything the update loop needs, built once at placement. + final class Display { + let labels: [PodiumLabel] + + init(labels: [PodiumLabel]) { + self.labels = labels + } + } + + // MARK: - Building + + /// Hangs the three labels off the steps container and hands back the + /// handles the update loop drives. + /// + /// The labels are children of the **steps container** (the same parent the + /// trophy uses) so they inherit the podium's placement yaw and can be + /// re-seated as their step breathes. + static func attach(to scene: Entity, standings: [SyntheticStandings.Entry]) -> Display { + let podium = scene.findEntity(named: PodiumBuilder.Name.steps) ?? scene + + let labelRoot = Entity() + labelRoot.name = Name.labels + podium.addChild(labelRoot) + + var labels: [PodiumLabel] = [] + for (step, entry) in zip(podiumSteps, standings.prefix(podiumSteps.count)) { + let label = makeLabel(for: entry, tint: tint(for: step)) + labelRoot.addChild(label) + labels.append(PodiumLabel(step: step, entity: label)) + // Seated properly on the first update; this keeps it off the floor + // for the frame before that. + seat(label, on: step, height: step.height) + } + + return Display(labels: labels) + } + + /// The three steps in podium order, so #1 lands on gold. + static let podiumSteps: [PodiumBuilder.Step] = [.gold, .silver, .bronze] + + static func tint(for step: PodiumBuilder.Step) -> UIColor { + switch step { + case .gold: return PodiumBuilder.Medal.gold + case .silver: return PodiumBuilder.Medal.silver + case .bronze: return PodiumBuilder.Medal.bronze + } + } + + /// A two-line label: the short name over the score. + static func makeLabel(for entry: SyntheticStandings.Entry, tint: UIColor) -> Entity { + let label = Entity() + label.name = Name.label(rank: entry.rank) + + let material = UnlitMaterial(color: tint) + let name = makeTextModel(entry.shortName, size: Look.nameSize, material: material) + let points = makeTextModel( + SyntheticStandings.formattedPoints(entry.points), + size: Look.pointsSize, + material: material + ) + name.position.y = (Look.nameSize + Look.lineGap) / 2 + points.position.y = -(Look.pointsSize + Look.lineGap) / 2 + + label.addChild(name) + label.addChild(points) + return label + } + + /// A line of text as a model entity whose **origin is the text's centre**. + /// + /// `MeshResource.generateText` puts the origin at the layout box's corner, + /// which would make every rotation swing the text around its own left edge. + /// Re-centring here means the caller can place, spin and billboard a label + /// by its middle. + static func makeTextModel(_ string: String, size: Float, material: UnlitMaterial) -> ModelEntity { + let scale = size / Float(Look.designFontSize) + let mesh = MeshResource.generateText( + string, + extrusionDepth: Look.extrusion / max(scale, 1e-5), + font: .systemFont(ofSize: Look.designFontSize, weight: .semibold), + containerFrame: .zero, + alignment: .center, + lineBreakMode: .byTruncatingTail + ) + let model = ModelEntity(mesh: mesh, materials: [material]) + model.scale = .init(repeating: scale) + // Transform order is translate ∘ scale, so the offset has to be scaled + // too for the centre to land on the parent's origin. + model.position = -mesh.bounds.center * scale + return model + } + + // MARK: - Per-frame updates + + /// Puts a label back on its step's top face. Called every frame, because + /// the step is breathing under it (FR-012). + static func seat(_ label: Entity, on step: PodiumBuilder.Step, height: Float) { + label.position = [step.x, height + Look.labelLift, Look.labelForward] + } + + /// Turns an entity to face the camera, yaw only, so text stays upright + /// instead of rolling over when the player crouches. + static func billboard(_ entity: Entity, toward camera: SIMD3) { + let here = entity.position(relativeTo: nil) + let dx = camera.x - here.x + let dz = camera.z - here.z + guard dx * dx + dz * dz > 1e-8 else { return } + entity.setOrientation(simd_quatf(angle: atan2(dx, dz), axis: [0, 1, 0]), relativeTo: nil) + } +} diff --git a/ios/IPP/Game/SyntheticMapPins.swift b/ios/IPP/Game/SyntheticMapPins.swift new file mode 100644 index 0000000..1b4b987 --- /dev/null +++ b/ios/IPP/Game/SyntheticMapPins.swift @@ -0,0 +1,79 @@ +import Foundation + +/// The floor map's offline sample (FR-013, Phase 5C). +/// +/// When no backend answers, the app layer hands the game this array instead of +/// real pins (see `MapPinsService.resolve`), so the map under the podium is +/// never a blank plate and the game stays fully playable with no network — +/// which is the property FR-008 and SC-005 protect. +/// +/// **Nothing here is real.** The coordinates are invented on the spot by a +/// seeded generator from four fictional cluster centres placed off the Chilean +/// coast, in water, several kilometres from any of the cities the real data +/// uses. Nobody lives at these coordinates, so an onlooker who reads the map +/// while the game is offline learns nothing about anybody. +/// +/// The clusters have deliberately different populations and spreads, so the +/// density tint (`FloorMap`) has something to show and the sample map looks +/// like the real one rather than like noise. +/// +/// Pure Foundation — no UIKit, no RealityKit, no ARKit, no networking. +enum SyntheticMapPins { + + /// One invented cluster: where it sits, how many pins it holds, and how + /// far they scatter (1σ, in degrees — 0.01° ≈ 1.1 km). + struct Cluster { + let latitude: Double + let longitude: Double + let count: Int + let sigma: Double + } + + /// Fixed by default so the sample map is the same every time it is shown. + /// The bytes spell "IPPMAP". + static let defaultSeed: UInt64 = 0x4950_504D_4150 + + /// Four clusters in open water west of Valparaíso: same latitude band as + /// the real data (so the map's aspect ratio looks familiar) but ~40–60 km + /// offshore, which puts them in the Pacific. + static let clusters: [Cluster] = [ + Cluster(latitude: -33.04, longitude: -72.20, count: 34, sigma: 0.016), + Cluster(latitude: -33.19, longitude: -72.36, count: 22, sigma: 0.011), + Cluster(latitude: -32.92, longitude: -72.41, count: 15, sigma: 0.022), + Cluster(latitude: -33.28, longitude: -72.12, count: 9, sigma: 0.008), + ] + + /// The sample pins. Same seed, same pins, always. + static func pins(seed: UInt64 = defaultSeed) -> [GeoPin] { + var generator = SyntheticStandings.Generator(seed: seed) + var pins: [GeoPin] = [] + pins.reserveCapacity(clusters.reduce(0) { $0 + max(0, $1.count) }) + + for cluster in clusters { + for _ in 0.. (Double, Double) { + let u1 = max(Double.random(in: 0..<1, using: &generator), 1e-12) + let u2 = Double.random(in: 0..<1, using: &generator) + let radius = (-2 * log(u1)).squareRoot() + let angle = 2 * Double.pi * u2 + return (radius * cos(angle), radius * sin(angle)) + } +} diff --git a/ios/IPP/Game/SyntheticStandings.swift b/ios/IPP/Game/SyntheticStandings.swift new file mode 100644 index 0000000..d4f9a89 --- /dev/null +++ b/ios/IPP/Game/SyntheticStandings.swift @@ -0,0 +1,226 @@ +import Foundation + +/// Made-up standings for the podium to display (FR-013, added at Gate 5). +/// +/// **Nothing here comes from the app.** Not from `/api/v1/leaderboard`, not +/// from `AppEnvironment`, not from disk, not from the network — the mini-game +/// is offline and disconnected from the app's data by FR-008 and SC-005, and it +/// stays that way while wearing a leaderboard's clothes. Every name below is +/// invented in this file, from a fixed pool, by a seeded generator. +/// +/// The names are also *obviously* invented, which is the point of the pool: +/// they are built from the Spanish placeholder tradition — Fulano, Mengano, +/// Zutano, Perengano, "de Tal" — plus surnames like *Ficticia*, *Ejemplo* and +/// *Anónimo*. A Spanish-speaking doctor reading "Dra. Marta Ficticia" on the +/// podium cannot mistake it for a colleague's score. +/// +/// Determinism is deliberate: the same seed always produces the same list, so +/// the podium looks the same every time the player places it, the tests can +/// assert on it, and nothing has to be stored anywhere (FR-008: the local best +/// score remains the game's only persistence). +/// +/// Pure Foundation — no UIKit, no RealityKit, no ARKit. +enum SyntheticStandings { + + /// One fictional entry. + struct Entry: Equatable { + /// 1-based place. `1`, `2` and `3` are the podium steps and the only + /// ones displayed since Phase 5C removed the crawl; the rest of the + /// list is still generated so the three shown places are the top of a + /// real ranking rather than the whole of one. + let rank: Int + /// Full display name, e.g. `"Dra. Marta Ficticia"`. + let name: String + /// Title plus surname only, e.g. `"Dra. Ficticia"` — what fits on a + /// 10 cm podium step and still reads at a metre. + let shortName: String + let points: Int + } + + // MARK: - Tuning + + /// How many places the podium knows about. Three go on the steps; the rest + /// only exist so those three sit at the top of a plausible ranking. + static let defaultCount = 10 + + /// Fixed by default so the podium is the same every time it is placed. + /// Nothing about the game depends on the value; it is a parameter so the + /// tests can prove determinism with more than one. The bytes spell "IPP2026". + static let defaultSeed: UInt64 = 0x4950_5032_3032_36 + + // MARK: - Generation + + /// The standings, top first. + /// + /// - Parameters: + /// - count: how many places to invent. Clamped to the size of the name + /// pools, so every name is distinct. + /// - seed: same seed, same list, always. + static func standings(count: Int = defaultCount, seed: UInt64 = defaultSeed) -> [Entry] { + var generator = Generator(seed: seed) + + let wanted = max(0, min(count, min(givenNames.count, surnames.count))) + guard wanted > 0 else { return [] } + + var people = givenNames.shuffled(using: &generator) + let houses = surnames.shuffled(using: &generator) + people.removeSubrange(wanted...) + + var entries: [Entry] = [] + entries.reserveCapacity(wanted) + var points = topPoints(using: &generator) + + for index in 0.. String { + let digits = String(abs(points)) + var grouped = "" + for (offset, digit) in digits.enumerated() { + if offset > 0, (digits.count - offset) % 3 == 0 { + grouped.append(".") + } + grouped.append(digit) + } + return points < 0 ? "-\(grouped)" : grouped + } + + // MARK: - Points + + /// The winner's score. Comfortably above anything a 60-second round can + /// produce (a round is tens of points), so the podium reads as a season + /// table rather than as something the player is about to beat. + private static func topPoints(using generator: inout Generator) -> Int { + 4_200 + 5 * Int(generator.next(upperBound: UInt64(180))) + } + + /// The drop from one place to the next. Always positive, so the list is + /// strictly descending, and small enough that ten places cannot reach zero + /// (9 × 340 = 3_060 against a floor of 4_200). + private static func gap(using generator: inout Generator) -> Int { + 120 + 5 * Int(generator.next(upperBound: UInt64(45))) + } + + // MARK: - The name pools + + fileprivate enum Gender { + case feminine + case masculine + + var title: String { + switch self { + case .feminine: return "Dra." + case .masculine: return "Dr." + } + } + } + + fileprivate struct Person { + let name: String + let gender: Gender + } + + /// A surname that agrees with the given name where Spanish asks it to. + fileprivate struct Surname { + let feminine: String + let masculine: String + + init(_ invariant: String) { + self.feminine = invariant + self.masculine = invariant + } + + init(feminine: String, masculine: String) { + self.feminine = feminine + self.masculine = masculine + } + + func form(for gender: Gender) -> String { + switch gender { + case .feminine: return feminine + case .masculine: return masculine + } + } + } + + /// Ordinary Spanish given names — the half of each name that is *supposed* + /// to look real, so the podium reads like a leaderboard. + fileprivate static let givenNames: [Person] = [ + Person(name: "Marta", gender: .feminine), + Person(name: "Javier", gender: .masculine), + Person(name: "Lucía", gender: .feminine), + Person(name: "Álvaro", gender: .masculine), + Person(name: "Elena", gender: .feminine), + Person(name: "Diego", gender: .masculine), + Person(name: "Nuria", gender: .feminine), + Person(name: "Íñigo", gender: .masculine), + Person(name: "Carmen", gender: .feminine), + Person(name: "Sergio", gender: .masculine), + Person(name: "Irene", gender: .feminine), + Person(name: "Tomás", gender: .masculine), + Person(name: "Pilar", gender: .feminine), + Person(name: "Hugo", gender: .masculine) + ] + + /// The half that makes the whole thing unmistakably fictional: Spain's + /// placeholder people (Fulano/Mengano/Zutano/Perengano, "de Tal") turned + /// into surnames, plus the plainly descriptive ones. + fileprivate static let surnames: [Surname] = [ + Surname("de Tal"), + Surname(feminine: "Ficticia", masculine: "Ficticio"), + Surname("Ejemplo"), + Surname("Placebo"), + Surname(feminine: "Anónima", masculine: "Anónimo"), + Surname("Fulánez"), + Surname("Menganez"), + Surname("Zutánez"), + Surname("Perengánez"), + Surname(feminine: "Imaginaria", masculine: "Imaginario"), + Surname(feminine: "Inventada", masculine: "Inventado"), + Surname(feminine: "Supuesta", masculine: "Supuesto"), + Surname("Demostración"), + Surname(feminine: "Prestada", masculine: "Prestado") + ] + + // MARK: - The generator + + /// SplitMix64 — small, fast, and identical on every device and every OS + /// version, which `SystemRandomNumberGenerator` is not. Determinism is a + /// requirement here (FR-013), not a convenience. + struct Generator: RandomNumberGenerator { + private var state: UInt64 + + init(seed: UInt64) { + // A zero seed is a legal SplitMix64 state, but mixing in the + // constant keeps a caller's "0" from looking special. + self.state = seed &+ 0x9E37_79B9_7F4A_7C15 + } + + mutating func next() -> UInt64 { + state &+= 0x9E37_79B9_7F4A_7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9 + z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB + return z ^ (z >> 31) + } + } +} diff --git a/ios/IPP/Game/TossController.swift b/ios/IPP/Game/TossController.swift new file mode 100644 index 0000000..042608f --- /dev/null +++ b/ios/IPP/Game/TossController.swift @@ -0,0 +1,892 @@ +import Foundation +import simd + +/// The rules of "Tiro al Trofeo", with none of the machinery. +/// +/// Everything a toss needs to be decided — does this swipe count, may we launch +/// right now, how fast and in which direction does the ball leave, has this ball +/// already scored, is it time to clean it up — lives here as plain arithmetic +/// over `simd` vectors. The type imports no ARKit and no RealityKit, so it runs +/// (and is asserted) in the Simulator, where ARKit does not exist. +/// +/// `PodiumARViewContainer.Coordinator` owns one of these and does the other +/// half: it reads the ARKit camera, spawns RealityKit entities and applies the +/// impulses this type hands it. +/// +/// Conventions: +/// - **Screen space** is UIKit's: points, `+x` right, `+y` **down**. So an +/// upward flick has a *negative* `translation.y`. +/// - **World space** is RealityKit's: metres, `+y` up. +/// +/// # Tuning (Gate 3 → Phase 4) +/// +/// Every number the game's feel depends on is a stored property of `Tuning`, so +/// the owner's feedback ("too weak", "too floaty", "curves too much") turns into +/// a one-line edit of ``Tuning/init()``'s defaults rather than a hunt through +/// the AR code. +/// +/// ## Where the launch-speed range comes from +/// +/// Not from eye-balling. The scene fixes the geometry: the cup's floor sits +/// 0.166 m and its mouth 0.226 m above the surface the podium stands on, so a +/// ball has to arrive at ≈ 0.20 m. A player holds the phone ≈ 0.35 m above that +/// surface and the ball leaves ``Tuning/spawnDownOffset`` below the camera, so +/// it starts at ≈ 0.31 m — i.e. it has to **drop** ≈ 0.11 m over the throw. +/// +/// Every throw leaves along `normalize(aim + worldUp · arc + side · lateral)`. +/// With ``Tuning/arc`` = 0.45 that is 24.2° above the aim **when the phone is +/// level** — but the player is looking down at a podium on a table, and the loft +/// is added along *world* up, so a downward tilt eats straight into the launch +/// angle. That is the part the first (4.5 m/s) ceiling missed, and it is why +/// Gate 3 row 3.1 reported having to walk the camera closer. +/// +/// Solving `x = v·cosθ·t`, `Δy = v·sinθ·t − ½gt²` at g = 9.81 m/s²: +/// +/// | Distance | Phone aimed at the cup | Phone tilted 20° down | +/// |---|---|---| +/// | 0.5 m | 2.4 m/s (θ ≈ 13.5°) | 2.7 m/s (θ ≈ 6.6°) | +/// | 0.7 m | 2.9 m/s | 3.6 m/s | +/// | 1.0 m | 3.5 m/s | 4.7 m/s | +/// | 1.5 m | 4.3 m/s | 6.3 m/s | +/// | 2.0 m | 5.0 m/s | 7.7 m/s | +/// +/// So the old 4.5 m/s ceiling topped out at ~1.7 m with a flat aim and **0.9 m** +/// with a 20° tilt — hence "move closer". ``Tuning/maxLaunchSpeed`` is now +/// **6.8 m/s**, which reaches ~2.8 m aimed flat and ~1.7 m at a steep tilt: a +/// hard flick clears the ~2 m the owner asked for without walking, and the +/// 7 m/s-ish worst case is only out of reach if the player insists on staring at +/// their own feet. +/// +/// ## Why the mapping is a curve, not a line +/// +/// Raising the ceiling with the old straight line would have dragged every mid +/// flick up with it (a 1200 pt/s flick would jump 2.8 → 3.8 m/s and sail over a +/// cup 0.7 m away). Instead the flick fraction is raised to +/// ``Tuning/powerCurve`` = 1.8 before it is mixed, which keeps the low and +/// middle of the range where Gate 3 said it already felt right and spends all +/// the new headroom on the hardest flicks: +/// +/// | Upward flick | Launch speed | Lands about | +/// |---|---|---| +/// | 600 pt/s (lazy) | 1.8 m/s | short of 0.4 m | +/// | 1000 pt/s | 2.5 m/s | ≈ 0.55 m | +/// | 1200 pt/s | 2.9 m/s | ≈ 0.75 m | +/// | 1400 pt/s | 3.5 m/s | ≈ 1.0 m | +/// | 1800 pt/s | 4.9 m/s | ≈ 1.8 m | +/// | 2200 pt/s (hard) | 6.8 m/s | ≈ 2.8 m | +/// +/// ``Tuning/fastFlick`` also came down 2400 → 2200 pt/s so the ceiling is +/// actually reachable by a thumb rather than being a number in a file. +/// +/// # Steering (Gate 5 → Phase 5B) +/// +/// The `side` in that formula used to be ARKit's `columns.0`, which is the +/// right-hand axis of a **landscape** screen. In a portrait-locked app that is +/// the phone's long axis, so the swipe's horizontal component was pushing the +/// throw up or down instead of left or right — Gate 5 row 5-g5, "I only see +/// straight ball launches even with diagonal swipes". +/// +/// Two changes fix it, and the second makes the first unfalsifiable: +/// ``CameraBasis/init(transform:orientation:)`` maps the transform onto the +/// real interface orientation, and ``CameraBasis/sideAxis`` then strips the +/// vertical part of whatever it gets. Steering is therefore always horizontal, +/// whatever the phone is doing and wherever the axis came from — the AR side +/// prefers to measure it through `ARView.ray(through:)`, which owns the +/// projection and the orientation. +/// +/// # Scoring (Gate 4 → Phase 5) +/// +/// Two tiers, and the second absorbs the first: touching the cup anywhere pays +/// ``Tuning/hitPoints``, landing inside pays ``Tuning/makePoints`` *in total*. +/// See ``registerHit(_:)`` and ``registerMake(_:)``. +/// +/// "Inside" is a geometric fact about where the ball's centre is +/// (``isInsideCup(_:ballRadius:)``) that has to hold for +/// ``Tuning/insideDwell`` seconds — not a sensor contact. Gate 4 found the +/// contact version awarding makes for balls that only hit the cup's front from +/// the outside; a rule written about the centre of the ball cannot be satisfied +/// from outside the wall at all. +struct TossController { + + // MARK: - Tuning + + /// Every constant the game's feel depends on, in one place. + struct Tuning: Equatable { + + // Ball body — a table-tennis-sized sphere with a little bounce. + + /// Radius of the ball, in metres. 3.5 cm against a 10 cm cup mouth. + var ballRadius: Float = 0.035 + /// Mass, in kilograms. Light enough to be lively, heavy enough that a + /// bounce off the podium does not fling it across the room. + var ballMass: Float = 0.045 + /// Bounciness. Kept well under the cup wall's own value so a ball that + /// hits the rim does not rocket away. + var ballRestitution: Float = 0.35 + var ballFriction: Float = 0.60 + + // Launch power — flick speed in points/second maps onto metres/second. + + /// Speed of the weakest launch, in m/s. Undershoots from ~0.5 m. + var minLaunchSpeed: Float = 1.6 + /// Speed of the hardest launch, in m/s. Reaches ~2.8 m with the phone + /// aimed flat, ~1.7 m with it tilted well down (Gate 3 row 3.1: the + /// previous 4.5 m/s made the player walk closer). + var maxLaunchSpeed: Float = 6.8 + /// Upward flick speed (points/second) that still maps to + /// ``minLaunchSpeed`` — a slow drag. + var slowFlick: Float = 350 + /// Upward flick speed (points/second) that reaches ``maxLaunchSpeed``. + /// A hard thumb flick covers ~250 pt in ~0.11 s. + var fastFlick: Float = 2200 + /// Shape of the flick → speed curve: the normalised flick fraction is + /// raised to this power before it is mixed between the two speeds. + /// + /// `1` is the straight line Phase 3 used. Above 1 the curve sags, so the + /// gentle and middling flicks keep the speeds they had before the + /// ceiling was raised and only the hardest flicks reach the new top end + /// — which is the whole point of 4.0a: more reach, same sweet spot. + var powerCurve: Float = 1.8 + /// Floor on the measured swipe duration, so a gesture the system reports + /// as near-instant cannot divide its way to an absurd flick speed. + var minimumSwipeDuration: TimeInterval = 0.05 + /// How far the finger must travel **upward** for the gesture to count as + /// a toss at all. Stops a stray drag while aiming from firing a ball. + var minimumUpwardTravel: Float = 40 + + // Aim — where the ball goes. + + /// World-up added per unit of camera-forward before normalising, i.e. + /// how much loft is baked into every throw. 0.45 ≈ 24° above the aim. + var arc: Float = 0.45 + /// Points of *horizontal* swipe that produce one full unit of sideways + /// deflection (before the cap below). + var lateralReference: Float = 220 + /// Hard cap on the sideways deflection, as a fraction of the aim vector. + /// Keeps a diagonal flick a nudge rather than a right-angle turn. + var maxLateral: Float = 0.35 + + // Spawn point — just in front of the camera, under the finger. + + /// Metres in front of the camera the ball appears at, so it is outside + /// the near plane and visibly leaves the player's hand. With a touch + /// point this is the *depth* of the spawn plane; the sideways and + /// vertical position come from where the finger went down (FR-004, + /// amended at Gate 4). + var spawnForwardOffset: Float = 0.16 + /// Metres below the camera the ball appears at when there is no touch + /// point to anchor it to (fallback only), so it arcs up into view + /// rather than starting dead centre over the crosshair. + var spawnDownOffset: Float = 0.04 + /// Smallest angle-cosine between the touch ray and the aim that is + /// still treated as "in front of the camera". A ray flatter than this + /// (a bad projection, a touch beyond the frustum) is pulled back in + /// rather than spawning the ball beside or behind the player. + var minimumSpawnCosine: Float = 0.30 + /// Hard cap on how far from the camera's aim axis a touch-anchored + /// spawn may sit, in metres. A corner touch on a wide lens projects to + /// ~0.15 m at the spawn depth; the cap keeps a freak projection from + /// putting the ball an arm's length off to the side. + var maxSpawnLateral: Float = 0.18 + /// Bounds on the spawn's depth in front of the camera, in metres. The + /// lower bound keeps the ball out of the near plane (and out of the + /// player's own hand); the upper bound keeps it from being pushed into + /// whatever the player is standing at. + var minSpawnDepth: Float = 0.10 + var maxSpawnDepth: Float = 0.30 + + // Scoring (FR-005, amended at Gate 4: two tiers). + + /// Points for touching the cup anywhere — outside included. Once per + /// ball. + var hitPoints: Int = 1 + /// Points for a ball that lands *inside* the cup. Once per ball, and it + /// **absorbs** the hit: a made ball is worth `makePoints` in total, so + /// if the hit already paid out, the make only adds the difference. + var makePoints: Int = 10 + + // "Inside the cup", as a geometric fact rather than a sensor contact + // (Gate 4 DEFECT: hits on the cup's front were scoring as makes). + + /// How far *below* the rim, as a fraction of the ball's radius, the + /// ball's centre must be before it counts as inside the cup. + /// + /// Deliberately the same number as ``rimGraceFraction``, which makes + /// the two classifications exact complements on the height axis: a ball + /// is either low enough to be inside or high enough to be perched, never + /// both. A ball resting on the cup floor sits ≈ 0.7 radii below the rim, + /// so there is ≈ 1 cm of margin. + var insideDepthFraction: Float = 0.40 + /// Slack, in metres, on the "the whole ball fits inside the wall" + /// radial test. The wall is a 12-gon, so its corners sit ~3.5 % further + /// out than the nominal inner radius and a ball can settle a couple of + /// millimetres past it. + var insideRadialTolerance: Float = 0.004 + /// How long a ball has to stay geometrically inside the cup before the + /// make is credited, in seconds. + /// + /// This is what separates *landing* in the cup from *passing through* + /// it. The region where a ball counts as inside is only ~4 cm across, + /// so anything still travelling leaves it again within a frame or two; + /// a ball that has actually come to rest in the cup holds it forever. + /// 0.10 s is six frames at 60 Hz — imperceptible as a delay, decisive + /// as a filter. + var insideDwell: TimeInterval = 0.10 + + // Flood control (FR-006, edge case "ball spam"). + + /// Minimum seconds between two launches. + var minimumLaunchInterval: TimeInterval = 0.30 + /// Hard cap on balls simulating at once. + var maximumLiveBalls: Int = 8 + + // Culling (FR-006, edge case "stale balls"). + + /// Speed (m/s) below which a ball counts as motionless. + var restSpeed: Float = 0.06 + /// Seconds a ball may sit still before it is removed. + var restDuration: TimeInterval = 1.0 + /// Seconds any ball may exist, however it is moving. + var maximumAge: TimeInterval = 5.0 + /// Height relative to the anchor plane (metres, so negative is below the + /// table) past which a ball has clearly left the play area. + var minimumHeight: Float = -0.40 + + // Rim rescue (Gate 3 row 3.2) — a ball must never be balanced on the + // cup's rim when the rest-culler fires, because from the player's seat + // that reads as the ball evaporating. + + /// How far *below* the rim's top face (as a fraction of the ball's + /// radius) a resting ball's centre may be and still count as perched on + /// the rim rather than sitting inside the cup. + /// + /// A ball inside the cup rests with its centre ≈ 0.7 radii **below** the + /// rim; one balanced on the rim sits a full radius above it. 0.4 splits + /// those two cases with room to spare on both sides. + var rimGraceFraction: Float = 0.40 + /// Speed (m/s) of the destabilising shove given to a ball caught resting + /// on the rim. Big enough to topple it, small enough that it drops in or + /// falls off rather than being launched. + var rimNudgeSpeed: Float = 0.30 + /// Downward part of that shove, as a fraction of its horizontal part, so + /// the ball commits to falling instead of skating along the rim. + var rimNudgeDownwardBias: Float = 0.35 + /// How many times one ball may be nudged before it is culled anyway. + /// Bounds the worst case; in practice the first shove settles it. + var maximumRimNudges: Int = 3 + /// Seconds of life handed back to a ball each time it is nudged, so the + /// shove has time to work before the same cull rule fires again. + var rimNudgeGrace: TimeInterval = 1.0 + + init() {} + } + + // MARK: - Values crossing the boundary + + /// Identifier the controller hands out per launch. Monotonic and never + /// reused, so a stale collision event can never credit a later ball. + typealias BallID = UInt64 + + /// A finished swipe, in UIKit screen space. + struct Swipe: Equatable { + /// Total finger travel in points: `+x` right, `+y` **down**. + var translation: SIMD2 + /// Seconds between touch-down and lift. + var duration: TimeInterval + + init(translation: SIMD2, duration: TimeInterval) { + self.translation = translation + self.duration = duration + } + + /// Upward travel in points (0 for a sideways or downward swipe). + var upwardTravel: Float { max(-translation.y, 0) } + } + + /// Which way up the screen is, so an ARKit camera transform can be read as + /// *screen* axes (Q4, fixed in Phase 5B). + /// + /// ARKit expresses `ARCamera.transform` in **landscape-right** orientation + /// whatever the device is actually doing: `columns.0` is the direction that + /// points to the right of a screen held in landscape-right, which is the + /// phone's **long** axis. IPP is portrait-locked, so reading `columns.0` as + /// "right" reads a vertical world direction — which is exactly what Gate 5 + /// row 5-g5 saw: diagonal flicks changed the throw's *height* instead of + /// steering it sideways. + /// + /// Rotating the landscape-right screen frame into each interface + /// orientation gives the mapping below (`x` = `columns.0`, `y` = + /// `columns.1`). + enum ScreenOrientation: Equatable, CaseIterable { + /// The only orientation IPP ever runs in — `Info.plist`'s + /// `UISupportedInterfaceOrientations` lists portrait and nothing else. + case portrait + case portraitUpsideDown + case landscapeLeft + case landscapeRight + } + + /// The camera's world-space pose, reduced to the three things a throw needs. + /// + /// Built from an `ARCamera`'s transform by the AR side; `simd_float4x4` is a + /// math type, so taking it here keeps this file free of ARKit. + struct CameraBasis: Equatable { + /// Where the camera is, in world space. + var position: SIMD3 + /// Unit vector the camera looks along. + var forward: SIMD3 + /// Unit vector along the direction the player perceives as "right of + /// the screen". Not necessarily horizontal — see ``sideAxis``, which is. + var right: SIMD3 + + init(position: SIMD3, forward: SIMD3, right: SIMD3) { + self.position = position + self.forward = forward + self.right = right + } + + /// ARKit's camera transform: `+x` right *in landscape-right*, `+y` up + /// *in landscape-right*, `+z` **backward**, so the viewing direction is + /// the negated third column and the screen's right-hand axis depends on + /// the interface orientation (``ScreenOrientation``). + /// + /// The default is `.portrait` because the app is portrait-locked; the + /// other cases exist so the mapping is stated once, testably, instead of + /// being an assumption buried in a column index (Q4). + init(transform: simd_float4x4, orientation: ScreenOrientation = .portrait) { + let landscapeRight = SIMD3( + transform.columns.0.x, transform.columns.0.y, transform.columns.0.z + ) + let landscapeUp = SIMD3( + transform.columns.1.x, transform.columns.1.y, transform.columns.1.z + ) + let screenRight: SIMD3 + switch orientation { + case .landscapeRight: screenRight = landscapeRight + case .landscapeLeft: screenRight = -landscapeRight + case .portrait: screenRight = landscapeUp + case .portraitUpsideDown: screenRight = -landscapeUp + } + self.init( + position: SIMD3(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z), + forward: -SIMD3(transform.columns.2.x, transform.columns.2.y, transform.columns.2.z), + right: screenRight + ) + } + + /// The axis a sideways swipe actually steers along: unit length, + /// **orthogonal to gravity**, pointing to the player's right. + /// + /// Two things are going on, and both are the Q4 fix: + /// + /// 1. ``right`` is the screen's right-hand axis, which the initialiser + /// above now derives for the real interface orientation instead of + /// assuming landscape. + /// 2. Whatever it is, its vertical part is removed. The nudge is a + /// *steering* control — it should change where the throw goes, never + /// how high it goes — so it may not be allowed to borrow from the + /// loft even when the player rolls the phone a few degrees. + /// + /// Fallbacks, in order: a screen-right that is (near) vertical, which + /// only happens with the phone rolled onto its side, falls back to the + /// horizontal axis the aim itself defines, `forward × up`; a camera + /// basis that is degenerate in both falls back to world `+x`. Neither + /// can produce a NaN. + var sideAxis: SIMD3 { + let flattened = right - TossController.worldUp * simd_dot(right, TossController.worldUp) + if simd_length_squared(flattened) > 1e-6 { + return TossController.unit(flattened, fallback: TossController.defaultRight) + } + let aim = TossController.unit(forward, fallback: TossController.defaultForward) + let derived = simd_cross(aim, TossController.worldUp) + if simd_length_squared(derived) > 1e-6 { + return TossController.unit(derived, fallback: TossController.defaultRight) + } + return TossController.defaultRight + } + } + + /// A screen touch turned into a world-space ray by the AR view. + /// + /// The AR side gets this from `ARView.ray(through:)`, which knows the real + /// projection matrix and the interface orientation; this type only decides + /// *where along it* the ball appears. Keeping the ray as the input is what + /// lets the spawn maths be tested without an `ARView` — and what keeps the + /// orientation question out of this file entirely. + struct TouchRay: Equatable { + /// World-space start of the ray (the camera, give or take the near + /// plane). + var origin: SIMD3 + /// World-space direction through the touched point. Need not be unit + /// length. + var direction: SIMD3 + + init(origin: SIMD3, direction: SIMD3) { + self.origin = origin + self.direction = direction + } + } + + /// Everything the AR side needs to put one ball into the scene. + struct Launch: Equatable { + var ball: BallID + /// World-space spawn point. + var origin: SIMD3 + /// World-space velocity the ball should leave with, in m/s. + var velocity: SIMD3 + /// The same thing as an impulse (mass × velocity), which is what + /// RealityKit's `applyLinearImpulse` wants. + var impulse: SIMD3 + } + + /// Why a flick did not become a ball. + enum Rejection: Equatable { + /// The finger did not travel far enough upward to read as a toss. + case notAToss + /// Less than ``Tuning/minimumLaunchInterval`` since the last launch. + case tooSoon + /// ``Tuning/maximumLiveBalls`` are already in flight. + case tooManyLiveBalls + } + + enum Outcome: Equatable { + case launched(Launch) + case rejected(Rejection) + } + + /// The two ways a ball can be worth points (FR-005, amended at Gate 4). + enum ScoreTier: Equatable { + /// The ball touched the cup — anywhere, inside or out. + case hit + /// The ball came to rest inside the cup. + case make + } + + /// A tier that has just been earned, and what it actually pays. + /// + /// ``points`` is not the tier's face value: the make **absorbs** the hit, + /// so a ball that has already been paid its `hitPoints` collects only the + /// remainder when it drops in. A made ball is worth `makePoints` in total, + /// never `makePoints + hitPoints`. + struct Award: Equatable { + var ball: BallID + var tier: ScoreTier + var points: Int + + init(ball: BallID, tier: ScoreTier, points: Int) { + self.ball = ball + self.tier = tier + self.points = points + } + } + + /// Why a ball is being taken out of the scene. + enum CullReason: Equatable { + /// It has been motionless long enough to be litter. + case atRest + /// It fell off the table or was thrown out of the play area. + case outOfBounds + /// It simply ran out of time. + case expired + } + + // MARK: - State + + var tuning: Tuning + + /// Balls currently simulating, newest last. + private(set) var liveBalls: [BallID] = [] + /// Balls that have already been paid the hit tier. Cleared per ball on + /// ``retire(_:)`` — ids are never reused, so nothing can be double-credited + /// afterwards. + private var hitBalls: Set = [] + /// Balls that have already been paid the make tier. + private var madeBalls: Set = [] + private var lastLaunch: TimeInterval? + private var nextBall: BallID = 1 + + init(tuning: Tuning = Tuning()) { + self.tuning = tuning + } + + var liveBallCount: Int { liveBalls.count } + + /// Has this ball already been paid for touching the cup? + func hasHit(_ ball: BallID) -> Bool { hitBalls.contains(ball) } + + /// Has this ball already been paid for landing in the cup? + func hasMade(_ ball: BallID) -> Bool { madeBalls.contains(ball) } + + /// Has this ball earned anything at all? + func hasScored(_ ball: BallID) -> Bool { hasHit(ball) || hasMade(ball) } + + func isLive(_ ball: BallID) -> Bool { liveBalls.contains(ball) } + + // MARK: - Swipe → impulse (pure) + + /// Does this gesture read as a toss, rather than as aiming or a stray drag? + func isToss(_ swipe: Swipe) -> Bool { + swipe.upwardTravel >= tuning.minimumUpwardTravel + } + + /// Launch speed in m/s, clamped to + /// ``Tuning/minLaunchSpeed``…``Tuning/maxLaunchSpeed``. + /// + /// Only the **upward** part of the swipe sets the power. That keeps aiming + /// and power independent: sideways travel steers (see ``lateralDeflection``) + /// and never adds force, so a hard sideways swipe is a gentle, wide throw + /// rather than a rocket. + /// + /// The flick fraction is shaped by ``Tuning/powerCurve`` before it is mixed, + /// so raising the ceiling for hard flicks (4.0a) did not also make every + /// ordinary flick overshoot. The function stays monotonic in flick speed for + /// any positive exponent. + func launchSpeed(for swipe: Swipe) -> Float { + let seconds = Float(max(swipe.duration, tuning.minimumSwipeDuration)) + let flick = swipe.upwardTravel / seconds + let span = max(tuning.fastFlick - tuning.slowFlick, 1) + let t = min(max((flick - tuning.slowFlick) / span, 0), 1) + let shaped = tuning.powerCurve == 1 ? t : pow(t, max(tuning.powerCurve, 0.01)) + return tuning.minLaunchSpeed + shaped * (tuning.maxLaunchSpeed - tuning.minLaunchSpeed) + } + + /// Sideways steering from the horizontal part of the swipe, as a fraction of + /// the aim vector, clamped to ±``Tuning/maxLateral``. Positive is to the + /// player's right, matching the swipe. + func lateralDeflection(for swipe: Swipe) -> Float { + let raw = swipe.translation.x / max(tuning.lateralReference, 1) + return min(max(raw, -tuning.maxLateral), tuning.maxLateral) + } + + /// Where the ball leaves from when there is no touch to anchor it to: just + /// in front of and slightly below the camera, so it is outside the near + /// plane and reads as leaving the hand. + func launchOrigin(camera: CameraBasis) -> SIMD3 { + let aim = Self.unit(camera.forward, fallback: Self.defaultForward) + return camera.position + + aim * tuning.spawnForwardOffset + - Self.worldUp * tuning.spawnDownOffset + } + + /// Where the ball leaves from: **under the finger** (FR-004, amended at + /// Gate 4). + /// + /// The ray is the touch-down point projected into the world by the AR view. + /// The ball is placed where that ray crosses the plane + /// ``Tuning/spawnForwardOffset`` in front of the camera, so the depth is + /// the same wherever the player touches and only the sideways/vertical + /// position follows the finger — which is exactly "the ball departs from + /// under my thumb" without also making corner throws start further away. + /// + /// Two clamps keep the result sane, whatever the projection hands over: + /// the ray's angle off the aim is capped (``Tuning/minimumSpawnCosine``) so + /// the spawn is always *in front of* the camera, and the final point is + /// clamped in depth (``Tuning/minSpawnDepth``…``Tuning/maxSpawnDepth``) and + /// in sideways offset (``Tuning/maxSpawnLateral``) so the ball cannot + /// appear inside the near plane or an arm's length off to one side. + /// + /// Passing `nil` falls back to the fixed spawn above. + func launchOrigin(camera: CameraBasis, touch: TouchRay?) -> SIMD3 { + guard let touch else { return launchOrigin(camera: camera) } + + let aim = Self.unit(camera.forward, fallback: Self.defaultForward) + let direction = Self.unit(touch.direction, fallback: aim) + let cosine = max(simd_dot(direction, aim), tuning.minimumSpawnCosine) + let projected = touch.origin + direction * (tuning.spawnForwardOffset / cosine) + + // Re-express around the camera so depth and sideways offset can be + // clamped independently. + let relative = projected - camera.position + let depth = simd_dot(relative, aim) + let lateral = relative - aim * depth + let lateralLength = simd_length(lateral) + let cappedLateral = lateralLength > tuning.maxSpawnLateral && lateralLength > 0 + ? lateral * (tuning.maxSpawnLateral / lateralLength) + : lateral + let cappedDepth = min(max(depth, tuning.minSpawnDepth), tuning.maxSpawnDepth) + + return camera.position + aim * cappedDepth + cappedLateral + } + + /// World-space launch velocity: the camera's aim, lofted by ``Tuning/arc`` + /// and steered by the swipe, scaled to ``launchSpeed(for:)``. + /// + /// The loft is added along **world** up rather than the camera's up, so the + /// arc is the same whether the player holds the phone level or tilted, and + /// the steering is added along ``CameraBasis/sideAxis``, which is horizontal + /// — so a diagonal flick veers left or right and never trades that for + /// height (Q4, Gate 5 row 5-g5). + func launchVelocity(for swipe: Swipe, camera: CameraBasis) -> SIMD3 { + let aim = Self.unit(camera.forward, fallback: Self.defaultForward) + let side = camera.sideAxis + let heading = aim + + Self.worldUp * tuning.arc + + side * lateralDeflection(for: swipe) + // |aim| == 1 and both additions are < 1, so `heading` cannot collapse to + // zero; the fallback is belt-and-braces for a degenerate camera basis. + return Self.unit(heading, fallback: aim) * launchSpeed(for: swipe) + } + + /// The velocity above expressed as the impulse RealityKit expects (N·s). + func impulse(for swipe: Swipe, camera: CameraBasis) -> SIMD3 { + launchVelocity(for: swipe, camera: camera) * tuning.ballMass + } + + // MARK: - Launching (stateful) + + /// Why a launch would be refused right now, or `nil` if it is allowed. + func launchBlocker(at now: TimeInterval) -> Rejection? { + if liveBalls.count >= tuning.maximumLiveBalls { return .tooManyLiveBalls } + if let lastLaunch, now - lastLaunch < tuning.minimumLaunchInterval { return .tooSoon } + return nil + } + + /// Turns a finished swipe into a ball, subject to the gesture test, the rate + /// limit and the live-ball cap. + /// + /// On success the new ball is recorded as live; the caller is responsible + /// for calling ``retire(_:)`` when it removes the entity again. + /// - Parameter touch: the swipe's *starting* point, projected into the + /// world by the AR view. The ball spawns under it; `nil` falls back to + /// the fixed in-front-of-the-camera spawn. + mutating func flick( + _ swipe: Swipe, + camera: CameraBasis, + at now: TimeInterval, + touch: TouchRay? = nil + ) -> Outcome { + guard isToss(swipe) else { return .rejected(.notAToss) } + if let blocker = launchBlocker(at: now) { return .rejected(blocker) } + + let ball = nextBall + nextBall += 1 + liveBalls.append(ball) + lastLaunch = now + + let velocity = launchVelocity(for: swipe, camera: camera) + return .launched( + Launch( + ball: ball, + origin: launchOrigin(camera: camera, touch: touch), + velocity: velocity, + impulse: velocity * tuning.ballMass + ) + ) + } + + // MARK: - Scoring (FR-005, SC-002) + // + // Two tiers, each paid at most once per ball, and the make absorbs the hit. + // Both are gated on the ball still being live, so a late event about a + // culled ball can never resurrect it. + + /// Credits a ball for touching the cup — the +1 tier, anywhere on the cup, + /// outside included. + /// + /// Returns the award **exactly once** per ball: a thrown ball rattles round + /// the wall segments and fires a contact for each of them, and only the + /// first is worth anything. A ball that has already been paid the make + /// earns nothing more, so the order the two tiers arrive in does not change + /// the total. + mutating func registerHit(_ ball: BallID) -> Award? { + guard liveBalls.contains(ball), !madeBalls.contains(ball) else { return nil } + guard hitBalls.insert(ball).inserted else { return nil } + return Award(ball: ball, tier: .hit, points: tuning.hitPoints) + } + + /// Credits a ball for landing inside the cup — the +10 tier. + /// + /// The payout is `makePoints` minus whatever the hit tier already paid for + /// the same ball, so a made ball is worth 10 in total rather than 11. The + /// caller decides *that* the ball is inside (see ``isInsideCup(_:ballRadius:)`` + /// and ``hasSettledInside(containedFor:)``); this only handles the money. + mutating func registerMake(_ ball: BallID) -> Award? { + guard liveBalls.contains(ball) else { return nil } + guard madeBalls.insert(ball).inserted else { return nil } + let alreadyPaid = hitBalls.contains(ball) ? tuning.hitPoints : 0 + return Award(ball: ball, tier: .make, points: tuning.makePoints - alreadyPaid) + } + + // MARK: - Culling (FR-006) + + /// Runs the "how long has this ball been still" accumulator. + /// + /// Returns the updated resting time: `previous + delta` while the ball is + /// slower than ``Tuning/restSpeed``, and back to zero the moment it moves. + func restingDuration(previous: TimeInterval, speed: Float, delta: TimeInterval) -> TimeInterval { + speed <= tuning.restSpeed ? previous + delta : 0 + } + + /// Whether this ball should leave the scene, and why. + func cullReason( + age: TimeInterval, + restingFor: TimeInterval, + heightAboveAnchor: Float + ) -> CullReason? { + if heightAboveAnchor < tuning.minimumHeight { return .outOfBounds } + if restingFor >= tuning.restDuration { return .atRest } + if age >= tuning.maximumAge { return .expired } + return nil + } + + // MARK: - Where the ball is relative to the cup + + /// One ball's position in the cup's own frame, reduced to the four numbers + /// the two cup rules need. The AR side measures these off the real + /// entities; every rule below is arithmetic over them. + struct CupPlacement: Equatable { + /// Ball centre minus the top of the cup wall, in metres. Positive means + /// the ball is above the mouth. + var heightAboveRim: Float + /// Horizontal distance from the cup's axis, in metres. + var radialDistance: Float + /// Ball centre minus the inner surface of the cup's floor disc, in + /// metres. Negative means the ball is below the cup altogether — on the + /// step, on the stem, on the table. + var heightAboveCupFloor: Float + /// Inner radius of the flared wall **at the ball's own height**, in + /// metres. Widest at the mouth, narrowest at the floor. + var interiorRadius: Float + + init( + heightAboveRim: Float, + radialDistance: Float, + heightAboveCupFloor: Float = 0, + interiorRadius: Float = 0 + ) { + self.heightAboveRim = heightAboveRim + self.radialDistance = radialDistance + self.heightAboveCupFloor = heightAboveCupFloor + self.interiorRadius = interiorRadius + } + } + + // MARK: - Inside the cup (Gate 4 DEFECT, SC-002) + + /// Is the ball **genuinely inside the cup** right now? + /// + /// Gate 4 found makes being awarded for balls that only hit the cup's front + /// wall from the outside. The cause was structural: a sensor volume fires + /// on *contact*, and contact says nothing about which side of the wall the + /// ball is on. This rule says it directly, and cannot be fooled from the + /// outside, because it is a statement about the ball's centre rather than + /// about a touch: + /// + /// 1. the centre is at least ``Tuning/insideDepthFraction`` of a radius + /// **below the rim** — a ball leaning on the outside of the wall at + /// mouth height is above it, a ball perched on the rim is a full radius + /// above it; + /// 2. the centre is **above the cup's floor**, which rules out everything + /// hanging under the mouth (the stem, the gold step, the table); + /// 3. the **whole ball** fits within the wall at that height — the centre + /// is within `interiorRadius − ballRadius` of the axis, plus a few + /// millimetres for the 12-gon's corners. + /// + /// Rule 3 is the one that kills the defect outright: a ball touching the + /// outside of the wall has its centre a full radius *beyond* the wall, + /// around 9 cm from the axis, where the test allows about 2. + /// + /// This is a snapshot, so it is also true for the single frame a ball + /// spends crossing the cup's middle. ``hasSettledInside(containedFor:)`` + /// is the other half: the ball has to *stay* inside. + func isInsideCup(_ placement: CupPlacement, ballRadius: Float) -> Bool { + guard placement.heightAboveRim <= -ballRadius * tuning.insideDepthFraction else { + return false + } + guard placement.heightAboveCupFloor >= 0 else { return false } + let clearance = max(placement.interiorRadius - ballRadius, 0) + tuning.insideRadialTolerance + return placement.radialDistance <= clearance + } + + /// Runs the "how long has this ball been inside the cup" accumulator, in + /// the same shape as ``restingDuration(previous:speed:delta:)``: it adds up + /// while the ball is inside and resets the instant it is not. + func containedDuration( + previous: TimeInterval, + isInside: Bool, + delta: TimeInterval + ) -> TimeInterval { + isInside ? previous + delta : 0 + } + + /// Has the ball been inside long enough to call it a landing rather than a + /// fly-through? See ``Tuning/insideDwell``. + func hasSettledInside(containedFor: TimeInterval) -> Bool { + containedFor >= tuning.insideDwell + } + + // MARK: - Rim rescue (Gate 3 row 3.2) + + /// Is this ball balanced on the cup's rim, rather than resting inside the + /// cup or somewhere else in the scene? + /// + /// Gate 3 row 3.2: balls occasionally came to rest on the 6 mm rim and were + /// then removed by the rest-culler, which looks to the player like the ball + /// vanishing. The geometry answers the question cleanly — a ball in the cup + /// has its centre below the rim, a ball on the rim has it a radius above — + /// so the caller can shove the perched one instead of deleting it. + func isPerchedOnRim( + _ placement: CupPlacement, + ballRadius: Float, + cupOuterRadius: Float + ) -> Bool { + placement.heightAboveRim > -ballRadius * tuning.rimGraceFraction + && placement.radialDistance < cupOuterRadius + ballRadius + } + + /// The shove given to a perched ball: horizontal, in the direction + /// `azimuth` (radians, measured from +X toward +Z), with a downward bias so + /// it drops rather than skates. + /// + /// The direction is a parameter rather than a random draw so the rule stays + /// pure and testable; the AR side picks the angle. It is picked at random + /// there on purpose — a ball tipped off the rim should be as free to fall + /// *in* as to fall out, which is exactly what Gate 3 row 3.2 asked for. + func rimNudgeVelocity(azimuth: Float) -> SIMD3 { + SIMD3( + cos(azimuth) * tuning.rimNudgeSpeed, + -tuning.rimNudgeSpeed * tuning.rimNudgeDownwardBias, + sin(azimuth) * tuning.rimNudgeSpeed + ) + } + + /// The same shove as an impulse (N·s). + func rimNudgeImpulse(azimuth: Float) -> SIMD3 { + rimNudgeVelocity(azimuth: azimuth) * tuning.ballMass + } + + /// Whether a ball that the culler wants to remove has any nudges left. + func mayNudgeOffRim(nudgesSoFar: Int) -> Bool { + nudgesSoFar < tuning.maximumRimNudges + } + + /// Forgets a ball the caller has removed from the scene. + mutating func retire(_ ball: BallID) { + liveBalls.removeAll { $0 == ball } + hitBalls.remove(ball) + madeBalls.remove(ball) + } + + /// Drops all per-ball state, e.g. when the podium is relocated and every + /// ball is cleared out with it. Ids keep counting up. + mutating func retireAll() { + liveBalls.removeAll() + hitBalls.removeAll() + madeBalls.removeAll() + lastLaunch = nil + } + + // MARK: - Vector helpers + + static let worldUp = SIMD3(0, 1, 0) + private static let defaultForward = SIMD3(0, 0, -1) + private static let defaultRight = SIMD3(1, 0, 0) + + /// `simd_normalize` on a zero-length vector is a NaN factory; this is the + /// same operation with a defined answer for that case. + private static func unit(_ vector: SIMD3, fallback: SIMD3) -> SIMD3 { + let lengthSquared = simd_length_squared(vector) + guard lengthSquared > 1e-12, lengthSquared.isFinite else { return fallback } + return vector / lengthSquared.squareRoot() + } +} diff --git a/ios/IPP/Game/TrophyTossView.swift b/ios/IPP/Game/TrophyTossView.swift new file mode 100644 index 0000000..3e0c8e5 --- /dev/null +++ b/ios/IPP/Game/TrophyTossView.swift @@ -0,0 +1,526 @@ +import SwiftUI +import UIKit + +/// Entry screen of the "Tiro al Trofeo" AR mini-game, launched from the +/// leaderboard. +/// +/// Two faces, chosen by whether the game can actually run: +/// - the **AR screen** (`gameScreen`) — a full-bleed `PodiumARViewContainer` +/// with a thin Spanish overlay: hint, Reubicar, close, the round HUD +/// (countdown + score) and the end-of-round summary +/// (FR-002, FR-005, FR-007, FR-009); +/// - the **explainer screen** (`infoScreen`) — the camera-permission story. +/// It asks for the camera when this view appears, the only moment the app +/// ever asks (FR-010), and offers a shortcut to Ajustes when the answer is no. +/// +/// Closing the screen removes the AR container, which tears the session down +/// (FR-011). The game is fully offline: this file makes no network request and +/// never touches `AppEnvironment` or the leaderboard data (FR-008). The floor +/// map's locations arrive from outside as a plain value — see ``floorMap``. +struct TrophyTossView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase + + /// Locations for the floor map under the podium (FR-013). + /// + /// Resolved by the **app layer** before this screen opens: either pins the + /// backend returned or the offline sample. Passing it in rather than + /// fetching it here is what keeps every file under `ios/IPP/Game/` free of + /// networking (question Q5, option A). The default means the game is still + /// launchable from nothing — it just shows the sample. + var floorMap: FloorMapData = FloorMapData(pins: SyntheticMapPins.pins(), isLive: false) + + @State private var permission: ARSupport.CameraPermission = .notDetermined + @State private var isAsking = false + /// Guards against asking twice if the view's task runs again. + @State private var didAsk = false + + /// State shared with the `ARView`. Lives here so it survives the AR view's + /// own updates, and dies with this screen. + @StateObject private var arModel = PodiumARModel() + + private var canPlay: Bool { + ARSupport.isWorldTrackingSupported && permission == .granted + } + + var body: some View { + Group { + if canPlay { + gameScreen + } else { + infoScreen + } + } + .task { await askForCameraIfNeeded() } + .onChange(of: scenePhase) { _, phase in + // Returning from Ajustes: the player may have changed the answer. + if phase == .active { permission = ARSupport.cameraPermission } + // A round must not burn its clock while the app is away (FR-007, + // edge case "backgrounding mid-round"). + arModel.setPaused(phase != .active, reason: .backgrounded) + } + } + + // MARK: - AR screen + + private var gameScreen: some View { + ZStack { + Color.black.ignoresSafeArea() + + PodiumARViewContainer(model: arModel, floorMap: floorMap) + .ignoresSafeArea() + + VStack(spacing: 10) { + topBar + if arModel.isPlaced { + HStack(spacing: 0) { + Spacer(minLength: 0) + relocateButton + } + } + Spacer(minLength: 0) + // Both are hidden behind the summary card rather than dimmed + // under it — the overlay owns the screen while it is up. + if !arModel.round.hasEnded { + if arModel.isPlaced && arModel.round.isIdle { + startButton + } + hintBar + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 20) + + if arModel.round.hasEnded { + summaryOverlay + .transition(.opacity.combined(with: .scale(scale: 0.96))) + } + } + .animation(.snappy(duration: 0.28), value: arModel.round.hasEnded) + } + + private var topBar: some View { + HStack(spacing: 10) { + Button { + dismiss() + } label: { + Label("Cerrar", systemImage: "xmark") + .labelStyle(.iconOnly) + .font(.headline) + .frame(width: 40, height: 40) + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .background(Color.ippInk.opacity(0.55), in: Circle()) + .accessibilityLabel("Cerrar el juego") + + Spacer(minLength: 0) + + if arModel.round.isRunning { + countdownPill + } + + if arModel.isPlaced { + scorePill + } + } + } + + /// Time left in the round (FR-007). Turns gold under ten seconds and says + /// so out loud when the clock is stopped for tracking or backgrounding. + private var countdownPill: some View { + HStack(spacing: 7) { + Image(systemName: arModel.round.isPaused ? "pause.fill" : "timer") + .font(.subheadline.weight(.semibold)) + Text(arModel.round.countdownText) + .font(.title3.weight(.bold)) + .monospacedDigit() + } + .foregroundStyle(countdownTint) + .padding(.horizontal, 14) + .frame(height: 40) + .background(Color.ippInk.opacity(0.65), in: Capsule()) + .accessibilityElement(children: .ignore) + .accessibilityLabel(arModel.round.isPaused ? "Ronda en pausa" : "Tiempo restante") + .accessibilityValue(arModel.round.countdownText) + } + + private var countdownTint: Color { + if arModel.round.isPaused { return .ippGold } + return arModel.round.remaining <= 10 ? .ippGold : .white + } + + /// Points: the round's while one is on, the free-practice tally before that. + private var scorePill: some View { + HStack(spacing: 7) { + Image(systemName: "trophy.fill") + .font(.subheadline.weight(.semibold)) + Text("\(arModel.displayedScore)") + .font(.title3.weight(.bold)) + .monospacedDigit() + .contentTransition(.numericText(value: Double(arModel.displayedScore))) + } + .foregroundStyle(Color.ippGold) + .padding(.horizontal, 14) + .frame(height: 40) + .background(Color.ippInk.opacity(0.65), in: Capsule()) + .animation(.snappy(duration: 0.25), value: arModel.displayedScore) + .accessibilityElement(children: .ignore) + .accessibilityLabel(arModel.round.isIdle ? "Puntos de práctica" : "Puntos de la ronda") + .accessibilityValue("\(arModel.displayedScore)") + } + + /// Moving the podium mid-round would pull the target out from under a + /// running clock, so the button is disabled rather than hidden — the player + /// can see it will come back. + private var relocateButton: some View { + Button { + arModel.relocate() + } label: { + Label("Reubicar", systemImage: "arrow.triangle.2.circlepath") + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 14) + .frame(height: 36) + } + .buttonStyle(.plain) + .foregroundStyle(.white) + .background(Color.ippTeal.opacity(arModel.canRelocate ? 0.92 : 0.35), in: Capsule()) + .opacity(arModel.canRelocate ? 1 : 0.55) + .disabled(!arModel.canRelocate) + } + + /// Starts a timed round (FR-007). Only appears once the podium is down — + /// before that there is nothing to aim at. + private var startButton: some View { + Button { + arModel.startRound() + } label: { + HStack(spacing: 8) { + Image(systemName: "play.fill") + Text("Comenzar") + .font(.headline) + } + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(LinearGradient.ippBrand, in: Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Comenzar una ronda de \(Int(arModel.round.rules.duration)) segundos") + } + + // MARK: - End-of-round summary (FR-007, FR-008) + + private var summaryOverlay: some View { + ZStack { + Color.black.opacity(0.55) + .ignoresSafeArea() + + VStack(spacing: 16) { + summaryCard + summaryActions + } + .padding(.horizontal, 24) + } + } + + private var summaryCard: some View { + VStack(spacing: 10) { + Text("Ronda terminada") + .font(.headline) + .foregroundStyle(.white.opacity(0.85)) + + Text("\(arModel.round.finalScore ?? 0)") + .font(.system(size: 64, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.white) + + Text(scoreWord(arModel.round.finalScore ?? 0)) + .font(.subheadline) + .foregroundStyle(.white.opacity(0.85)) + + if arModel.didSetRecord { + Label("¡Nuevo récord!", systemImage: "sparkles") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.ippGold) + .padding(.horizontal, 14) + .padding(.vertical, 7) + .background(Color.white.opacity(0.16), in: Capsule()) + } else { + Text("Tu mejor marca: \(arModel.bestScore)") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.75)) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 26) + .padding(.horizontal, 20) + .background(LinearGradient.ippBrand) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + + private var summaryActions: some View { + VStack(spacing: 10) { + Button { + arModel.startRound() + } label: { + Text("Jugar de nuevo") + .font(.headline) + .foregroundStyle(Color.ippTealDeep) + .frame(maxWidth: .infinity) + .frame(height: 52) + .background(Color.white, in: Capsule()) + } + .buttonStyle(.plain) + + Button { + arModel.returnToPractice() + } label: { + Text("Seguir practicando") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(Color.white.opacity(0.18), in: Capsule()) + } + .buttonStyle(.plain) + + Button("Salir del juego") { dismiss() } + .font(.subheadline) + .foregroundStyle(.white.opacity(0.8)) + .padding(.top, 2) + } + } + + private func scoreWord(_ score: Int) -> String { + score == 1 ? "punto" : "puntos" + } + + private var hintBar: some View { + HStack(spacing: 10) { + Image(systemName: hintIcon) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(hintTint) + Text(arModel.hint) + .font(.subheadline) + .foregroundStyle(.white) + .multilineTextAlignment(.leading) + Spacer(minLength: 0) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .background(Color.ippInk.opacity(0.72), in: RoundedRectangle(cornerRadius: 14)) + .animation(.easeInOut(duration: 0.2), value: arModel.hint) + } + + private var hintIcon: String { + if arModel.failure != nil { return "exclamationmark.triangle.fill" } + if arModel.trackingIssue != nil { return "viewfinder.trianglebadge.exclamationmark" } + return arModel.isPlaced ? "trophy.fill" : "hand.tap.fill" + } + + private var hintTint: Color { + if arModel.failure != nil || arModel.trackingIssue != nil { return .ippGold } + return .white.opacity(0.85) + } + + // MARK: - Explainer screen + + private var infoScreen: some View { + NavigationStack { + ZStack { + Color.ippScreen.ignoresSafeArea() + + ScrollView { + VStack(spacing: 16) { + heroCard + content + } + .padding(.horizontal, 20) + .padding(.vertical, 18) + } + } + .navigationTitle("Tiro al Trofeo") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("Cerrar") { dismiss() } + } + } + } + } + + // MARK: - Sections + + private var heroCard: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + Image(systemName: "trophy.fill") + .font(.title2) + .foregroundStyle(.white) + Text("Tiro al Trofeo") + .font(.title3.weight(.bold)) + .foregroundStyle(.white) + } + Text("Apunta a una mesa o al suelo, coloca el podio y encesta la pelota en la copa.") + .font(.subheadline) + .foregroundStyle(.white.opacity(0.85)) + Text("Juego sin conexión · no cambia tus puntos del ranking.") + .font(.caption) + .foregroundStyle(.white.opacity(0.75)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(18) + .background(LinearGradient.ippBrand) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + @ViewBuilder + private var content: some View { + if !ARSupport.isWorldTrackingSupported { + unsupportedState + } else { + switch permission { + case .granted: openingState + case .denied: deniedState + case .restricted: restrictedState + case .notDetermined: askingState + } + } + } + + /// Reachable only if support disappears between the leaderboard check and + /// this screen — the entry point is already gated on the same flag. + private var unsupportedState: some View { + card(icon: "iphone.slash", tint: .ippMuted, title: "No disponible aquí") { + Text(ARSupport.unsupportedMessage) + .font(.callout) + .foregroundStyle(Color.ippBody) + } + } + + private var askingState: some View { + card(icon: "camera.fill", tint: .ippTeal, title: "Permiso de cámara") { + VStack(alignment: .leading, spacing: 12) { + Text("El juego necesita la cámara para ver la superficie donde se apoya el podio. Las imágenes no se graban ni se envían.") + .font(.callout) + .foregroundStyle(Color.ippBody) + if isAsking { + HStack(spacing: 8) { + ProgressView() + Text("Esperando tu respuesta…") + .font(.caption) + .foregroundStyle(Color.ippMuted) + } + } else { + Button("Permitir cámara") { + Task { await askForCamera() } + } + .buttonStyle(.borderedProminent) + .tint(.ippTeal) + } + } + } + } + + private var deniedState: some View { + card(icon: "camera.badge.ellipsis", tint: .ippGold, title: "Sin acceso a la cámara") { + VStack(alignment: .leading, spacing: 12) { + Text("No podemos mostrar el podio sin la cámara. Puedes activarla en Ajustes › IPP › Cámara y volver a intentarlo.") + .font(.callout) + .foregroundStyle(Color.ippBody) + Button("Abrir Ajustes") { openSettings() } + .buttonStyle(.borderedProminent) + .tint(.ippTeal) + } + } + } + + private var restrictedState: some View { + card(icon: "lock.fill", tint: .ippGold, title: "Cámara restringida") { + VStack(alignment: .leading, spacing: 12) { + Text("El acceso a la cámara está bloqueado en este dispositivo (control parental o perfil de gestión), así que el mini-juego no puede abrirse.") + .font(.callout) + .foregroundStyle(Color.ippBody) + Button("Abrir Ajustes") { openSettings() } + .buttonStyle(.bordered) + .tint(.ippTeal) + } + } + } + + /// Only ever on screen for the frame between the player granting the camera + /// and `canPlay` swapping this whole screen for `gameScreen`. + private var openingState: some View { + card(icon: "camera.fill", tint: .ippTeal, title: "Abriendo la cámara") { + HStack(spacing: 8) { + ProgressView() + Text("Preparando la vista de realidad aumentada…") + .font(.callout) + .foregroundStyle(Color.ippBody) + } + } + } + + // MARK: - Building blocks + + private func card( + icon: String, + tint: Color, + title: String, + @ViewBuilder content: () -> Content + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(tint.opacity(0.14)) + .frame(width: 44, height: 44) + Image(systemName: icon) + .font(.title3) + .foregroundStyle(tint) + } + Text(title) + .font(.title3.weight(.semibold)) + .foregroundStyle(Color.ippInk) + Spacer(minLength: 0) + } + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + .background(Color.white) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .overlay( + RoundedRectangle(cornerRadius: 16) + .stroke(Color.ippBorder, lineWidth: 1) + ) + } + + // MARK: - Permission flow + + /// Runs when the game screen appears — this is the one place the app is + /// allowed to raise the camera prompt (FR-010). + private func askForCameraIfNeeded() async { + permission = ARSupport.cameraPermission + guard ARSupport.isWorldTrackingSupported, + permission == .notDetermined, + !didAsk + else { return } + await askForCamera() + } + + private func askForCamera() async { + guard !isAsking else { return } + didAsk = true + isAsking = true + permission = await ARSupport.requestCameraAccess() + isAsking = false + } + + private func openSettings() { + guard let url = URL(string: UIApplication.openSettingsURLString) else { return } + UIApplication.shared.open(url) + } +} diff --git a/ios/IPP/Models/MapPin.swift b/ios/IPP/Models/MapPin.swift new file mode 100644 index 0000000..72a3e02 --- /dev/null +++ b/ios/IPP/Models/MapPin.swift @@ -0,0 +1,58 @@ +import Foundation + +/// One anonymized data location: a latitude/longitude and nothing else. +/// +/// This is the *only* shape in which location data reaches the AR mini-game. +/// The backend's `/api/v1/map-pins` payload also carries a record id and an +/// `anchorKey`; both are dropped at the app layer (``MapPinsService``) so the +/// game — which renders these on a floor plane visible to anyone standing near +/// the phone — never holds anything that could identify a record. +struct GeoPin: Equatable, Hashable, Sendable { + let latitude: Double + let longitude: Double + + init(latitude: Double, longitude: Double) { + self.latitude = latitude + self.longitude = longitude + } + + /// Both coordinates are real numbers in range. The projection filters on + /// this rather than trusting the wire. + var isUsable: Bool { + latitude.isFinite && longitude.isFinite + && abs(latitude) <= 90 && abs(longitude) <= 180 + } +} + +/// What the app layer hands the game for its floor map (FR-013). +/// +/// `isLive` is not used to decide anything — it only picks the caption, so a +/// person looking at the podium can tell real data from the offline sample +/// without opening a debugger. The game does no networking either way +/// (FR-008, Q5 option A). +struct FloorMapData: Equatable { + let pins: [GeoPin] + let isLive: Bool + + init(pins: [GeoPin], isLive: Bool) { + self.pins = pins + self.isLive = isLive + } + + static let none = FloorMapData(pins: [], isLive: false) +} + +/// Wire model for `GET /api/v1/map-pins`, the backend's public, anonymized +/// pin list. Read-only; the app never POSTs to it. +struct MapPinsResponse: Decodable { + struct Pin: Decodable { + let latitude: Double + let longitude: Double + } + + let pins: [Pin] + + var coordinates: [GeoPin] { + pins.map { GeoPin(latitude: $0.latitude, longitude: $0.longitude) } + } +} diff --git a/ios/IPP/Resources/Info.plist b/ios/IPP/Resources/Info.plist index cc86768..519875c 100644 --- a/ios/IPP/Resources/Info.plist +++ b/ios/IPP/Resources/Info.plist @@ -22,6 +22,10 @@ 1 LSRequiresIPhoneOS + NSCameraUsageDescription + IPP usa la cámara únicamente en el mini-juego de realidad aumentada "Tiro al Trofeo", para detectar una superficie y colocar el podio sobre ella. No se graban ni se envían imágenes. + NSLocalNetworkUsageDescription + IPP busca el servidor de la clínica en tu red local (Wi-Fi) para cargar y guardar las fichas, el ranking y el mapa de ubicaciones. No se contacta ningún servicio externo. UILaunchScreen UISupportedInterfaceOrientations @@ -30,6 +34,14 @@ BackendURL http://localhost:3334 + + BackendCandidates + + http://192.168.100.15:3334 + http://192.168.100.11:3334 + WebURL http://localhost:5174 NSAppTransportSecurity diff --git a/ios/IPP/Services/APIPatientStore.swift b/ios/IPP/Services/APIPatientStore.swift index 20a74f3..cfeb149 100644 --- a/ios/IPP/Services/APIPatientStore.swift +++ b/ios/IPP/Services/APIPatientStore.swift @@ -4,7 +4,11 @@ import Foundation // IPP backend, which persists to Neon Postgres. The backend extracts id, RUT // and coords for indexing; the full record lives in a JSONB column. final class APIPatientStore: PatientStore { - let baseURL: URL + /// Mutable so `AppEnvironment.resolveBackend()` can re-point the app at the + /// LAN host it found at launch (Phase 5C). Written once, from the main + /// actor, before the first request — same convention as the two provider + /// closures below. + var baseURL: URL /// Closure returning the current doctor name (or nil). Read lazily so a /// rename via the leaderboard UI takes effect on the next save. var doctorNameProvider: () -> String? = { nil } diff --git a/ios/IPP/Services/AppEnvironment.swift b/ios/IPP/Services/AppEnvironment.swift index d77f182..0bc8705 100644 --- a/ios/IPP/Services/AppEnvironment.swift +++ b/ios/IPP/Services/AppEnvironment.swift @@ -9,6 +9,10 @@ final class AppEnvironment: ObservableObject { let schemaService: SchemaService /// URL of the web dashboard (map + búsqueda + feedback) embedded in-app. let webURL: URL + /// Reads the backend's anonymized pins for the mini-game's floor map. + /// Lives here, not in the game, so `ios/IPP/Game/` stays network-free + /// (FR-008, question Q5 option A). + let mapPins = MapPinsService() var store: PatientStore { apiStore } var schema: FormSchema { schemaService.schema } @@ -16,18 +20,43 @@ final class AppEnvironment: ObservableObject { @Published var lastAnchor: AnchorResponse? @Published var lastError: String? + /// What `Info.plist` asked for — `http://localhost:3334` in a stock build. + /// Kept so the launch probe can tell "the user configured a host" from + /// "nobody configured anything" (see `BackendLocator.candidates`). + private let configuredBackendURL: URL + /// The base URL every client is actually using right now. Starts at the + /// configured value and moves once, if the launch probe finds a LAN host. + @Published private(set) var backendURL: URL + + /// The one and only backend resolution, kept so that anything that needs a + /// URL can *wait* for it instead of racing it (question Q8). + private var resolution: Task? + + /// What the resolution actually asks the network. A stored closure only so + /// a test can hold resolution open and prove that dependent work waits; + /// production never replaces it. + var probeBackend: @Sendable (_ configured: URL, _ isSimulator: Bool) async -> URL? = { + configured, isSimulator in + await BackendLocator.probe( + BackendLocator.candidates(configured: configured, isSimulator: isSimulator) + ) + } + init( apiStore: APIPatientStore, effectStream: EffectStreamClient, session: SessionService, schemaService: SchemaService, - webURL: URL + webURL: URL, + backendURL: URL ) { self.apiStore = apiStore self.effectStream = effectStream self.session = session self.schemaService = schemaService self.webURL = webURL + self.configuredBackendURL = backendURL + self.backendURL = backendURL // Doctor name on every save comes from the session - the username // becomes the leaderboard attribution. Read via a nonisolated, // thread-safe snapshot so the API store can call it off the main actor. @@ -54,11 +83,73 @@ final class AppEnvironment: ObservableObject { effectStream: EffectStreamClient(baseURL: url), session: SessionService(), schemaService: SchemaService(baseURL: url), - webURL: webURL + webURL: webURL, + backendURL: url ) } + // MARK: - Finding the backend on the LAN (Phase 5C) + + /// Probes the candidate hosts once at launch and re-points every client at + /// whichever answers `/health` first. + /// + /// On a phone the bundled `http://localhost:3334` can never work, so this + /// is what makes the *whole* app — login, patients, field stats, schema, + /// leaderboard — reach the Mac running the backend. In the Simulator the + /// candidate list is just the configured URL, so behaviour there is + /// unchanged. + /// + /// Failure is silent and harmless: nothing moves, the app keeps the + /// configured URL, and every screen shows the offline state it always did. + func resolveBackend() async { + await backendReady() + } + + /// Waits until the backend URL is settled, starting the probe if nobody + /// has yet, and returns immediately once it is (question Q8). + /// + /// Every request in the app goes through this first, so the launch window + /// in which a screen could fire a request at the *unresolved* URL is + /// closed: a user who taps straight into Ranking from a cold launch waits + /// out the probe (~10 ms when the first host answers) instead of sending + /// one doomed request to `localhost` and self-healing on refresh. + /// + /// Resolution happens exactly once per app run — the first caller starts + /// the task, everyone else awaits the same one — so this is free after + /// launch. + func backendReady() async { + if let resolution { + return await resolution.value + } + let task = Task { @MainActor [weak self] in + guard let self else { return } + await self.performResolution() + } + resolution = task + await task.value + } + + private func performResolution() async { + guard let found = await probeBackend(configuredBackendURL, BackendLocator.isSimulator), + found != backendURL + else { return } + + apiStore.baseURL = found + effectStream.baseURL = found + schemaService.baseURL = found + backendURL = found + } + + /// Read-only fetch of the anonymized map pins for the mini-game's floor + /// map. `nil` when the backend is unreachable — the caller substitutes the + /// offline sample (`MapPinsService.resolve`). + func fetchMapPins() async -> [GeoPin]? { + await backendReady() + return await mapPins.fetch(baseURL: backendURL) + } + func saveAndAnchor(_ patient: Patient) async -> Patient? { + await backendReady() do { guard let wallet = session.wallet else { lastError = "Inicia sesión para guardar y anclar." @@ -81,16 +172,19 @@ final class AppEnvironment: ObservableObject { } func fetchLeaderboard() async throws -> [LeaderboardEntry] { - try await apiStore.fetchLeaderboard() + await backendReady() + return try await apiStore.fetchLeaderboard() } /// Records a search for ranking points (+10). Best-effort, viewer-safe. func recordSearch() async { + await backendReady() await apiStore.logSearchEvent() } /// Per-field comparison stats for the patient form (nil for viewers/errors). func fetchFieldStats(lat: Double?, lng: Double?) async -> FieldStatsBundle? { - await apiStore.fetchFieldStats(lat: lat, lng: lng) + await backendReady() + return await apiStore.fetchFieldStats(lat: lat, lng: lng) } } diff --git a/ios/IPP/Services/BackendLocator.swift b/ios/IPP/Services/BackendLocator.swift new file mode 100644 index 0000000..416c20b --- /dev/null +++ b/ios/IPP/Services/BackendLocator.swift @@ -0,0 +1,139 @@ +import Foundation + +/// Finds the IPP backend on the local network at launch (Phase 5C task 5C.1). +/// +/// The bundled `BackendURL` is `http://localhost:3334`, which is right in the +/// Simulator and useless on a phone. On device the app therefore asks a short, +/// ordered list of candidates for `/health` and keeps the first one that +/// answers; `AppEnvironment.resolveBackend()` then points every client at it, +/// so login, patients, field stats, the schema and the leaderboard all follow. +/// +/// The candidate hosts live in `Info.plist` under ``candidatesInfoKey``, next +/// to `BackendURL` and `WebURL` — **not** in this file (question Q7). A demo +/// Mac usually offers more than one address for the same server (an Ethernet +/// and a Wi-Fi interface on the same `/24`, say), and only the phone can say +/// which one its subnet actually reaches, so the list is ordered and tried in +/// turn. Pointing the app at a different machine is a plist edit, not a source +/// edit, and an empty or missing list simply means "only use `BackendURL`". +/// +/// Everything about *which* URLs are tried and *in what order* is a pure +/// function (``candidates(configured:isSimulator:lan:)``) so it is unit-tested +/// off-device; only ``probe(_:timeout:session:)`` touches the network. +enum BackendLocator { + + /// `Info.plist` key holding the ordered array of candidate base URLs. + static let candidatesInfoKey = "BackendCandidates" + + /// Turns the raw `Info.plist` value into URLs, ignoring anything unusable. + /// + /// Pure, so a malformed plist is a test case rather than a crash: a missing + /// key, a wrong type, an empty array and junk strings all degrade to "no + /// candidates", which just leaves the configured `BackendURL` in charge. + static func parseCandidates(_ raw: Any?) -> [URL] { + guard let strings = raw as? [String] else { return [] } + return strings.compactMap { string in + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + let url = URL(string: trimmed), + url.scheme != nil, + url.host != nil + else { return nil } + return url + } + } + + /// The candidate hosts this build was configured with, in order. + static var lanCandidates: [URL] { + parseCandidates(Bundle.main.object(forInfoDictionaryKey: candidatesInfoKey)) + } + + /// How long a single `/health` request may take before the next candidate + /// is tried. Short: on a LAN a live host answers in single-digit + /// milliseconds, and a dead one should not hold up the launch. + static let defaultTimeout: TimeInterval = 1.5 + + static var isSimulator: Bool { + #if targetEnvironment(simulator) + return true + #else + return false + #endif + } + + /// Hosts that only ever mean "this machine". + static func isLoopback(_ url: URL) -> Bool { + guard let host = url.host?.lowercased() else { return false } + return host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" + } + + /// The candidate list, most likely first, with duplicates removed. + /// + /// - In the **Simulator** only the configured URL is tried. `localhost` + /// there is the Mac, which is exactly where the backend runs, and + /// reaching out to the LAN would be both pointless and slower. + /// - On **device**, a configured loopback URL cannot possibly work, so the + /// two LAN addresses go first and the configured URL stays at the back as + /// a last resort. + /// - A configured URL that is *not* loopback is someone deliberately + /// pointing the app somewhere, so it is tried first and the LAN addresses + /// become the fallback. + /// + /// `lan` defaults to the `Info.plist` list; it is a parameter only so the + /// ordering can be tested against a fixed list. + static func candidates( + configured: URL, + isSimulator: Bool, + lan: [URL] = BackendLocator.lanCandidates + ) -> [URL] { + let ordered: [URL] + if isSimulator { + ordered = [configured] + } else if isLoopback(configured) { + ordered = lan + [configured] + } else { + ordered = [configured] + lan + } + + var seen = Set() + return ordered.filter { seen.insert($0.absoluteString).inserted } + } + + static func healthURL(for base: URL) -> URL { + base.appendingPathComponent("health") + } + + /// Asks each candidate for `/health` in turn and returns the first that + /// answers 2xx. `nil` when none does — the caller then keeps whatever base + /// URL it already had, which is the offline case. + static func probe( + _ candidates: [URL], + timeout: TimeInterval = defaultTimeout, + session: URLSession? = nil + ) async -> URL? { + let session = session ?? makeSession(timeout: timeout) + for candidate in candidates { + var request = URLRequest(url: healthURL(for: candidate)) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + do { + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode) + else { continue } + return candidate + } catch { + continue + } + } + return nil + } + + static func makeSession(timeout: TimeInterval) -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = timeout + configuration.waitsForConnectivity = false + return URLSession(configuration: configuration) + } +} diff --git a/ios/IPP/Services/EffectStreamClient.swift b/ios/IPP/Services/EffectStreamClient.swift index c9b3758..ed32c1d 100644 --- a/ios/IPP/Services/EffectStreamClient.swift +++ b/ios/IPP/Services/EffectStreamClient.swift @@ -38,7 +38,9 @@ enum EffectStreamError: Error, LocalizedError { } final class EffectStreamClient { - let baseURL: URL + /// Mutable so `AppEnvironment.resolveBackend()` can re-point the client at + /// the LAN host discovered at launch (Phase 5C). + var baseURL: URL init(baseURL: URL) { self.baseURL = baseURL diff --git a/ios/IPP/Services/MapPinsService.swift b/ios/IPP/Services/MapPinsService.swift new file mode 100644 index 0000000..4a3c76c --- /dev/null +++ b/ios/IPP/Services/MapPinsService.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Reads the backend's anonymized map pins for the AR mini-game's floor map +/// (FR-013), at the **app layer** — this is the piece that keeps +/// `ios/IPP/Game/` free of networking (FR-008, question Q5, option A). +/// +/// The game is handed a plain `FloorMapData` and cannot tell whether it came +/// from the backend or from the offline sample, so it needs no reachability +/// logic, no error states and no timeouts of its own. +/// +/// Read-only by construction: one GET, no auth headers, nothing written back. +/// The response's record ids and anchor keys are discarded here — see +/// ``GeoPin``. +struct MapPinsService { + + /// Long enough for a LAN round-trip carrying ~1000 pins, short enough that + /// a player who opens the leaderboard offline is not left waiting. + static let defaultTimeout: TimeInterval = 2.5 + + var timeout: TimeInterval = defaultTimeout + var session: URLSession? + + /// Chooses what the floor map shows. Pure — the whole fallback rule in one + /// testable place. + /// + /// An **empty** backend answer counts as a miss, not as live data: a map + /// with no pins on it looks broken, and "the backend is up but has no + /// patients yet" is exactly the demo case where the sample is better than + /// a blank plate. + static func resolve(fetched: [GeoPin]?, fallback: [GeoPin]) -> FloorMapData { + let usable = (fetched ?? []).filter(\.isUsable) + if usable.isEmpty { + return FloorMapData(pins: fallback.filter(\.isUsable), isLive: false) + } + return FloorMapData(pins: usable, isLive: true) + } + + /// `GET {baseURL}/api/v1/map-pins`. Returns `nil` on any failure — an + /// unreachable host, a non-2xx status or an undecodable body — because the + /// caller's next move is the same in all three cases. + func fetch(baseURL: URL) async -> [GeoPin]? { + var request = URLRequest(url: baseURL.appendingPathComponent("api/v1/map-pins")) + request.httpMethod = "GET" + request.timeoutInterval = timeout + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + + let session = session ?? BackendLocator.makeSession(timeout: timeout) + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode) + else { return nil } + return try JSONDecoder().decode(MapPinsResponse.self, from: data).coordinates + } catch { + return nil + } + } +} diff --git a/ios/IPP/Services/SchemaService.swift b/ios/IPP/Services/SchemaService.swift index 85644ff..f2b1e5b 100644 --- a/ios/IPP/Services/SchemaService.swift +++ b/ios/IPP/Services/SchemaService.swift @@ -9,7 +9,10 @@ import SwiftUI @MainActor final class SchemaService: ObservableObject { @Published private(set) var schema: FormSchema - private let baseURL: URL + /// Mutable so `AppEnvironment.resolveBackend()` can re-point the schema + /// refresh at the LAN host discovered at launch (Phase 5C). Set before the + /// first `refresh()`. + var baseURL: URL private static let cacheKey = "ipp.schema.v1" private static var cachedFromDisk: FormSchema? { diff --git a/ios/IPP/Views/IPPApp.swift b/ios/IPP/Views/IPPApp.swift index 6eb216f..4c3a325 100644 --- a/ios/IPP/Views/IPPApp.swift +++ b/ios/IPP/Views/IPPApp.swift @@ -13,6 +13,10 @@ struct IPPApp: App { .tint(.ippTeal) .preferredColorScheme(.light) .task { + // Find the backend before anything asks it a question: on + // device the bundled localhost URL is nobody, and the LAN + // host has to be discovered first (Phase 5C). + await env.resolveBackend() await env.schemaService.refresh() } } diff --git a/ios/IPP/Views/LeaderboardView.swift b/ios/IPP/Views/LeaderboardView.swift index 9460b6b..730fcb5 100644 --- a/ios/IPP/Views/LeaderboardView.swift +++ b/ios/IPP/Views/LeaderboardView.swift @@ -8,6 +8,19 @@ struct LeaderboardView: View { @State private var entries: [LeaderboardEntry] = [] @State private var loading = true @State private var error: String? + @State private var showingGame = false + /// Locations for the mini-game's floor map (FR-013). + /// + /// Fetched **here**, at the app layer, and handed to the game as plain + /// coordinates so that nothing under `ios/IPP/Game/` performs a request + /// (FR-008, question Q5). Starts as the offline sample, so opening the game + /// before the fetch lands shows a map rather than an empty plate. + @State private var floorMap = FloorMapData(pins: SyntheticMapPins.pins(), isLive: false) + + /// The AR mini-game only runs where ARKit world tracking does — elsewhere + /// (Simulator, unsupported hardware) the entry point stays disabled with an + /// explanation (FR-001). Reading this never touches the camera. + private var gameAvailable: Bool { ARSupport.isWorldTrackingSupported } var body: some View { NavigationStack { @@ -26,6 +39,14 @@ struct LeaderboardView: View { } } + Section { + gameRow + } footer: { + Text(gameAvailable + ? "Mini-juego de realidad aumentada. Es solo por diversión: no cambia tus puntos." + : ARSupport.unsupportedMessage) + } + Section { if loading { HStack { ProgressView(); Text("Cargando…") } @@ -63,7 +84,45 @@ struct LeaderboardView: View { } } .task { await load() } + .task { await loadFloorMap() } + .fullScreenCover(isPresented: $showingGame) { + TrophyTossView(floorMap: floorMap) + } + } + } + + private var gameRow: some View { + Button { + showingGame = true + } label: { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(gameAvailable ? Color.ippGoldSoft : Color(.tertiarySystemFill)) + .frame(width: 36, height: 36) + Image(systemName: "trophy.fill") + .font(.title3) + .foregroundStyle(gameAvailable ? Color.ippGold : Color.ippMuted) + } + VStack(alignment: .leading, spacing: 2) { + Text("Jugar") + .font(.callout.weight(.semibold)) + .foregroundStyle(gameAvailable ? Color.ippInk : Color.ippMuted) + Text("Tiro al Trofeo · encesta en el podio") + .font(.caption) + .foregroundStyle(Color.ippMuted) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.ippFaint) + } + .padding(.vertical, 2) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .disabled(!gameAvailable) + .opacity(gameAvailable ? 1 : 0.55) } private func heroCard(username: String) -> some View { @@ -106,6 +165,20 @@ struct LeaderboardView: View { } loading = false } + + /// Reads the anonymized map pins for the mini-game's floor map. + /// + /// Best-effort and silent: when the backend does not answer, `resolve` + /// substitutes the offline sample and the map's own caption says so. Only + /// runs where the game can run, so a device that will never show the map + /// never makes the request. + private func loadFloorMap() async { + guard gameAvailable else { return } + floorMap = MapPinsService.resolve( + fetched: await env.fetchMapPins(), + fallback: SyntheticMapPins.pins() + ) + } } private struct LeaderboardRow: View { diff --git a/ios/IPP/Views/PatientFormView.swift b/ios/IPP/Views/PatientFormView.swift index 1fa3c24..b8aa42a 100644 --- a/ios/IPP/Views/PatientFormView.swift +++ b/ios/IPP/Views/PatientFormView.swift @@ -255,6 +255,9 @@ struct PatientFormView: View { verifyResult = nil defer { verifying = false } do { + // Straight to the client, so the wait for the launch probe has to + // be explicit here (question Q8). + await env.backendReady() verifyResult = try await env.effectStream.verify(rut: patient.rut) } catch { verifyError = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription diff --git a/ios/IPP/Views/PatientListView.swift b/ios/IPP/Views/PatientListView.swift index aea12a8..fc1319a 100644 --- a/ios/IPP/Views/PatientListView.swift +++ b/ios/IPP/Views/PatientListView.swift @@ -53,6 +53,9 @@ struct PatientListView: View { } private func refresh() async { + // Straight to the store, so the wait for the launch probe has to be + // explicit here (question Q8). + await env.backendReady() if let list = try? await env.store.list() { patients = list } diff --git a/ios/IPPTests/AppEnvironmentBackendReadyTests.swift b/ios/IPPTests/AppEnvironmentBackendReadyTests.swift new file mode 100644 index 0000000..e61706a --- /dev/null +++ b/ios/IPPTests/AppEnvironmentBackendReadyTests.swift @@ -0,0 +1,239 @@ +import Foundation +import XCTest + +@testable import IPP + +/// Question Q8 — the launch window in which a request could leave with the +/// *unresolved* backend URL. +/// +/// Before the fix, `resolveBackend()` was started and forgotten: a user who +/// tapped straight into Ranking within the probe's window sent one request to +/// the bundled `localhost` and got `-1004` before the app self-healed. The fix +/// is `backendReady()`, awaited by every request path. +/// +/// These tests hold the probe open on purpose, so "the request waited" is a +/// fact about ordering rather than a race that happens to come out right. +@MainActor +final class AppEnvironmentBackendReadyTests: XCTestCase { + + /// Nothing listens here, in either direction — a request to `resolved` + /// fails instantly with "connection refused" rather than waiting on a + /// timeout, which keeps these tests fast and offline. + private let configured = URL(string: "http://127.0.0.1:2")! + private let resolved = URL(string: "http://127.0.0.1:1")! + + private func makeEnvironment() -> AppEnvironment { + AppEnvironment( + apiStore: APIPatientStore(baseURL: configured), + effectStream: EffectStreamClient(baseURL: configured), + session: SessionService(), + schemaService: SchemaService(baseURL: configured), + webURL: URL(string: "http://127.0.0.1:3")!, + backendURL: configured + ) + } + + /// Installs a probe that answers only once `gate.release()` is called. + private func gate(_ env: AppEnvironment, answering answer: URL?) -> ProbeGate { + let gate = ProbeGate() + env.probeBackend = { _, _ in + await gate.noteProbe() + await gate.wait() + return answer + } + return gate + } + + /// Enough hops for anything that was *not* waiting to have finished. + private func settle() async { + for _ in 0..<20 { await Task.yield() } + } + + // MARK: - The window itself + + func testBackendReadyDoesNotReturnUntilTheProbeHasAnswered() async { + let env = makeEnvironment() + let gate = gate(env, answering: resolved) + let done = Flag() + + let waiter = Task { @MainActor in + await env.backendReady() + done.value = true + } + await settle() + + XCTAssertFalse(done.value, "backendReady() returned while the probe was still in flight") + XCTAssertEqual(env.backendURL, configured, "the URL moved before the probe answered") + + await gate.release() + await waiter.value + + XCTAssertTrue(done.value) + XCTAssertEqual(env.backendURL, resolved) + XCTAssertEqual(env.apiStore.baseURL, resolved) + XCTAssertEqual(env.effectStream.baseURL, resolved) + XCTAssertEqual(env.schemaService.baseURL, resolved) + } + + func testARequestStartedMidProbeWaitsForItRatherThanUsingTheStaleURL() async { + // This is the exact Q8 scenario: an already-authenticated user taps a + // data screen while the launch probe is still running. Without the + // await, this call would have completed against `configured` long + // before the gate opened. + let env = makeEnvironment() + let gate = gate(env, answering: resolved) + let done = Flag() + + let request = Task { @MainActor in + _ = await env.fetchMapPins() + done.value = true + } + await settle() + + XCTAssertFalse(done.value, "a map-pin fetch outran the probe") + XCTAssertEqual(env.backendURL, configured) + + await gate.release() + await request.value + + XCTAssertTrue(done.value) + XCTAssertEqual(env.backendURL, resolved, "the fetch ran against the resolved URL") + let probes = await gate.probeCount + XCTAssertEqual(probes, 1) + } + + func testTheLeaderboardFetchAlsoWaits() async { + // The screen the owner actually hit the -1004 on. + let env = makeEnvironment() + let gate = gate(env, answering: resolved) + let done = Flag() + + let request = Task { @MainActor in + _ = try? await env.fetchLeaderboard() + done.value = true + } + await settle() + + XCTAssertFalse(done.value, "the leaderboard fetch outran the probe") + + await gate.release() + await request.value + XCTAssertEqual(env.backendURL, resolved) + } + + // MARK: - Resolution happens once + + func testConcurrentCallersShareASingleProbe() async { + let env = makeEnvironment() + let gate = gate(env, answering: resolved) + + let waiters = (0..<8).map { _ in + Task { @MainActor in await env.backendReady() } + } + await settle() + await gate.release() + for waiter in waiters { await waiter.value } + + let probes = await gate.probeCount + XCTAssertEqual(probes, 1, "the launch probe must run once per app run, not once per caller") + XCTAssertEqual(env.backendURL, resolved) + } + + func testOnceResolvedFurtherCallsReturnImmediatelyAndReProbeNothing() async { + let env = makeEnvironment() + let gate = gate(env, answering: resolved) + + let first = Task { @MainActor in await env.backendReady() } + await gate.release() + await first.value + + // The gate is open now, so a second probe *would* succeed — the point + // is that it never happens. + await env.backendReady() + await env.resolveBackend() + _ = await env.fetchMapPins() + + let probes = await gate.probeCount + XCTAssertEqual(probes, 1) + } + + func testResolveBackendIsJustTheLaunchSpellingOfBackendReady() async { + // `IPPApp` still calls `resolveBackend()`; it must be the same single + // resolution the request paths await. + let env = makeEnvironment() + let gate = gate(env, answering: resolved) + + let launch = Task { @MainActor in await env.resolveBackend() } + let request = Task { @MainActor in await env.backendReady() } + await settle() + await gate.release() + await launch.value + await request.value + + let probes = await gate.probeCount + XCTAssertEqual(probes, 1) + XCTAssertEqual(env.backendURL, resolved) + } + + // MARK: - Nothing reachable + + func testAFailedProbeLeavesTheConfiguredURLInPlaceAndIsNotRetried() async { + // Offline is a sanctioned outcome, not an error: every screen shows + // the offline state it always did, and the app does not re-probe on + // each request (which would make every later request pay the timeout). + let env = makeEnvironment() + let gate = gate(env, answering: nil) + + let waiter = Task { @MainActor in await env.backendReady() } + await gate.release() + await waiter.value + + XCTAssertEqual(env.backendURL, configured) + XCTAssertEqual(env.apiStore.baseURL, configured) + + await env.backendReady() + let probes = await gate.probeCount + XCTAssertEqual(probes, 1) + } + + func testAProbeAnsweringTheURLWeAlreadyHaveChangesNothing() async { + let env = makeEnvironment() + let gate = gate(env, answering: configured) + + let waiter = Task { @MainActor in await env.backendReady() } + await gate.release() + await waiter.value + + XCTAssertEqual(env.backendURL, configured) + XCTAssertEqual(env.schemaService.baseURL, configured) + } +} + +// MARK: - Helpers + +/// A probe that answers only when the test says so. +private actor ProbeGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + private(set) var probeCount = 0 + + func noteProbe() { probeCount += 1 } + + func wait() async { + if isOpen { return } + await withCheckedContinuation { waiters.append($0) } + } + + func release() { + isOpen = true + let pending = waiters + waiters = [] + for continuation in pending { continuation.resume() } + } +} + +/// A main-actor box, so "did that task finish?" is readable without racing. +@MainActor +private final class Flag { + var value = false +} diff --git a/ios/IPPTests/BackendLocatorTests.swift b/ios/IPPTests/BackendLocatorTests.swift new file mode 100644 index 0000000..19fec98 --- /dev/null +++ b/ios/IPPTests/BackendLocatorTests.swift @@ -0,0 +1,173 @@ +import Foundation +import XCTest + +@testable import IPP + +/// Phase 5C task 5C.1: which backend the phone tries, and in what order. +/// Phase 6 (question Q7): *where the list comes from* — `Info.plist`, not +/// source — is now part of the contract, so it is asserted here too. +/// +/// Deliberately **no LAN address literals in this file**: the ordering cases +/// run against a synthetic pair, and the bundle case reads the expected values +/// out of `Info.plist` itself. That way the suite says nothing about anybody's +/// house, and it keeps passing when the plist is edited for another network. +/// +/// Only the ordering and the parsing are tested — that is the whole of the +/// logic. The probe itself is one `URLSession` call per candidate and is +/// exercised for real by the device gate (5C-g2) rather than pretended at here. +final class BackendLocatorTests: XCTestCase { + + private let localhost = URL(string: "http://localhost:3334")! + /// Stand-ins for "the first host to try" and "the second host to try". + private let first = URL(string: "http://10.1.2.3:3334")! + private let second = URL(string: "http://10.1.2.4:3334")! + private var pair: [URL] { [first, second] } + + // MARK: - Where the list comes from (Q7) + + func testTheCandidateListIsReadFromInfoPlistRatherThanFromSource() { + // The suite is hosted by the app, so `Bundle.main` is the app bundle + // and this is the very array a device build would probe. + let raw = Bundle.main.object(forInfoDictionaryKey: BackendLocator.candidatesInfoKey) + let declared = raw as? [String] + XCTAssertNotNil( + declared, + "Info.plist must carry a \(BackendLocator.candidatesInfoKey) array of host URLs" + ) + XCTAssertFalse(declared?.isEmpty ?? true, "a build with no candidates can never find a LAN host") + + XCTAssertEqual( + BackendLocator.lanCandidates.map(\.absoluteString), + declared, + "the probed list must be exactly the plist's, in the plist's order" + ) + } + + func testEveryCandidateInThisBuildIsAUsablePlainURL() { + for url in BackendLocator.lanCandidates { + XCTAssertNotNil(url.scheme, "\(url) has no scheme") + XCTAssertNotNil(url.host, "\(url) has no host") + XCTAssertFalse( + BackendLocator.isLoopback(url), + "\(url) is loopback — it can never answer on a phone, so it belongs in BackendURL" + ) + } + } + + func testAMissingOrMalformedPlistEntryMeansNoCandidatesRatherThanACrash() { + XCTAssertEqual(BackendLocator.parseCandidates(nil), []) + XCTAssertEqual(BackendLocator.parseCandidates([String]()), []) + XCTAssertEqual(BackendLocator.parseCandidates("http://10.1.2.3:3334"), [], "a bare string is not a list") + XCTAssertEqual(BackendLocator.parseCandidates([1, 2, 3]), [], "a list of non-strings is not a list of URLs") + XCTAssertEqual(BackendLocator.parseCandidates(["", " ", "not a url", "/relative/path"]), []) + } + + func testParsingKeepsOrderTrimsWhitespaceAndDropsOnlyTheJunk() { + XCTAssertEqual( + BackendLocator.parseCandidates([ + " http://10.1.2.3:3334 ", + "nonsense", + "http://10.1.2.4:3334", + ]), + pair + ) + } + + // MARK: - Ordering + + func testTheSimulatorOnlyEverTriesTheConfiguredURL() { + // localhost in the Simulator *is* the Mac running the backend, so + // reaching for the LAN would be slower and pointless. + XCTAssertEqual( + BackendLocator.candidates(configured: localhost, isSimulator: true, lan: pair), + [localhost] + ) + } + + func testOnDeviceTheConfiguredCandidatesComeFirstInTheirDeclaredOrder() { + // The bundled localhost cannot possibly answer on a phone, so it goes + // last rather than being dropped — if someone ever runs the backend on + // the device itself, it still works. + XCTAssertEqual( + BackendLocator.candidates(configured: localhost, isSimulator: false, lan: pair), + [first, second, localhost] + ) + } + + func testWithNoCandidatesConfiguredTheAppJustUsesBackendURL() { + XCTAssertEqual( + BackendLocator.candidates(configured: localhost, isSimulator: false, lan: []), + [localhost] + ) + } + + func testADeliberatelyConfiguredHostWinsAndIsNotDuplicated() { + // Someone pointing BackendURL at a real host means it, so it is tried + // first; and when that host is already one of the candidates it must + // appear once, not twice. + XCTAssertEqual( + BackendLocator.candidates(configured: second, isSimulator: false, lan: pair), + [second, first] + ) + + let elsewhere = URL(string: "http://10.0.0.7:3334")! + XCTAssertEqual( + BackendLocator.candidates(configured: elsewhere, isSimulator: false, lan: pair), + [elsewhere, first, second] + ) + } + + func testTheDefaultCandidateListIsTheBundlesOne() { + // The `lan:` parameter exists for these tests; production callers omit + // it and must get the plist's list. + XCTAssertEqual( + BackendLocator.candidates(configured: localhost, isSimulator: false), + BackendLocator.lanCandidates + [localhost] + ) + } + + func testLoopbackIsRecognisedInEveryFormAndPrivateIPsAreNot() { + for string in [ + "http://localhost:3334", + "http://LOCALHOST:3334", + "http://127.0.0.1:3334", + ] { + XCTAssertTrue(BackendLocator.isLoopback(URL(string: string)!), string) + } + for string in [ + "http://10.1.2.3:3334", + "http://10.0.0.7:3334", + "https://api.example.com", + ] { + XCTAssertFalse(BackendLocator.isLoopback(URL(string: string)!), string) + } + } + + // MARK: - Probing + + func testTheHealthPathIsAppendedWithoutDoublingSlashes() { + XCTAssertEqual( + BackendLocator.healthURL(for: first).absoluteString, + "http://10.1.2.3:3334/health" + ) + XCTAssertEqual( + BackendLocator.healthURL(for: URL(string: "http://10.1.2.3:3334/")!).absoluteString, + "http://10.1.2.3:3334/health" + ) + } + + func testTheProbeTimeoutIsShortEnoughNotToStallTheLaunch() { + // Every candidate × the timeout is the worst case a player waits + // before the app gives up and shows its offline state. Q8 made the + // first request wait for this, so the bound now matters twice over. + let worstCase = BackendLocator.defaultTimeout + * Double(BackendLocator.lanCandidates.count + 1) + XCTAssertLessThanOrEqual(worstCase, 5) + XCTAssertGreaterThan(BackendLocator.defaultTimeout, 0.5, "a busy LAN needs some slack") + } + + func testAnEmptyCandidateListResolvesToNothingWithoutTouchingTheNetwork() async { + let found = await BackendLocator.probe([], timeout: 0.1) + XCTAssertNil(found) + } +} diff --git a/ios/IPPTests/BestScoreStoreTests.swift b/ios/IPPTests/BestScoreStoreTests.swift new file mode 100644 index 0000000..c5b2bc5 --- /dev/null +++ b/ios/IPPTests/BestScoreStoreTests.swift @@ -0,0 +1,99 @@ +import XCTest + +@testable import IPP + +/// Throwaway harness used during Phase 4 to exercise `BestScoreStore` for real +/// on a Simulator. Phase 6a adds the permanent test target; this file lives +/// outside the repo on purpose. +/// +/// Every case runs against its own `UserDefaults` suite, so the app's real +/// defaults are never touched and the suite can be thrown away afterwards. +final class BestScoreStoreTests: XCTestCase { + + private var suiteName: String! + private var defaults: UserDefaults! + + override func setUpWithError() throws { + suiteName = "ipp.tests.bestScore.\(UUID().uuidString)" + defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + } + + override func tearDown() { + defaults.removePersistentDomain(forName: suiteName) + defaults = nil + suiteName = nil + super.tearDown() + } + + private func store(key: String = BestScoreStore.defaultKey) -> BestScoreStore { + BestScoreStore(defaults: defaults, key: key) + } + + func testAFreshStoreHasNoBestScore() { + XCTAssertEqual(store().best, 0) + } + + func testTheFirstRealScoreBecomesTheBest() { + let store = self.store() + XCTAssertTrue(store.submit(4)) + XCTAssertEqual(store.best, 4) + } + + func testOnlyAHigherScoreUpdatesTheBest() { + let store = self.store() + store.submit(7) + + XCTAssertFalse(store.submit(3), "a worse round must not overwrite the record") + XCTAssertEqual(store.best, 7) + + XCTAssertFalse(store.submit(7), "matching the record is not beating it") + XCTAssertEqual(store.best, 7) + + XCTAssertTrue(store.submit(8)) + XCTAssertEqual(store.best, 8) + } + + func testAScorelessRoundNeverWrites() { + let store = self.store() + XCTAssertFalse(store.submit(0)) + XCTAssertFalse(store.submit(-2)) + XCTAssertEqual(store.best, 0) + XCTAssertNil(defaults.object(forKey: BestScoreStore.defaultKey)) + } + + /// US2 / gate row 4.3: the best score has to survive the app being killed. + /// A second instance reading the same suite is exactly that — the store + /// caches nothing, so every read comes back from `UserDefaults`. + func testTheBestScorePersistsAcrossInstances() { + store().submit(11) + + let reopened = store() + XCTAssertEqual(reopened.best, 11) + + XCTAssertFalse(reopened.submit(9)) + XCTAssertTrue(reopened.submit(12)) + XCTAssertEqual(store().best, 12) + } + + func testStoresOnDifferentKeysDoNotSeeEachOther() { + let mine = store(key: "ipp.tests.a") + let theirs = store(key: "ipp.tests.b") + mine.submit(5) + XCTAssertEqual(mine.best, 5) + XCTAssertEqual(theirs.best, 0) + } + + func testClearingForgetsTheBest() { + let store = self.store() + store.submit(6) + store.clear() + XCTAssertEqual(store.best, 0) + } + + /// FR-008: the key is namespaced to the game, so nothing else in the app + /// can be clobbered by it. + func testTheDefaultKeyIsNamespacedToTheGame() { + XCTAssertTrue(BestScoreStore.defaultKey.contains("trophyToss")) + XCTAssertTrue(BestScoreStore.defaultKey.hasPrefix("com.nonturing.ipp")) + } +} diff --git a/ios/IPPTests/FloorMapAnimationTests.swift b/ios/IPPTests/FloorMapAnimationTests.swift new file mode 100644 index 0000000..a1bd837 --- /dev/null +++ b/ios/IPPTests/FloorMapAnimationTests.swift @@ -0,0 +1,257 @@ +import XCTest + +@testable import IPP + +/// Phase 5C task 5C.3: the pure maths behind the floor map's shimmer. The +/// entity-level behaviour is `FloorMapTests`; this pins the numbers the owner +/// will ask to tune. +final class FloorMapAnimationTests: XCTestCase { + + private let field = (0..<400).map { FloorMapAnimation.dot(index: $0) } + + // MARK: - Per-dot parameters + + func testADotIsDeterministicForItsIndexAndSeed() { + XCTAssertEqual(FloorMapAnimation.dot(index: 7), FloorMapAnimation.dot(index: 7)) + XCTAssertEqual( + FloorMapAnimation.dot(index: 7, seed: 42), + FloorMapAnimation.dot(index: 7, seed: 42) + ) + } + + func testDifferentDotsAndDifferentSeedsDiffer() { + XCTAssertNotEqual(FloorMapAnimation.dot(index: 7), FloorMapAnimation.dot(index: 8)) + XCTAssertNotEqual( + FloorMapAnimation.dot(index: 7, seed: 1), + FloorMapAnimation.dot(index: 7, seed: 2) + ) + } + + func testADotDependsOnlyOnItsOwnIndexSoInsertingAPinDoesNotReshuffleTheField() { + // The generator is re-seeded per dot rather than run as one stream. + // If it were a stream, dot 300 would change whenever dot 0 changed. + let alone = FloorMapAnimation.dot(index: 300) + let inSequence = (0...300).map { FloorMapAnimation.dot(index: $0) }.last + XCTAssertEqual(alone, inSequence) + } + + func testEveryDotsConstantsAreInsideTheirTuningRanges() { + for (index, dot) in field.enumerated() { + XCTAssertTrue( + (FloorMapAnimation.Tuning.minPulsePeriod...FloorMapAnimation.Tuning.maxPulsePeriod) + .contains(dot.pulsePeriod), "dot \(index)" + ) + XCTAssertTrue( + (FloorMapAnimation.Tuning.minPulseAmplitude...FloorMapAnimation.Tuning.maxPulseAmplitude) + .contains(dot.pulseAmplitude), "dot \(index)" + ) + XCTAssertTrue((0..<(2 * Float.pi)).contains(dot.pulsePhase), "dot \(index)") + XCTAssertTrue( + (FloorMapAnimation.Tuning.minDropoutCycle...FloorMapAnimation.Tuning.maxDropoutCycle) + .contains(dot.dropoutCycle), "dot \(index)" + ) + XCTAssertTrue((0..() + for dot in field { + buckets.insert(Int(dot.pulsePhase / (2 * .pi) * 6)) + } + XCTAssertGreaterThanOrEqual(buckets.count, 6) + } + + // MARK: - Pulse + + func testTheScaleStaysInsideItsAmplitudeAndIsAlwaysPositive() { + for (index, dot) in field.prefix(60).enumerated() { + for time in stride(from: 0.0, through: 30.0, by: 0.05) { + let scale = FloorMapAnimation.scale(dot, at: time) + XCTAssertGreaterThanOrEqual(scale, 1 - dot.pulseAmplitude - 1e-5, "\(index)@\(time)") + XCTAssertLessThanOrEqual(scale, 1 + dot.pulseAmplitude + 1e-5, "\(index)@\(time)") + XCTAssertGreaterThan(scale, 0) + } + } + } + + func testTheScaleActuallyReachesBothEndsOfItsSwing() { + // A pulse that never grows or never shrinks is not a pulse. + let dot = FloorMapAnimation.dot(index: 3) + var lowest = Float.greatestFiniteMagnitude + var highest = -Float.greatestFiniteMagnitude + for time in stride(from: 0.0, through: dot.pulsePeriod, by: 0.01) { + let scale = FloorMapAnimation.scale(dot, at: time) + lowest = min(lowest, scale) + highest = max(highest, scale) + } + XCTAssertEqual(lowest, 1 - dot.pulseAmplitude, accuracy: 0.01) + XCTAssertEqual(highest, 1 + dot.pulseAmplitude, accuracy: 0.01) + } + + func testThePulseRepeatsAfterExactlyOnePeriod() { + let dot = FloorMapAnimation.dot(index: 11) + for time in stride(from: 0.0, through: 2.0, by: 0.13) { + XCTAssertEqual( + FloorMapAnimation.scale(dot, at: time), + FloorMapAnimation.scale(dot, at: time + dot.pulsePeriod), + accuracy: 1e-4 + ) + } + } + + func testAPulseIsGentleEnoughToReadAsBreathingRatherThanStrobing() { + // The owner asked for "slightly animated". Bounds on the tuning so a + // later edit cannot quietly turn the map into a disco floor. + XCTAssertGreaterThanOrEqual(FloorMapAnimation.Tuning.minPulsePeriod, 1.0) + XCTAssertLessThanOrEqual(FloorMapAnimation.Tuning.maxPulseAmplitude, 0.5) + } + + // MARK: - Dropout + + func testADotThatNeverDropsOutIsAlwaysFullyVisible() { + guard let steady = field.first(where: { !$0.dropsOut }) else { + return XCTFail("every dot drops out") + } + for time in stride(from: 0.0, through: 120.0, by: 0.1) { + XCTAssertEqual(FloorMapAnimation.visibility(steady, at: time), 1, "t=\(time)") + } + } + + func testADroppingDotFadesOutHoldsAtNothingAndFadesBack() { + guard let dot = field.first(where: { $0.dropsOut }) else { + return XCTFail("no dot drops out") + } + let fade = min(FloorMapAnimation.Tuning.fadeDuration, dot.dropoutDuration / 2) + + // Just before its turn: fully present. + XCTAssertEqual(FloorMapAnimation.visibility(dot, at: dot.dropoutStart - 0.01), 1, accuracy: 1e-3) + // Mid fade-out: partly there. + let fadingOut = FloorMapAnimation.visibility(dot, at: dot.dropoutStart + fade / 2) + XCTAssertGreaterThan(fadingOut, 0.2) + XCTAssertLessThan(fadingOut, 0.8) + // Middle of the dropout: gone. + XCTAssertEqual( + FloorMapAnimation.visibility(dot, at: dot.dropoutStart + dot.dropoutDuration / 2), + 0, accuracy: 1e-6 + ) + // Mid fade-back: partly there again. + let fadingIn = FloorMapAnimation.visibility( + dot, at: dot.dropoutStart + dot.dropoutDuration - fade / 2 + ) + XCTAssertGreaterThan(fadingIn, 0.2) + XCTAssertLessThan(fadingIn, 0.8) + // After: back for good, until the next cycle. + XCTAssertEqual( + FloorMapAnimation.visibility(dot, at: dot.dropoutStart + dot.dropoutDuration + 0.01), + 1, accuracy: 1e-3 + ) + } + + func testVisibilityIsAlwaysABlendableFraction() { + for dot in field.prefix(80) { + for time in stride(from: -20.0, through: 120.0, by: 0.07) { + let visibility = FloorMapAnimation.visibility(dot, at: time) + XCTAssertGreaterThanOrEqual(visibility, 0) + XCTAssertLessThanOrEqual(visibility, 1) + } + } + } + + func testTheDropoutRepeatsOnItsCycle() { + guard let dot = field.first(where: { $0.dropsOut }) else { + return XCTFail("no dot drops out") + } + for time in stride(from: 0.0, through: 8.0, by: 0.29) { + XCTAssertEqual( + FloorMapAnimation.visibility(dot, at: time), + FloorMapAnimation.visibility(dot, at: time + dot.dropoutCycle), + accuracy: 1e-4 + ) + } + } + + func testADotIsPresentForMostOfItsCycle() { + // "Some can disappear for a few seconds" — occasionally missing, not + // flickering. This is the guard on `minDropoutCycle` against + // `maxDropoutDuration`; the two constants have to be tuned together. + for dot in field.filter(\.dropsOut) { + let present = (dot.dropoutCycle - dot.dropoutDuration) / dot.dropoutCycle + XCTAssertGreaterThan(present, 0.72, "a dot should be missing, not mostly absent") + } + } + + // MARK: - Fade ramp indexing + + func testEveryVisibilityIndexesTheRampAndSpansInvisibleToFull() { + for step in 0...100 { + let level = FloorMapAnimation.fadeLevel(forVisibility: Float(step) / 100) + XCTAssertTrue((0...FloorMapAnimation.Tuning.fadeSteps).contains(level), "\(step)") + } + XCTAssertEqual(FloorMapAnimation.fadeLevel(forVisibility: 0), 0) + XCTAssertEqual( + FloorMapAnimation.fadeLevel(forVisibility: 1), + FloorMapAnimation.Tuning.fadeSteps + ) + } + + func testTheFadeLevelSurvivesTheValuesThatWouldTrapAnIntConversion() { + // `Swift.min`/`max` propagate NaN and `Int(nan)` traps — the crash the + // Phase 5B crawl fade found. An unreadable visibility must fail *on*, + // never off, so a bug cannot silently empty the map. + XCTAssertEqual( + FloorMapAnimation.fadeLevel(forVisibility: .nan), + FloorMapAnimation.Tuning.fadeSteps + ) + XCTAssertEqual(FloorMapAnimation.fadeLevel(forVisibility: -5), 0) + XCTAssertEqual( + FloorMapAnimation.fadeLevel(forVisibility: 5), + FloorMapAnimation.Tuning.fadeSteps + ) + XCTAssertEqual( + FloorMapAnimation.fadeLevel(forVisibility: .infinity), + FloorMapAnimation.Tuning.fadeSteps + ) + } + + // MARK: - Degenerate dots + + func testADotWithNoPeriodOrNoCycleIsStillWellDefined() { + var frozen = FloorMapAnimation.dot(index: 1) + frozen.pulsePeriod = 0 + XCTAssertEqual(FloorMapAnimation.scale(frozen, at: 3), 1) + + var never = FloorMapAnimation.dot(index: 2) + never.dropsOut = true + never.dropoutCycle = 0 + XCTAssertEqual(FloorMapAnimation.visibility(never, at: 3), 1) + + var instant = FloorMapAnimation.dot(index: 3) + instant.dropsOut = true + instant.dropoutDuration = 0 + XCTAssertEqual(FloorMapAnimation.visibility(instant, at: 3), 1) + } + + func testANonFiniteClockFreezesRatherThanCrashes() { + let dot = FloorMapAnimation.dot(index: 5) + for time in [Double.nan, .infinity, -.infinity] { + XCTAssertEqual(FloorMapAnimation.scale(dot, at: time), 1) + XCTAssertEqual(FloorMapAnimation.visibility(dot, at: time), 1) + } + } +} diff --git a/ios/IPPTests/FloorMapProjectionTests.swift b/ios/IPPTests/FloorMapProjectionTests.swift new file mode 100644 index 0000000..7c6240d --- /dev/null +++ b/ios/IPPTests/FloorMapProjectionTests.swift @@ -0,0 +1,283 @@ +import XCTest +import simd + +@testable import IPP + +/// Phase 5C testing item 1: the lat/lng → floor-plane projection, including the +/// degenerate cases that would otherwise reach RealityKit as `NaN` and take the +/// game down with them. +final class FloorMapProjectionTests: XCTestCase { + + private let extent: Float = 0.88 + + // MARK: - Bounding-box fit + + func testAPinSetIsCentredOnThePlate() { + // Two pins on a north–south line: they must straddle the centre. + let pins = [ + GeoPin(latitude: -33.00, longitude: -71.60), + GeoPin(latitude: -33.10, longitude: -71.60), + ] + let fit = FloorMapProjection.fit(pins, extent: extent) + let points = pins.map(fit.project) + + XCTAssertEqual(points[0].x, 0, accuracy: 1e-5) + XCTAssertEqual(points[1].x, 0, accuracy: 1e-5) + XCTAssertEqual(points[0].y + points[1].y, 0, accuracy: 1e-5, "symmetric about the centre") + } + + func testTheDominantAxisFillsTheExtentExactly() { + let pins = [ + GeoPin(latitude: -33.00, longitude: -71.60), + GeoPin(latitude: -33.10, longitude: -71.60), + ] + let fit = FloorMapProjection.fit(pins, extent: extent) + let points = pins.map(fit.project) + + XCTAssertEqual(abs(points[0].y - points[1].y), extent, accuracy: 1e-4) + } + + func testNorthIsAwayFromThePlayer() { + // The podium faces the player along +Z, so a higher latitude has to + // land further away, i.e. at a smaller z. + let north = GeoPin(latitude: -33.00, longitude: -71.60) + let south = GeoPin(latitude: -33.10, longitude: -71.60) + let fit = FloorMapProjection.fit([north, south], extent: extent) + + XCTAssertLessThan(fit.project(north).y, fit.project(south).y) + } + + func testEastIsToThePlayersRight() { + let west = GeoPin(latitude: -33.05, longitude: -71.70) + let east = GeoPin(latitude: -33.05, longitude: -71.50) + let fit = FloorMapProjection.fit([west, east], extent: extent) + + XCTAssertLessThan(fit.project(west).x, fit.project(east).x) + } + + func testTheAspectRatioIsPreservedRatherThanStretchedToFill() { + // A set twice as wide as it is tall must stay twice as wide: the + // shorter axis uses less than the full extent. + let pins = [ + GeoPin(latitude: -33.00, longitude: -71.70), + GeoPin(latitude: -33.00, longitude: -71.50), + GeoPin(latitude: -33.05, longitude: -71.70), + GeoPin(latitude: -33.05, longitude: -71.50), + ] + let fit = FloorMapProjection.fit(pins, extent: extent) + let points = pins.map(fit.project) + let widthSpan = (points.map(\.x).max() ?? 0) - (points.map(\.x).min() ?? 0) + let depthSpan = (points.map(\.y).max() ?? 0) - (points.map(\.y).min() ?? 0) + + XCTAssertEqual(widthSpan, extent, accuracy: 1e-4, "the wide axis fills the plate") + XCTAssertLessThan(depthSpan, extent * 0.9, "the short axis must not be stretched") + XCTAssertGreaterThan(depthSpan, 0) + } + + func testLongitudeIsCompressedByTheCosineOfTheLatitude() { + // At Valparaíso's latitude a degree of longitude is ~0.838 of a degree + // of latitude on the ground; ignoring that would smear the map + // east–west by 19 %. + let pins = [ + GeoPin(latitude: -33.05, longitude: -71.70), + GeoPin(latitude: -33.05, longitude: -71.50), + ] + let fit = FloorMapProjection.fit(pins, extent: extent) + XCTAssertEqual(fit.longitudeScale, cos(-33.05 * .pi / 180), accuracy: 1e-9) + XCTAssertEqual(fit.longitudeScale, 0.838, accuracy: 0.005) + } + + // MARK: - Degenerate inputs + + func testASinglePinSitsDeadCentre() { + let pin = GeoPin(latitude: -33.05, longitude: -71.60) + let fit = FloorMapProjection.fit([pin], extent: extent) + let point = fit.project(pin) + + XCTAssertEqual(point.x, 0, accuracy: 1e-6) + XCTAssertEqual(point.y, 0, accuracy: 1e-6) + XCTAssertEqual(fit.metresPerDegree, 0, "no extent to scale to") + } + + func testEveryPinAtTheSameCoordinateCollapsesToTheCentreWithoutDividingByZero() { + let pin = GeoPin(latitude: -33.05, longitude: -71.60) + let pins = Array(repeating: pin, count: 40) + let fit = FloorMapProjection.fit(pins, extent: extent) + + for point in pins.map(fit.project) { + XCTAssertTrue(point.x.isFinite && point.y.isFinite) + XCTAssertEqual(simd_length(point), 0, accuracy: 1e-6) + } + } + + func testAnEmptyPinListStillProducesAUsableFit() { + let fit = FloorMapProjection.fit([], extent: extent) + let point = fit.project(GeoPin(latitude: -33.05, longitude: -71.60)) + XCTAssertTrue(point.x.isFinite && point.y.isFinite) + } + + func testAZeroExtentPlateProducesNoNaN() { + let fit = FloorMapProjection.fit( + [GeoPin(latitude: -33.0, longitude: -71.5), GeoPin(latitude: -33.1, longitude: -71.6)], + extent: 0 + ) + let point = fit.project(GeoPin(latitude: -33.05, longitude: -71.55)) + XCTAssertTrue(point.x.isFinite && point.y.isFinite) + } + + func testNonFiniteAndOutOfRangeCoordinatesAreRejectedRatherThanProjected() { + let bad = [ + GeoPin(latitude: .nan, longitude: -71.6), + GeoPin(latitude: -33.0, longitude: .nan), + GeoPin(latitude: .infinity, longitude: .infinity), + GeoPin(latitude: 91, longitude: 0), + GeoPin(latitude: 0, longitude: -181), + ] + for pin in bad { + XCTAssertFalse(pin.isUsable, "\(pin)") + } + + // …and one that slips into a fit alongside good pins projects to the + // centre instead of poisoning the plate. + let good = [ + GeoPin(latitude: -33.00, longitude: -71.60), + GeoPin(latitude: -33.10, longitude: -71.50), + ] + let fit = FloorMapProjection.fit(good + bad, extent: extent) + for pin in bad { + let point = fit.project(pin) + XCTAssertTrue(point.x.isFinite && point.y.isFinite, "\(pin)") + XCTAssertEqual(simd_length(point), 0, accuracy: 1e-6) + } + // The good pins still fill the plate: the bad ones did not widen the + // bounding box. + XCTAssertEqual(abs(fit.project(good[0]).y - fit.project(good[1]).y), extent, accuracy: 1e-4) + } + + func testNothingEverLandsOutsideThePlate() { + let pins = [ + GeoPin(latitude: -33.00, longitude: -71.60), + GeoPin(latitude: -33.10, longitude: -71.50), + ] + let fit = FloorMapProjection.fit(pins, extent: extent) + let half = extent / 2 + 1e-4 + + // Sweep well outside the fitted set, including the far side of the + // planet. + for latitude in stride(from: -90.0, through: 90.0, by: 7.5) { + for longitude in stride(from: -180.0, through: 180.0, by: 15.0) { + let point = fit.project(GeoPin(latitude: latitude, longitude: longitude)) + XCTAssertLessThanOrEqual(abs(point.x), half, "\(latitude),\(longitude)") + XCTAssertLessThanOrEqual(abs(point.y), half, "\(latitude),\(longitude)") + } + } + } + + // MARK: - Thinning + + func testASmallPinSetIsNotThinnedAtAll() { + let pins = (0..<10).map { GeoPin(latitude: Double($0), longitude: 0) } + XCTAssertEqual(FloorMapProjection.sample(pins, limit: 260), pins) + } + + func testALargePinSetIsThinnedToExactlyTheLimit() { + let pins = (0..<960).map { GeoPin(latitude: Double($0) / 100, longitude: 0) } + let sampled = FloorMapProjection.sample(pins, limit: 260) + + XCTAssertEqual(sampled.count, 260) + XCTAssertTrue(sampled.allSatisfy { pins.contains($0) }) + } + + func testThinningKeepsTheSpreadRatherThanTakingAPrefix() { + // The seeded data arrives grouped by city, so a prefix would show one + // city. Stride sampling has to reach the end of the list. + let pins = (0..<960).map { GeoPin(latitude: Double($0) / 100, longitude: 0) } + let sampled = FloorMapProjection.sample(pins, limit: 260) + + XCTAssertEqual(sampled.first, pins.first) + XCTAssertGreaterThan(sampled.last!.latitude, pins[900].latitude, "never reached the tail") + // Strictly increasing, i.e. it walks the list once in order. + for (a, b) in zip(sampled, sampled.dropFirst()) { + XCTAssertLessThan(a.latitude, b.latitude) + } + } + + func testThinningToNothingIsAnEmptyMapNotACrash() { + let pins = (0..<50).map { GeoPin(latitude: Double($0), longitude: 0) } + XCTAssertEqual(FloorMapProjection.sample(pins, limit: 0).count, 0) + XCTAssertEqual(FloorMapProjection.sample(pins, limit: -3).count, 0) + XCTAssertEqual(FloorMapProjection.sample([], limit: 260).count, 0) + } + + // MARK: - Density tint + + func testAUniformlySpreadSetGetsAUniformTint() { + let points = (0..<9).map { index in + SIMD2(Float(index % 3) * 0.3, Float(index / 3) * 0.3) + } + let levels = FloorMapProjection.densityLevels(for: points, radius: 0.05, stops: 5) + XCTAssertEqual(Set(levels).count, 1, "nobody has a neighbour, so nobody is hotter") + } + + func testACrowdedNeighbourhoodRunsHotAndALonePinRunsCold() { + var points = (0..<12).map { index in + SIMD2(0.01 * Float(index % 4), 0.01 * Float(index / 4)) + } + let loner = SIMD2(0.45, 0.45) + points.append(loner) + + let levels = FloorMapProjection.densityLevels(for: points, radius: 0.05, stops: 5) + XCTAssertEqual(levels.last, 0, "the isolated pin is the coldest") + XCTAssertEqual(levels.dropLast().max(), 4, "the cluster reaches the top of the ramp") + } + + func testEveryLevelIndexesTheRamp() { + let points = (0..<200).map { index in + SIMD2( + Float(index % 20) * 0.02 - 0.2, + Float(index / 20) * 0.05 - 0.2 + ) + } + for stops in [2, 3, 5, 8] { + let levels = FloorMapProjection.densityLevels(for: points, radius: 0.05, stops: stops) + XCTAssertEqual(levels.count, points.count) + XCTAssertTrue(levels.allSatisfy { (0..(0, 0), SIMD2(0.01, 0)] + XCTAssertEqual(FloorMapProjection.densityLevels(for: [], radius: 0.05, stops: 5), []) + XCTAssertEqual( + FloorMapProjection.densityLevels(for: points, radius: 0.05, stops: 1), + [0, 0] + ) + XCTAssertEqual( + FloorMapProjection.densityLevels(for: points, radius: 0, stops: 5), + [0, 0] + ) + } + + // MARK: - Caption + + func testTheCaptionNamesTheSourceSoTheGateIsDecidableByEye() { + let live = FloorMapProjection.caption(pinCount: 260, isLive: true) + let sample = FloorMapProjection.caption(pinCount: 80, isLive: false) + + XCTAssertTrue(live.contains("en vivo"), live) + XCTAssertTrue(live.contains("260"), live) + XCTAssertTrue(sample.contains("ejemplo"), sample) + XCTAssertTrue(sample.contains("80"), sample) + XCTAssertNotEqual(live, sample) + } + + func testTheCaptionIsGrammaticalSpanishForOneLocation() { + XCTAssertTrue( + FloorMapProjection.caption(pinCount: 1, isLive: true).contains("1 ubicación"), + "singular, not '1 ubicaciones' (FR-009)" + ) + XCTAssertTrue( + FloorMapProjection.caption(pinCount: 0, isLive: false).contains("0 ubicaciones") + ) + } +} diff --git a/ios/IPPTests/FloorMapTests.swift b/ios/IPPTests/FloorMapTests.swift new file mode 100644 index 0000000..4a2ce18 --- /dev/null +++ b/ios/IPPTests/FloorMapTests.swift @@ -0,0 +1,327 @@ +import RealityKit +import XCTest +import simd + +@testable import IPP + +/// Phase 5C tasks 5C.2 and 5C.3: the built floor map. Whether it *looks* right +/// is the owner's gate rows 5C-g1/5C-g3; what is checked here is that it is +/// built, bounded, capped, captioned, animated — and, above all, incapable of +/// touching a ball. +/// +/// **Task 5C.3 reshaped this suite.** The owner removed the plate and border, +/// so the three cases that asserted their geometry are gone; the dot, caption +/// and inertness cases stay, and the animation cases are new. +@MainActor +final class FloorMapTests: XCTestCase { + + private func descendants(of entity: Entity) -> [Entity] { + entity.children.flatMap { [$0] + descendants(of: $0) } + } + + private func dots(of display: FloorMap.Display) -> [Entity] { + descendants(of: display.root).filter { $0.name.hasPrefix(FloorMap.Name.pinPrefix) } + } + + private func sampleData(count: Int, isLive: Bool = true) -> FloorMapData { + let pins = (0.. Int { + ([entity] + descendants(of: entity)) + .filter { $0.components[CollisionComponent.self] != nil } + .count + } + let scene = PodiumBuilder.makeScene() + let before = colliders(scene) + scene.addChild(FloorMap.make(sampleData(count: 200)).root) + XCTAssertEqual(colliders(scene), before) + } + + func testAnimatingTheMapNeverGrowsColliders() { + // The shimmer writes transforms and materials every frame; none of that + // may ever add a collider to the scene. + let display = FloorMap.make(sampleData(count: 120)) + for time in stride(from: 0.0, through: 40.0, by: 0.5) { + FloorMap.update(display, at: time) + } + for entity in [display.root] + descendants(of: display.root) { + XCTAssertNil(entity.components[CollisionComponent.self], entity.name) + XCTAssertNil(entity.components[PhysicsBodyComponent.self], entity.name) + } + } + + // MARK: - The plate is gone (task 5C.3) + + func testThereIsNoPlateOrBorderLeftUnderTheDots() { + // The owner asked for the bounding box to go: the floor itself is the + // map's background now. A leftover slab would mean the removal was + // cosmetic. + let display = FloorMap.make(sampleData(count: 60)) + for name in ["floor_map_plate", "floor_map_frame"] { + XCTAssertNil(display.root.findEntity(named: name), "\(name) survived") + } + // Every model in the subtree is either a dot or the caption's text. + let caption = display.root.findEntity(named: FloorMap.Name.caption) + let captionModels = caption.map { descendants(of: $0) } ?? [] + let models = descendants(of: display.root).compactMap { $0 as? ModelEntity } + for model in models { + let isDot = model.name.hasPrefix(FloorMap.Name.pinPrefix) + let isCaption = captionModels.contains { $0 === model } + XCTAssertTrue(isDot || isCaption, "unexpected geometry: \(model.name)") + } + } + + // MARK: - Geometry + + func testTheFieldSitsAboveTheFloorPlaneSoItCannotZFightIt() { + // PodiumBuilder's invisible collider has its top face at y = 0. + let display = FloorMap.make(sampleData(count: 20)) + XCTAssertGreaterThan(display.root.position.y, 0) + XCTAssertLessThan(display.root.position.y, 0.01, "it is a map on the floor, not a table") + } + + func testTheFieldIsBigEnoughToSurroundThePodiumAndSmallEnoughForADesk() { + let podiumWidth = PodiumBuilder.Metrics.stepWidth * 3 + XCTAssertGreaterThan(FloorMap.Look.side, podiumWidth * 2, "the map has to read as under it") + XCTAssertLessThanOrEqual(FloorMap.Look.side, 1.2) + } + + func testEveryDotLandsInTheFieldWithItsInsetRespected() { + let display = FloorMap.make(sampleData(count: 400)) + let limit = FloorMap.Look.side / 2 - FloorMap.Look.inset + 1e-4 + + XCTAssertFalse(dots(of: display).isEmpty) + for dot in dots(of: display) { + XCTAssertLessThanOrEqual(abs(dot.position.x), limit, dot.name) + XCTAssertLessThanOrEqual(abs(dot.position.z), limit, dot.name) + XCTAssertGreaterThan(dot.position.y, 0, "dots float above the anchor plane") + } + } + + func testTheDotCountIsCappedHoweverManyPinsArrive() { + // The seeded backend returns ~960; the scene must not grow 960 + // entities for a field a metre across. + let display = FloorMap.make(sampleData(count: 960)) + XCTAssertEqual(dots(of: display).count, FloorMap.Look.maxDots) + XCTAssertEqual(display.dots.count, FloorMap.Look.maxDots) + XCTAssertEqual(display.motion.count, FloorMap.Look.maxDots) + XCTAssertEqual(display.density.count, FloorMap.Look.maxDots) + } + + func testASmallPinSetDrawsEveryPin() { + let display = FloorMap.make(sampleData(count: 37)) + XCTAssertEqual(dots(of: display).count, 37) + } + + func testAnEmptyMapIsStillACaptionRatherThanNothing() { + let display = FloorMap.make(FloorMapData.none) + XCTAssertNotNil(display.root.findEntity(named: FloorMap.Name.caption)) + XCTAssertTrue(dots(of: display).isEmpty) + // …and animating an empty field is a no-op, not a crash. + FloorMap.update(display, at: 12.5) + } + + func testASinglePinDrawsOneDotInTheMiddle() { + let display = FloorMap.make( + FloorMapData(pins: [GeoPin(latitude: -33.05, longitude: -71.6)], isLive: true) + ) + let placed = dots(of: display) + XCTAssertEqual(placed.count, 1) + XCTAssertEqual(placed[0].position.x, 0, accuracy: 1e-5) + XCTAssertEqual(placed[0].position.z, 0, accuracy: 1e-5) + } + + // MARK: - Caption + + func testTheCaptionSaysWhereTheDataCameFromAndIsRealGeometry() { + for isLive in [true, false] { + let display = FloorMap.make(sampleData(count: 50, isLive: isLive)) + guard let caption = display.root.findEntity(named: FloorMap.Name.caption) else { + return XCTFail("no caption") + } + let models = descendants(of: caption).compactMap { $0 as? ModelEntity } + XCTAssertEqual(models.count, 1) + XCTAssertGreaterThan(models[0].model?.mesh.bounds.extents.x ?? 0, 0) + } + } + + func testTheCaptionLiesOnTheFieldAndLeansTowardThePlayer() { + let display = FloorMap.make(sampleData(count: 50)) + guard let caption = display.root.findEntity(named: FloorMap.Name.caption) else { + return XCTFail("no caption") + } + // On the near edge, the side the podium was turned toward. + XCTAssertGreaterThan(caption.position.z, FloorMap.Look.side / 2 - FloorMap.Look.inset) + XCTAssertLessThan(caption.position.z, FloorMap.Look.side / 2) + + // Its face points mostly up out of the floor, tipped back at the player. + let facing = caption.orientation.act(SIMD3(0, 0, 1)) + XCTAssertGreaterThan(facing.y, 0.9, "mostly up") + XCTAssertGreaterThan(facing.z, 0, "leaning toward the player, not away") + XCTAssertEqual(facing.x, 0, accuracy: 1e-6) + + // Text runs away from the player, so it reads the right way up. + let textUp = caption.orientation.act(SIMD3(0, 1, 0)) + XCTAssertLessThan(textUp.z, 0) + } + + func testTheCaptionFitsInTheField() { + let model = StandingsDisplay.makeTextModel( + FloorMapProjection.caption(pinCount: 260, isLive: true), + size: FloorMap.Look.captionSize, + material: UnlitMaterial(color: .white) + ) + let bounds = model.visualBounds(relativeTo: nil) + print("caption width \(bounds.extents.x) m, height \(bounds.extents.y) m") + XCTAssertGreaterThan(bounds.extents.x, 0.10, "too small to read at a metre") + XCTAssertLessThan(bounds.extents.x, FloorMap.Look.side, "wider than the field") + } + + // MARK: - Tint ramp + + func testTheRampCrossesEveryTintWithEveryFadeLevel() { + let ramp = FloorMap.densityRamp() + XCTAssertEqual(ramp.count, FloorMap.Look.densityStops) + for row in ramp { + XCTAssertEqual(row.count, FloorMapAnimation.Tuning.fadeSteps + 1) + } + } + + func testTheDensityRampRunsFromTheBrandTealToThePodiumGold() { + XCTAssertEqual(FloorMap.blend(PodiumBuilder.Medal.ball, PodiumBuilder.Medal.gold, 0), + PodiumBuilder.Medal.ball) + XCTAssertEqual(FloorMap.blend(PodiumBuilder.Medal.ball, PodiumBuilder.Medal.gold, 1), + PodiumBuilder.Medal.gold) + } + + func testBlendingClampsRatherThanExtrapolating() { + // Compared component-wise: `blend` always returns an sRGB colour, and + // `UIColor.black`/`.white` are greyscale, so `==` on the objects is + // false even when the colours are identical. + func rgba(_ color: UIColor) -> [CGFloat] { + var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0 + color.getRed(&red, green: &green, blue: &blue, alpha: &alpha) + return [red, green, blue, alpha] + } + + XCTAssertEqual(rgba(FloorMap.blend(.black, .white, -5)), rgba(.black)) + XCTAssertEqual(rgba(FloorMap.blend(.black, .white, 5)), rgba(.white)) + XCTAssertEqual(rgba(FloorMap.blend(.black, .white, 0.5))[0], 0.5, accuracy: 1e-6) + } + + // MARK: - The field is alive (task 5C.3) + + func testTheFieldIsAlreadyAnimatedTheFrameItIsBuilt() { + // Otherwise the map snaps into motion a frame after the podium lands. + let display = FloorMap.make(sampleData(count: 120)) + XCTAssertFalse(display.levels.contains(-1), "every dot got its opening rung") + XCTAssertTrue(display.dots.contains { $0.scale.x != 1 }, "nothing is pulsing") + } + + func testEveryDotStaysWithinItsPulseBoundsForeverAndKeepsItsPlace() { + let display = FloorMap.make(sampleData(count: 80)) + let origins = display.dots.map(\.position) + + for time in stride(from: 0.0, through: 90.0, by: 0.37) { + FloorMap.update(display, at: time) + for (index, dot) in display.dots.enumerated() { + let amplitude = display.motion[index].pulseAmplitude + XCTAssertGreaterThanOrEqual(dot.scale.x, 1 - amplitude - 1e-5, "t=\(time)") + XCTAssertLessThanOrEqual(dot.scale.x, 1 + amplitude + 1e-5, "t=\(time)") + XCTAssertEqual(dot.scale.x, dot.scale.y, accuracy: 1e-6, "uniform scale only") + // A pulsing dot must not wander off its location. + XCTAssertEqual(dot.position, origins[index], "t=\(time)") + } + } + } + + func testTheMapNeverLooksEmptyHoweverThePhasesLineUp() { + // The dropouts are staggered and only a minority of dots ever drop out; + // if that ever stopped being true the map would visibly gutter. + let display = FloorMap.make(sampleData(count: 260)) + for time in stride(from: 0.0, through: 200.0, by: 0.25) { + FloorMap.update(display, at: time) + let visible = display.dots.filter(\.isEnabled).count + XCTAssertGreaterThan( + Double(visible), Double(display.dots.count) * 0.8, + "only \(visible)/\(display.dots.count) dots visible at t=\(time)" + ) + } + } + + func testSomeDotsDoActuallyVanishAndComeBack() { + // The other half of the previous test: a field that never blinks would + // pass "never empty" trivially. + let display = FloorMap.make(sampleData(count: 260)) + var everHidden = Set() + for time in stride(from: 0.0, through: 60.0, by: 0.25) { + FloorMap.update(display, at: time) + for (index, dot) in display.dots.enumerated() where !dot.isEnabled { + everHidden.insert(index) + } + } + XCTAssertGreaterThan(everHidden.count, 10, "nothing ever blinked out") + + // …and every one of them is visible again at some point. + var stillHidden = everHidden + for time in stride(from: 0.0, through: 60.0, by: 0.25) { + FloorMap.update(display, at: time) + for index in everHidden where display.dots[index].isEnabled { + stillHidden.remove(index) + } + } + XCTAssertTrue(stillHidden.isEmpty, "these never came back: \(stillHidden)") + } + + func testAFullyFadedDotIsDisabledRatherThanDrawnInvisible() { + let display = FloorMap.make(sampleData(count: 260)) + for time in stride(from: 0.0, through: 60.0, by: 0.25) { + FloorMap.update(display, at: time) + for (index, dot) in display.dots.enumerated() { + let level = FloorMapAnimation.fadeLevel( + forVisibility: FloorMapAnimation.visibility(display.motion[index], at: time) + ) + XCTAssertEqual(dot.isEnabled, level > 0, "dot \(index) at t=\(time)") + } + } + } + + func testTheFieldIsDeterministicForASeed() { + // The same map must animate the same way every time it is placed. + let a = FloorMap.make(sampleData(count: 60), seed: 99) + let b = FloorMap.make(sampleData(count: 60), seed: 99) + XCTAssertEqual(a.motion, b.motion) + + let c = FloorMap.make(sampleData(count: 60), seed: 100) + XCTAssertNotEqual(a.motion, c.motion) + } + + func testAnimationIsWellDefinedForAbsurdClocks() { + let display = FloorMap.make(sampleData(count: 40)) + for time in [-5.0, 0.0, .greatestFiniteMagnitude, .infinity, .nan] as [TimeInterval] { + FloorMap.update(display, at: time) + for dot in display.dots { + XCTAssertTrue(dot.scale.x.isFinite, "t=\(time)") + XCTAssertGreaterThan(dot.scale.x, 0, "t=\(time)") + } + } + } +} diff --git a/ios/IPPTests/GameRoundTests.swift b/ios/IPPTests/GameRoundTests.swift new file mode 100644 index 0000000..51cd476 --- /dev/null +++ b/ios/IPPTests/GameRoundTests.swift @@ -0,0 +1,290 @@ +import XCTest + +@testable import IPP + +/// Throwaway harness used during Phase 4 to exercise `GameRound` for real on a +/// Simulator. Phase 6a adds the permanent test target; this file lives outside +/// the repo on purpose. +/// +/// `GameRound` imports nothing but Foundation and reads no clock, so every rule +/// about time can be driven exactly rather than waited for. +final class GameRoundTests: XCTestCase { + + private func round(duration: TimeInterval = 60) -> GameRound { + var rules = GameRound.Rules() + rules.duration = duration + return GameRound(rules: rules) + } + + // MARK: - Starting + + func testANewRoundIsIdleAndAcceptsFreePractice() { + let round = self.round() + XCTAssertEqual(round.state, .idle) + XCTAssertTrue(round.isIdle) + XCTAssertFalse(round.isRunning) + XCTAssertEqual(round.score, 0) + XCTAssertTrue(round.canStart) + // Free practice: balls may be thrown, but nothing counts for a round. + XCTAssertTrue(round.acceptsFlicks) + XCTAssertFalse(round.countsScores) + } + + func testStartingPutsTheFullDurationOnTheClock() { + var round = self.round(duration: 45) + XCTAssertTrue(round.start()) + XCTAssertEqual(round.state, .running(remaining: 45)) + XCTAssertEqual(round.remaining, 45, accuracy: 1e-9) + XCTAssertTrue(round.isRunning) + XCTAssertTrue(round.isTicking) + XCTAssertTrue(round.acceptsFlicks) + XCTAssertTrue(round.countsScores) + } + + func testTheDefaultRoundIsSixtySeconds() { + var round = GameRound() + round.start() + XCTAssertEqual(round.remaining, 60, accuracy: 1e-9) + } + + func testStartingAgainMidRoundIsRefused() { + var round = self.round() + round.start() + round.tick(10) + XCTAssertFalse(round.canStart) + XCTAssertFalse(round.start(), "a running round must not be restarted from under the player") + XCTAssertEqual(round.remaining, 50, accuracy: 1e-9) + } + + // MARK: - Ticking down + + func testTickingConsumesTime() { + var round = self.round(duration: 10) + round.start() + XCTAssertFalse(round.tick(3)) + XCTAssertEqual(round.remaining, 7, accuracy: 1e-9) + XCTAssertFalse(round.tick(3)) + XCTAssertEqual(round.remaining, 4, accuracy: 1e-9) + } + + func testTickingToZeroEndsTheRoundWithItsScore() { + var round = self.round(duration: 2) + round.start() + round.registerScore() + round.registerScore() + round.registerScore() + + XCTAssertFalse(round.tick(1.5)) + XCTAssertTrue(round.tick(0.5), "the tick that empties the clock reports the end") + XCTAssertEqual(round.state, .ended(score: 3)) + XCTAssertEqual(round.finalScore, 3) + XCTAssertTrue(round.hasEnded) + XCTAssertFalse(round.isRunning) + } + + func testTheEndIsReportedExactlyOnce() { + var round = self.round(duration: 1) + round.start() + XCTAssertTrue(round.tick(5), "an overshooting tick still ends the round") + XCTAssertFalse(round.tick(5), "…and never ends it a second time") + XCTAssertFalse(round.tick(5)) + } + + func testTickingDoesNothingBeforeAndAfterARound() { + var round = self.round(duration: 10) + XCTAssertFalse(round.tick(5)) + XCTAssertEqual(round.state, .idle) + + round.start() + round.tick(10) + XCTAssertEqual(round.state, .ended(score: 0)) + XCTAssertFalse(round.tick(5)) + XCTAssertEqual(round.state, .ended(score: 0), "the summary must not change under the player") + } + + func testANonPositiveTickIsIgnored() { + var round = self.round(duration: 10) + round.start() + XCTAssertFalse(round.tick(0)) + XCTAssertFalse(round.tick(-4), "a clock that ran backwards must not hand back time") + XCTAssertEqual(round.remaining, 10, accuracy: 1e-9) + } + + // MARK: - Pausing (edge cases "tracking loss" and "backgrounding") + + func testPausedTimeIsNotChargedToThePlayer() { + var round = self.round(duration: 30) + round.start() + round.tick(5) + + round.setPaused(true, reason: .trackingLimited) + XCTAssertTrue(round.isPaused) + XCTAssertFalse(round.isTicking) + + for _ in 0..<100 { + XCTAssertFalse(round.tick(1)) + } + XCTAssertEqual(round.remaining, 25, accuracy: 1e-9, "100 s of paused time consumed the clock") + + round.setPaused(false, reason: .trackingLimited) + XCTAssertTrue(round.isTicking) + round.tick(5) + XCTAssertEqual(round.remaining, 20, accuracy: 1e-9) + } + + func testAPausedRoundAcceptsNoFlicksAndScoresNothing() { + var round = self.round() + round.start() + round.setPaused(true, reason: .backgrounded) + + XCTAssertFalse(round.acceptsFlicks) + XCTAssertFalse(round.countsScores) + XCTAssertFalse(round.registerScore(), "a ball landing while paused must not score") + XCTAssertEqual(round.score, 0) + } + + func testEveryPauseReasonMustClearBeforeTheClockRestarts() { + var round = self.round(duration: 20) + round.start() + round.setPaused(true, reason: .trackingLimited) + round.setPaused(true, reason: .backgrounded) + + round.setPaused(false, reason: .trackingLimited) + XCTAssertTrue(round.isPaused, "the app is still in the background") + round.tick(5) + XCTAssertEqual(round.remaining, 20, accuracy: 1e-9) + + round.setPaused(false, reason: .backgrounded) + XCTAssertTrue(round.isTicking) + round.tick(5) + XCTAssertEqual(round.remaining, 15, accuracy: 1e-9) + } + + func testPausingIsIdempotent() { + var round = self.round(duration: 20) + round.start() + for _ in 0..<5 { round.setPaused(true, reason: .trackingLimited) } + round.setPaused(false, reason: .trackingLimited) + XCTAssertTrue(round.isTicking, "one clear must undo any number of identical pauses") + } + + func testARoundStartedWhileTrackingIsLostBeginsPaused() { + var round = self.round(duration: 20) + round.setPaused(true, reason: .trackingLimited) + round.start() + XCTAssertTrue(round.isPaused, "starting must not silently clear a live pause reason") + round.tick(5) + XCTAssertEqual(round.remaining, 20, accuracy: 1e-9) + } + + // MARK: - Flick and score gating (FR-007) + + func testScoresOnlyCountWhileARoundIsTicking() { + var round = self.round() + + // idle — free practice, counts for no round + XCTAssertFalse(round.registerScore()) + XCTAssertEqual(round.score, 0) + + round.start() + XCTAssertTrue(round.registerScore()) + XCTAssertTrue(round.registerScore()) + XCTAssertEqual(round.score, 2) + + round.tick(1000) + // ended — the summary is frozen + XCTAssertFalse(round.registerScore()) + XCTAssertEqual(round.finalScore, 2) + } + + /// Phase 5: the round takes the *amount*, because the tier that earned it + /// (+1 for touching the cup, the balance of +10 for landing in it) is + /// `TossController`'s business, not the clock's. + func testTheRoundBanksWhateverAmountItIsHandedAndOnlyWhileTicking() { + var round = self.round() + round.start() + + XCTAssertTrue(round.registerScore(1)) + XCTAssertEqual(round.score, 1) + XCTAssertTrue(round.registerScore(9), "the make's remainder after an absorbed hit") + XCTAssertEqual(round.score, 10, "a made ball is worth ten in total") + XCTAssertTrue(round.registerScore(10), "a clean make with no prior hit") + XCTAssertEqual(round.score, 20) + + // The default is still the single point, so nothing that predates the + // two-tier rule changed meaning. + XCTAssertTrue(round.registerScore()) + XCTAssertEqual(round.score, 21) + + round.setPaused(true, reason: .trackingLimited) + XCTAssertFalse(round.registerScore(10), "a ball landing while paused pays nobody") + XCTAssertEqual(round.score, 21) + } + + func testTheSummaryRefusesFlicksButFreePracticeDoesNot() { + var round = self.round(duration: 1) + XCTAssertTrue(round.acceptsFlicks, "free practice before the first round") + round.start() + XCTAssertTrue(round.acceptsFlicks) + round.tick(1) + XCTAssertFalse(round.acceptsFlicks, "no throwing behind the end-of-round card") + round.reset() + XCTAssertTrue(round.acceptsFlicks, "dismissing the summary returns to free practice") + } + + // MARK: - Replay and reset + + func testPlayingAgainStartsFromAFullClockAndAZeroScore() { + var round = self.round(duration: 10) + round.start() + round.registerScore() + round.tick(10) + XCTAssertEqual(round.finalScore, 1) + + XCTAssertTrue(round.start()) + XCTAssertEqual(round.remaining, 10, accuracy: 1e-9) + XCTAssertEqual(round.score, 0) + XCTAssertTrue(round.isTicking) + } + + func testResetReturnsToIdle() { + var round = self.round(duration: 10) + round.start() + round.registerScore() + round.reset() + XCTAssertEqual(round.state, .idle) + XCTAssertEqual(round.score, 0) + XCTAssertTrue(round.canStart) + } + + // MARK: - Countdown text + + func testCountdownTextRoundsUpSoTheHudNeverShowsZeroEarly() { + XCTAssertEqual(GameRound.countdownText(60), "1:00") + XCTAssertEqual(GameRound.countdownText(59.4), "1:00") + XCTAssertEqual(GameRound.countdownText(59), "0:59") + XCTAssertEqual(GameRound.countdownText(9.2), "0:10") + XCTAssertEqual(GameRound.countdownText(0.1), "0:01") + XCTAssertEqual(GameRound.countdownText(0), "0:00") + XCTAssertEqual(GameRound.countdownText(-3), "0:00", "a finished round never shows a negative clock") + } + + func testCountdownTextTracksTheRound() { + var round = self.round(duration: 60) + round.start() + XCTAssertEqual(round.countdownText, "1:00") + round.tick(15) + XCTAssertEqual(round.countdownText, "0:45") + } + + // MARK: - Rules are the single knob + + func testTheRoundLengthIsASingleConstant() { + var rules = GameRound.Rules() + rules.duration = 30 + var round = GameRound(rules: rules) + round.start() + XCTAssertEqual(round.remaining, 30, accuracy: 1e-9) + XCTAssertTrue(round.tick(30)) + } +} diff --git a/ios/IPPTests/MapPinsServiceTests.swift b/ios/IPPTests/MapPinsServiceTests.swift new file mode 100644 index 0000000..fdb71fb --- /dev/null +++ b/ios/IPPTests/MapPinsServiceTests.swift @@ -0,0 +1,89 @@ +import XCTest + +@testable import IPP + +/// Phase 5C testing item 1: the fallback rule — backend pins versus the offline +/// sample. This is the whole of the decision the game depends on, and it is a +/// pure function so it can be pinned here rather than at the device gate. +final class MapPinsServiceTests: XCTestCase { + + private let fallback = [ + GeoPin(latitude: -33.0, longitude: -72.2), + GeoPin(latitude: -33.1, longitude: -72.3), + ] + private let fetched = [ + GeoPin(latitude: -33.04, longitude: -71.62), + GeoPin(latitude: -33.05, longitude: -71.61), + GeoPin(latitude: -33.06, longitude: -71.60), + ] + + func testBackendPinsAreUsedAndMarkedLive() { + let result = MapPinsService.resolve(fetched: fetched, fallback: fallback) + XCTAssertEqual(result.pins, fetched) + XCTAssertTrue(result.isLive) + } + + func testAnUnreachableBackendFallsBackToTheSample() { + let result = MapPinsService.resolve(fetched: nil, fallback: fallback) + XCTAssertEqual(result.pins, fallback) + XCTAssertFalse(result.isLive) + } + + func testAnEmptyBackendAnswerCountsAsAMissNotAsLiveData() { + // "The backend is up but has no patients yet" is exactly the demo case + // where a blank plate looks broken and the sample looks right. + let result = MapPinsService.resolve(fetched: [], fallback: fallback) + XCTAssertEqual(result.pins, fallback) + XCTAssertFalse(result.isLive) + } + + func testUnusableCoordinatesAreFilteredOutOfBothSides() { + let poisoned = fetched + [GeoPin(latitude: .nan, longitude: 0)] + let result = MapPinsService.resolve(fetched: poisoned, fallback: fallback) + XCTAssertEqual(result.pins, fetched) + XCTAssertTrue(result.isLive) + + // A backend answer made up entirely of junk is a miss. + let allJunk = MapPinsService.resolve( + fetched: [GeoPin(latitude: .infinity, longitude: .nan)], + fallback: fallback + ) + XCTAssertEqual(allJunk.pins, fallback) + XCTAssertFalse(allJunk.isLive) + } + + func testWithNothingAnywhereTheMapIsEmptyRatherThanUndefined() { + let result = MapPinsService.resolve(fetched: nil, fallback: []) + XCTAssertEqual(result.pins, []) + XCTAssertFalse(result.isLive) + XCTAssertEqual(result, FloorMapData.none) + } + + func testTheWireFormatDecodesToPlainCoordinatesAndDropsEverythingElse() throws { + // The backend also sends `id` and `anchorKey`; the game must never see + // them (see GeoPin's doc comment). + let json = """ + {"pins":[ + {"id":"35200ad8-3ca3-5a89-a368-6376f0f82e21", + "latitude":-33.03596037947302, + "longitude":-71.38914117733839, + "anchorKey":"1fc0295a6d78bd1f69a522f8232ac8fab2e390dceea7f5b10941a416131b3178"} + ]} + """ + let decoded = try JSONDecoder().decode(MapPinsResponse.self, from: Data(json.utf8)) + let coordinates = decoded.coordinates + + XCTAssertEqual(coordinates.count, 1) + XCTAssertEqual(coordinates[0].latitude, -33.03596037947302, accuracy: 1e-12) + XCTAssertEqual(coordinates[0].longitude, -71.38914117733839, accuracy: 1e-12) + XCTAssertTrue(coordinates[0].isUsable) + } + + func testAnEmptyPinArrayDecodesRatherThanThrowing() throws { + let decoded = try JSONDecoder().decode( + MapPinsResponse.self, + from: Data(#"{"pins":[]}"#.utf8) + ) + XCTAssertTrue(decoded.coordinates.isEmpty) + } +} diff --git a/ios/IPPTests/PodiumBreathingTests.swift b/ios/IPPTests/PodiumBreathingTests.swift new file mode 100644 index 0000000..28309fc --- /dev/null +++ b/ios/IPPTests/PodiumBreathingTests.swift @@ -0,0 +1,233 @@ +import RealityKit +import XCTest +import simd + +@testable import IPP + +/// Phase 5B task 5B.1 (FR-012): the podium breathes. +/// +/// The height function and the rung ladder are the whole of the feature that +/// can be checked without a camera — whether a *ball* then rests on a moving +/// step is the owner's gate row 5B-g2. +@MainActor +final class PodiumBreathingTests: XCTestCase { + + private let steps = PodiumBuilder.Step.allCases + + // MARK: - The height function + + func testEveryStepStaysInsideItsAmplitudeForever() { + for step in steps { + let range = PodiumBreathing.bounds(for: step) + for tick in stride(from: 0.0, through: 120.0, by: 0.05) { + let height = PodiumBreathing.height(for: step, at: tick) + XCTAssertGreaterThanOrEqual(height, range.lowerBound - 1e-5, "\(step) at \(tick)") + XCTAssertLessThanOrEqual(height, range.upperBound + 1e-5, "\(step) at \(tick)") + } + } + } + + func testTheShortestStepNeverApproachesZeroHeight() { + // A step that shrank to nothing would drop the trophy through the + // table; bronze is the one with the least room. + let bronze = PodiumBreathing.bounds(for: .bronze) + XCTAssertGreaterThan(bronze.lowerBound, 0.02, "the podium must stay a podium") + } + + func testEachStepPassesThroughItsRestingHeight() { + for step in steps { + let range = PodiumBreathing.bounds(for: step) + XCTAssertEqual((range.lowerBound + range.upperBound) / 2, step.height, accuracy: 1e-6) + } + } + + func testTheThreeStepsBreatheOutOfPhase() { + // Same instant, three different points in the cycle: the podium never + // pulses as one block. + let offsets = steps.map { PodiumBreathing.height(for: $0, at: 0) - $0.height } + for (a, b) in [(0, 1), (0, 2), (1, 2)] { + XCTAssertGreaterThan( + abs(offsets[a] - offsets[b]), 0.005, + "\(steps[a]) and \(steps[b]) start too close together" + ) + } + } + + func testTheThreeStepsHaveDifferentPeriods() { + let periods = steps.map { PodiumBreathing.period(for: $0) } + XCTAssertEqual(Set(periods).count, periods.count, "equal periods would lock the steps together") + for period in periods { + XCTAssertGreaterThanOrEqual(period, 3.0, "the breath must stay slow") + XCTAssertLessThanOrEqual(period, 5.5) + } + } + + func testHeightRepeatsAfterExactlyOnePeriod() { + for step in steps { + let period = PodiumBreathing.period(for: step) + for tick in stride(from: 0.0, through: 3.0, by: 0.25) { + XCTAssertEqual( + PodiumBreathing.height(for: step, at: tick), + PodiumBreathing.height(for: step, at: tick + period), + accuracy: 1e-4, + "\(step) at \(tick)" + ) + } + } + } + + func testAFrozenClockFreezesTheHeight() { + // How the pause during a make celebration works: the coordinator stops + // advancing the clock, so the same time gives the same height and the + // breath resumes from the same phase rather than jumping. + for step in steps { + let held = PodiumBreathing.height(for: step, at: 7.5) + XCTAssertEqual(PodiumBreathing.height(for: step, at: 7.5), held) + } + } + + func testDegenerateOscillatorsReturnTheRestingHeight() { + XCTAssertEqual( + PodiumBreathing.height(base: 0.09, amplitude: 0.02, period: 0, phase: 0, at: 3), + 0.09 + ) + XCTAssertEqual( + PodiumBreathing.height(base: 0.09, amplitude: 0, period: 4, phase: 0, at: 3), + 0.09 + ) + } + + // MARK: - The rung ladder + + func testEachLadderSpansExactlyTheStepsBand() { + for step in steps { + guard let ladder = PodiumBreathing.ladder(for: step) else { + return XCTFail("no ladder for \(step)") + } + let range = PodiumBreathing.bounds(for: step) + XCTAssertEqual(ladder.rungs.count, PodiumBreathing.rungCount) + XCTAssertEqual(ladder.rungs.first?.height ?? 0, range.lowerBound, accuracy: 1e-6) + XCTAssertEqual(ladder.rungs.last?.height ?? 0, range.upperBound, accuracy: 1e-6) + for (lower, higher) in zip(ladder.rungs, ladder.rungs.dropFirst()) { + XCTAssertGreaterThan(higher.height, lower.height) + } + } + } + + func testTheQuantisationIsFinerThanTheEyeAndThanTheBall() { + let ballRadius = TossController.Tuning().ballRadius + for step in steps { + guard let ladder = PodiumBreathing.ladder(for: step) else { + return XCTFail("no ladder for \(step)") + } + XCTAssertLessThan(ladder.spacing, 0.004, "a visible step in the animation") + XCTAssertLessThan( + ladder.spacing, ballRadius / 5, + "a rung change must nudge a resting ball, not punch it" + ) + } + } + + func testTheLadderRoundsToTheNearestRungAndClampsAtTheEnds() { + guard let ladder = PodiumBreathing.ladder(for: .gold) else { + return XCTFail("no ladder for gold") + } + let range = PodiumBreathing.bounds(for: .gold) + + XCTAssertEqual(ladder.index(nearest: range.lowerBound), 0) + XCTAssertEqual(ladder.index(nearest: range.upperBound), ladder.rungs.count - 1) + XCTAssertEqual(ladder.index(nearest: range.lowerBound - 10), 0, "clamped below") + XCTAssertEqual(ladder.index(nearest: range.upperBound + 10), ladder.rungs.count - 1) + XCTAssertEqual(ladder.index(nearest: .nan), 0, "and a NaN cannot crash the loop") + + // Halfway between two rungs rounds to one of them, and never further + // than half a rung away from what was asked for. + for target in stride(from: range.lowerBound, through: range.upperBound, by: 0.0005) { + let chosen = ladder.rung(nearest: target) + XCTAssertLessThanOrEqual(abs(chosen.height - target), ladder.spacing / 2 + 1e-5) + } + } + + func testEveryHeightTheFunctionCanAskForIsOnTheLadder() { + for step in steps { + guard let ladder = PodiumBreathing.ladder(for: step) else { + return XCTFail("no ladder for \(step)") + } + for tick in stride(from: 0.0, through: 20.0, by: 0.05) { + let wanted = PodiumBreathing.height(for: step, at: tick) + let index = ladder.index(nearest: wanted) + XCTAssertTrue(ladder.rungs.indices.contains(index), "\(step) at \(tick)") + } + } + } + + func testLaddersAreSharedRatherThanRebuiltPerPlacement() { + // The meshes are the expensive part; two placements must not pay twice. + let first = PodiumBreathing.ladder(for: .silver) + let second = PodiumBreathing.ladder(for: .silver) + XCTAssertEqual(first?.rungs.count, second?.rungs.count) + XCTAssertTrue(first?.rungs.first?.mesh === second?.rungs.first?.mesh) + } + + // MARK: - Applying a rung to a real step + + func testResizingAStepMovesItsMeshAndItsColliderTogether() { + let step = PodiumBuilder.makeStep( + name: PodiumBuilder.Name.goldStep, + color: PodiumBuilder.Medal.gold, + height: PodiumBuilder.Metrics.goldHeight, + x: 0 + ) + guard let ladder = PodiumBreathing.ladder(for: .gold) else { + return XCTFail("no ladder for gold") + } + let tall = ladder.rungs[ladder.rungs.count - 1] + + PodiumBuilder.resize(step, mesh: tall.mesh, shape: tall.shape, height: tall.height) + + XCTAssertEqual(step.position.y, tall.height / 2, accuracy: 1e-6, "still resting on the surface") + XCTAssertTrue(step.model?.mesh === tall.mesh) + XCTAssertEqual(step.collision?.shapes.count, 1) + // The step is never scaled — that is the point of the ladder, since a + // scaled collider is a RealityKit behaviour this cannot verify. + XCTAssertEqual(step.scale, .one) + + let box = step.model?.mesh.bounds.extents ?? .zero + XCTAssertEqual(box.y, tall.height, accuracy: 0.002, "the drawn box is the asked-for height") + } + + func testAStepDrawnAtARungIsCollidableAndStatic() { + let step = PodiumBuilder.makeStep( + name: PodiumBuilder.Name.bronzeStep, + color: PodiumBuilder.Medal.bronze, + height: PodiumBuilder.Metrics.bronzeHeight, + x: 0 + ) + guard let shortest = PodiumBreathing.ladder(for: .bronze)?.rungs.first else { + return XCTFail("no ladder for bronze") + } + PodiumBuilder.resize(step, mesh: shortest.mesh, shape: shortest.shape, height: shortest.height) + + XCTAssertFalse(step.collision?.shapes.isEmpty ?? true) + XCTAssertEqual(step.components[PhysicsBodyComponent.self]?.mode, .static) + } + + // MARK: - What rides the step + + func testTheTrophyStandsOnWhateverHeightItsStepCurrentlyHas() { + for step in steps { + let range = PodiumBreathing.bounds(for: step) + for height in [range.lowerBound, step.height, range.upperBound] { + let place = step.trophyPosition(atHeight: height) + XCTAssertEqual(place.y, height, accuracy: 1e-6) + XCTAssertEqual(place.x, step.x, accuracy: 1e-6) + XCTAssertEqual(place.z, 0, accuracy: 1e-6) + } + } + // And the no-argument form is still the resting height. + XCTAssertEqual( + PodiumBuilder.Step.gold.trophyPosition, + PodiumBuilder.Step.gold.trophyPosition(atHeight: PodiumBuilder.Step.gold.height) + ) + } +} diff --git a/ios/IPPTests/PodiumBuilderTests.swift b/ios/IPPTests/PodiumBuilderTests.swift new file mode 100644 index 0000000..b8c38c6 --- /dev/null +++ b/ios/IPPTests/PodiumBuilderTests.swift @@ -0,0 +1,389 @@ +import RealityKit +import XCTest +import simd + +@testable import IPP + +/// A generator that always yields zero, so a "pick one of the others at +/// random" rule can be shown to hold even when the randomness does not help. +private struct AlwaysZeroGenerator: RandomNumberGenerator { + mutating func next() -> UInt64 { 0 } +} + +/// Throwaway harness used during Phase 2 to run `PodiumBuilder`'s structural +/// assertions for real on a Simulator. Phase 6a adds the permanent test target; +/// this file lives outside the repo on purpose. +@MainActor +final class PodiumBuilderTests: XCTestCase { + + func testSelfCheckReportsNoProblems() { + XCTAssertEqual(PodiumBuilder.selfCheck(), []) + } + + func testSceneContainsThreeStepsCupAndFloor() { + let scene = PodiumBuilder.makeScene() + + for name in [ + PodiumBuilder.Name.goldStep, + PodiumBuilder.Name.silverStep, + PodiumBuilder.Name.bronzeStep, + PodiumBuilder.Name.trophy, + PodiumBuilder.Name.cup, + PodiumBuilder.Name.cupFloor, + PodiumBuilder.Name.floor + ] { + XCTAssertNotNil(scene.findEntity(named: name), "missing \(name)") + } + + let wallSegments = (0.. = [] + for _ in 0..<200 { + seen.insert(PodiumBuilder.nextStep(after: current, using: &generator)) + } + XCTAssertEqual(seen, Set(PodiumBuilder.Step.allCases).subtracting([current])) + } + } + + /// A rigged generator proves the "never the current step" property is + /// structural (the current step is removed from the pool) rather than a + /// lucky draw: even a generator that always picks the first candidate + /// cannot land on the current step. + func testTheStepPickerHoldsWithADegenerateGenerator() { + var generator = AlwaysZeroGenerator() + for current in PodiumBuilder.Step.allCases { + for _ in 0..<10 { + XCTAssertNotEqual(PodiumBuilder.nextStep(after: current, using: &generator), current) + } + } + } + + func testEveryStepPutsTheTrophyOnItsOwnTopFace() { + for step in PodiumBuilder.Step.allCases { + XCTAssertEqual(step.trophyPosition.y, step.height, accuracy: 0.0001) + XCTAssertEqual(step.trophyPosition.x, step.x, accuracy: 0.0001) + XCTAssertEqual(step.trophyPosition.z, 0, accuracy: 0.0001) + } + // The three steps really are three different places to aim at. + let places = PodiumBuilder.Step.allCases.map { $0.trophyPosition } + for (index, place) in places.enumerated() { + for other in places[(index + 1)...] { + XCTAssertGreaterThan(simd_distance(place, other), 0.02, "steps are too close to matter") + } + } + } + + func testStepsRestOnTheAnchorPlane() { + let scene = PodiumBuilder.makeScene() + let expected: [(String, Float)] = [ + (PodiumBuilder.Name.goldStep, PodiumBuilder.Metrics.goldHeight), + (PodiumBuilder.Name.silverStep, PodiumBuilder.Metrics.silverHeight), + (PodiumBuilder.Name.bronzeStep, PodiumBuilder.Metrics.bronzeHeight) + ] + for (name, height) in expected { + guard let step = scene.findEntity(named: name) else { + XCTFail("missing \(name)") + continue + } + XCTAssertEqual(step.position.y, height / 2, accuracy: 0.0001, "\(name)") + } + } + + // MARK: - Flared rim (Phase 4, task 4.0b — Gate 3 row 3.2) + + /// The cup must be a shallow cone, not a tube: wider at the mouth than at + /// the floor, so its rim is a slope with nowhere for a ball to balance. + func testTheCupWidensTowardItsMouth() { + let metrics = PodiumBuilder.Metrics.self + let atFloor = metrics.cupInnerRadius(atHeight: metrics.cupFloorThickness) + let atMouth = metrics.cupInnerRadius(atHeight: metrics.cupRimHeight) + + XCTAssertGreaterThan(metrics.cupWallFlare, 0, "a flat rim is what balls balanced on") + XCTAssertGreaterThan(atMouth, atFloor + 0.005, "the flare is too slight to matter") + XCTAssertGreaterThan(atMouth, metrics.cupInnerRadius) + } + + /// The flare leans the wall's base inward, so check it did not close the + /// cup around the ball. + func testTheFlaredWallStillLetsABallReachTheCupFloor() { + let metrics = PodiumBuilder.Metrics.self + let ballRadius = TossController.Tuning().ballRadius + let restingHeight = metrics.cupFloorThickness + ballRadius + XCTAssertGreaterThan( + metrics.cupInnerRadius(atHeight: restingHeight), + ballRadius + 0.005, + "the ball cannot settle on the cup floor" + ) + } + + /// The rim slope is only a fix if it beats the rim's friction — a ball must + /// slide off it rather than grip. + func testTheRimIsSteeperThanItIsGrippy() { + let metrics = PodiumBuilder.Metrics.self + let frictionAngle = atan(metrics.cupRimFriction) + XCTAssertGreaterThan( + metrics.cupWallFlare, + frictionAngle, + "a ball would still rest on the rim instead of sliding off" + ) + } + + // MARK: - The scoring geometry (Gate 4 DEFECT, SC-002) + + /// The bridge between the built cup and the scoring rule, checked against + /// the real metrics: a ball resting on the cup floor is inside, and a ball + /// pressed against the *outside* of the wall is not — at any height on the + /// wall, the low front included, which is where the owner produced false + /// makes at Gate 4. + func testOnlyABallActuallyInTheCupReadsAsInside() { + let toss = TossController() + let radius = toss.tuning.ballRadius + let metrics = PodiumBuilder.Metrics.self + + let resting = SIMD3(0, metrics.cupFloorThickness + radius, 0) + XCTAssertTrue( + toss.isInsideCup(PodiumBuilder.cupPlacement(ofBallAt: resting), ballRadius: radius), + "a ball sitting on the cup floor must count as a make" + ) + + for step in 0...24 { + let height = Float(step) / 24 * metrics.cupRimHeight + let outward = metrics.cupInnerRadius(atHeight: height) + metrics.cupWallThickness + radius + for angle in stride(from: Float(0), to: 2 * .pi, by: .pi / 6) { + let outside = SIMD3(outward * cos(angle), height, outward * sin(angle)) + XCTAssertFalse( + toss.isInsideCup(PodiumBuilder.cupPlacement(ofBallAt: outside), ballRadius: radius), + "a ball touching the cup's outside at \(height) m read as inside" + ) + } + } + } + + /// A ball perched on the rim is not a make either — Gate 3's rim rescue and + /// Gate 4's make rule must not disagree about the same ball. + func testAPerchedBallIsPerchedAndNotInside() { + let toss = TossController() + let radius = toss.tuning.ballRadius + let metrics = PodiumBuilder.Metrics.self + let centre = SIMD3(metrics.cupRimRingRadius, metrics.cupRimHeight + radius, 0) + let placement = PodiumBuilder.cupPlacement(ofBallAt: centre) + + XCTAssertFalse(toss.isInsideCup(placement, ballRadius: radius)) + XCTAssertTrue( + toss.isPerchedOnRim( + placement, + ballRadius: radius, + cupOuterRadius: metrics.cupRimOuterRadius + ) + ) + } + + /// Every collidable part of the cup is what the +1 tier listens to, so the + /// cup has to actually have collidable parts — and they must be ordinary + /// physics bodies, not sensors. + func testTheCupHasCollidablePartsForTheHitTier() throws { + let scene = PodiumBuilder.makeScene() + let cup = try XCTUnwrap(scene.findEntity(named: PodiumBuilder.Name.cup)) + + var colliders: [Entity] = [] + func walk(_ entity: Entity) { + if entity.components[CollisionComponent.self] != nil { colliders.append(entity) } + entity.children.forEach(walk) + } + walk(cup) + + // Twelve wall segments plus the floor disc. + XCTAssertEqual(colliders.count, PodiumBuilder.Metrics.cupWallSegments + 1) + for collider in colliders { + XCTAssertEqual( + collider.components[CollisionComponent.self]?.mode, + .default, + "\(collider.name) would not produce a physical bounce" + ) + } + } + + /// The entities, not just the arithmetic: every wall segment must actually + /// lean outward, and the ring must stay closed at the mouth where the flare + /// has spread the segments furthest apart. + func testEveryWallSegmentLeansOutward() throws { + let scene = PodiumBuilder.makeScene() + let metrics = PodiumBuilder.Metrics.self + + for index in 0..( + sin(2 * Float.pi * Float(index) / Float(metrics.cupWallSegments)), + 0, + cos(2 * Float.pi * Float(index) / Float(metrics.cupWallSegments)) + ) + let localUp = segment.orientation.act(SIMD3(0, 1, 0)) + XCTAssertEqual(localUp.y, cos(metrics.cupWallFlare), accuracy: 1e-4, "segment \(index)") + XCTAssertEqual( + simd_dot(localUp, outward), + sin(metrics.cupWallFlare), + accuracy: 1e-4, + "segment \(index) leans the wrong way" + ) + } + } + + func testTheWallRingHasNoGapsAtTheMouth() throws { + let scene = PodiumBuilder.makeScene() + let metrics = PodiumBuilder.Metrics.self + let segment = try XCTUnwrap(scene.findEntity(named: "\(PodiumBuilder.Name.cupWall)_0")) + let model = try XCTUnwrap(segment.components[ModelComponent.self]) + let width = model.mesh.bounds.extents.x + let chordAtMouth = 2 * metrics.cupRimRingRadius * sin(.pi / Float(metrics.cupWallSegments)) + XCTAssertGreaterThan(width, chordAtMouth, "neighbouring segments leave a gap at the rim") + } + + // MARK: - Ball (Phase 3) + + func testBallIsADynamicSphereWithCollision() { + let ball = PodiumBuilder.makeBall( + id: 7, + radius: 0.035, + mass: 0.045, + friction: 0.6, + restitution: 0.35 + ) + XCTAssertEqual(ball.name, "\(PodiumBuilder.Name.ballPrefix)7") + XCTAssertNotNil(ball.components[ModelComponent.self], "the ball must be visible") + XCTAssertFalse(ball.components[CollisionComponent.self]?.shapes.isEmpty ?? true) + XCTAssertEqual(ball.components[PhysicsBodyComponent.self]?.mode, .dynamic) + XCTAssertEqual(ball.components[PhysicsBodyComponent.self]?.massProperties.mass, 0.045) + XCTAssertTrue( + ball.components[PhysicsBodyComponent.self]?.isContinuousCollisionDetectionEnabled ?? false, + "a fast ball would tunnel through the 6 mm cup wall without CCD" + ) + XCTAssertNotNil( + ball.components[PhysicsMotionComponent.self], + "the culler reads the ball's velocity from the first frame" + ) + } + + /// The +1 tier rests on the cup wall and the ball being able to see each + /// other's collision filters. Assert it rather than discovering it on + /// device. + func testBallAndCupWallCollisionFiltersSeeEachOther() throws { + let scene = PodiumBuilder.makeScene() + let wall = try XCTUnwrap(scene.findEntity(named: "\(PodiumBuilder.Name.cupWall)_0")) + let wallFilter = try XCTUnwrap(wall.components[CollisionComponent.self]).filter + + let ball = PodiumBuilder.makeBall( + id: 1, + radius: 0.035, + mass: 0.045, + friction: 0.6, + restitution: 0.35 + ) + let ballFilter = try XCTUnwrap(ball.components[CollisionComponent.self]).filter + + XCTAssertNotEqual( + wallFilter.mask.rawValue & ballFilter.group.rawValue, + 0, + "the cup wall cannot see the ball" + ) + XCTAssertNotEqual( + ballFilter.mask.rawValue & wallFilter.group.rawValue, + 0, + "the ball cannot see the cup wall" + ) + } + + func testCylinderMeshIsGeneratedProcedurally() { + let mesh = PodiumBuilder.cylinderMesh(height: 0.02, radius: 0.05, segments: 16) + let parts = Array(mesh.contents.models).flatMap { Array($0.parts) } + XCTAssertFalse(parts.isEmpty, "cylinder mesh has no parts") + // 4n + 2 vertices and 12n indices for n = 16. + let positions = parts.reduce(0) { $0 + $1.positions.count } + let indices = parts.reduce(0) { $0 + ($1.triangleIndices?.count ?? 0) } + XCTAssertEqual(positions, 4 * 16 + 2) + XCTAssertEqual(indices, 12 * 16) + XCTAssertTrue(parts.allSatisfy { $0.normals != nil }, "cylinder mesh has no normals") + } +} diff --git a/ios/IPPTests/StandingsDisplayTests.swift b/ios/IPPTests/StandingsDisplayTests.swift new file mode 100644 index 0000000..8debf00 --- /dev/null +++ b/ios/IPPTests/StandingsDisplayTests.swift @@ -0,0 +1,187 @@ +import RealityKit +import XCTest +import simd + +@testable import IPP + +/// Phase 5B task 5B.3 (FR-013): the podium name labels. Whether they *look* +/// right is the owner's gate row; what is checked here is that they are built, +/// positioned, tinted and — above all — incapable of touching a ball. +/// +/// **Phase 5C trimmed this suite.** The Star Wars crawl of places #4+ was +/// removed from the game by owner decision (the space under the podium is now +/// `FloorMap`), so the thirteen crawl cases went with it. What remains is the +/// label half, unchanged, plus one case asserting the crawl really is gone +/// rather than merely unused. +@MainActor +final class StandingsDisplayTests: XCTestCase { + + private func placedScene() -> (scene: Entity, display: StandingsDisplay.Display) { + let scene = PodiumBuilder.makeScene() + let display = StandingsDisplay.attach(to: scene, standings: SyntheticStandings.standings()) + return (scene, display) + } + + private func descendants(of entity: Entity) -> [Entity] { + entity.children.flatMap { [$0] + descendants(of: $0) } + } + + // MARK: - What gets built + + func testThePodiumGetsExactlyThreeLabelsAndNothingElse() { + let (_, display) = placedScene() + + XCTAssertEqual(display.labels.count, 3) + XCTAssertEqual(display.labels.map(\.step), [.gold, .silver, .bronze]) + } + + func testTheCrawlIsGoneFromThePlacedScene() { + // Phase 5C removed it. A leftover crawl root would mean the deletion + // was cosmetic and the text is still marching under the floor map. + let (scene, _) = placedScene() + for name in ["standings_crawl", "crawl_line_0", "crawl_line_1"] { + XCTAssertNil(scene.findEntity(named: name), "the crawl survived: \(name)") + } + } + + func testLabelsHangOffTheStepsContainerSoTheyInheritThePodiumsPlacement() { + let (scene, _) = placedScene() + guard let labelRoot = scene.findEntity(named: StandingsDisplay.Name.labels) else { + return XCTFail("no label root") + } + XCTAssertEqual(labelRoot.parent?.name, PodiumBuilder.Name.steps) + } + + func testEveryPieceOfSceneryIsInertNoCollisionNoPhysics() { + // FR-013: "purely decorative". Nothing here may ever touch a ball. + let (scene, _) = placedScene() + guard let root = scene.findEntity(named: StandingsDisplay.Name.labels) else { + return XCTFail("no label root") + } + for entity in [root] + descendants(of: root) { + XCTAssertNil(entity.components[CollisionComponent.self], entity.name) + XCTAssertNil(entity.components[PhysicsBodyComponent.self], entity.name) + } + } + + func testTheSceneryAddsNoCollidersToThePodiumAtAll() { + // Stronger version of the above: the placed scene must have exactly the + // colliders `PodiumBuilder` puts there, with or without the standings. + func colliders(_ entity: Entity) -> Int { + ([entity] + descendants(of: entity)) + .filter { $0.components[CollisionComponent.self] != nil } + .count + } + let bare = PodiumBuilder.makeScene() + let before = colliders(bare) + _ = StandingsDisplay.attach(to: bare, standings: SyntheticStandings.standings()) + XCTAssertEqual(colliders(bare), before) + } + + func testEachLabelCarriesItsNameAndItsScoreAsTwoLinesOfRealGeometry() { + let (_, display) = placedScene() + for label in display.labels { + let models = descendants(of: label.entity).compactMap { $0 as? ModelEntity } + XCTAssertEqual(models.count, 2, "a name and a score") + for model in models { + XCTAssertGreaterThan( + model.model?.mesh.bounds.extents.x ?? 0, 0, + "the text mesh is empty" + ) + } + // Name above, score below. + let heights = models.map(\.position.y).sorted() + XCTAssertLessThan(heights[0], heights[1]) + } + } + + func testLabelsAreTintedWithTheirStepsMedalColour() { + XCTAssertEqual(StandingsDisplay.tint(for: .gold), PodiumBuilder.Medal.gold) + XCTAssertEqual(StandingsDisplay.tint(for: .silver), PodiumBuilder.Medal.silver) + XCTAssertEqual(StandingsDisplay.tint(for: .bronze), PodiumBuilder.Medal.bronze) + } + + func testATextModelIsCentredOnItsOwnOrigin() { + // Otherwise every billboard rotation would swing the label around its + // left edge. + let model = StandingsDisplay.makeTextModel( + "Dra. Ficticia", + size: StandingsDisplay.Look.nameSize, + material: UnlitMaterial(color: PodiumBuilder.Medal.gold) + ) + let bounds = model.visualBounds(relativeTo: model.parent) + XCTAssertEqual(bounds.center.x, 0, accuracy: 0.002) + XCTAssertEqual(bounds.center.y, 0, accuracy: 0.002) + // …and scaled to roughly the requested em size, not left at design size. + XCTAssertLessThan(bounds.extents.y, StandingsDisplay.Look.nameSize * 2) + } + + func testTextIsBuiltAtAHumanScale() { + // The em size is in metres, so a wrong constant does not fail to + // compile — it puts a three-metre name across the room. These bounds + // are the "does this fit on a podium" check. + let standings = SyntheticStandings.standings() + let white = UnlitMaterial(color: .white) + + let name = StandingsDisplay.makeTextModel( + standings[0].shortName, + size: StandingsDisplay.Look.nameSize, + material: white + ) + let nameBounds = name.visualBounds(relativeTo: nil) + print("label width \(nameBounds.extents.x) m, height \(nameBounds.extents.y) m") + XCTAssertGreaterThan(nameBounds.extents.x, 0.03) + XCTAssertLessThan(nameBounds.extents.x, 0.20, "wider than the whole podium") + XCTAssertGreaterThan(nameBounds.extents.y, 0.005, "too small to read at a metre") + XCTAssertLessThan(nameBounds.extents.y, 0.025) + } + + // MARK: - Riding a breathing step (FR-012 × FR-013) + + func testALabelIsSeatedAboveAndInFrontOfItsStepsCurrentTopFace() { + let label = Entity() + for step in PodiumBuilder.Step.allCases { + let range = PodiumBreathing.bounds(for: step) + for height in [range.lowerBound, step.height, range.upperBound] { + StandingsDisplay.seat(label, on: step, height: height) + XCTAssertEqual(label.position.x, step.x, accuracy: 1e-6) + XCTAssertGreaterThan(label.position.y, height, "the label floats above the step") + XCTAssertEqual( + label.position.y - height, + StandingsDisplay.Look.labelLift, + accuracy: 1e-6 + ) + XCTAssertGreaterThan( + label.position.z, PodiumBuilder.Metrics.stepDepth / 2, + "in front of the step, so it never fights the trophy for the same air" + ) + } + } + } + + // MARK: - Billboarding + + func testBillboardingTurnsALabelsFaceTowardTheCamera() { + let label = Entity() + label.position = [0, 0.2, 0] + for angle in stride(from: Float(0), through: 350, by: 25) { + let radians = angle * .pi / 180 + let camera = SIMD3(2 * sin(radians), 0.4, 2 * cos(radians)) + StandingsDisplay.billboard(label, toward: camera) + + let facing = label.orientation.act(SIMD3(0, 0, 1)) + let toCamera = simd_normalize(SIMD3(camera.x, 0, camera.z) - [0, 0, 0]) + XCTAssertEqual(facing.x, toCamera.x, accuracy: 1e-4, "\(angle)°") + XCTAssertEqual(facing.z, toCamera.z, accuracy: 1e-4, "\(angle)°") + XCTAssertEqual(facing.y, 0, accuracy: 1e-5, "yaw only — text must stay upright") + } + } + + func testBillboardingIgnoresACameraDirectlyAboveTheLabel() { + let label = Entity() + label.position = [0, 0.2, 0] + let before = label.orientation + StandingsDisplay.billboard(label, toward: [0, 3, 0]) + XCTAssertEqual(label.orientation.vector, before.vector) + } +} diff --git a/ios/IPPTests/SyntheticMapPinsTests.swift b/ios/IPPTests/SyntheticMapPinsTests.swift new file mode 100644 index 0000000..11a16bb --- /dev/null +++ b/ios/IPPTests/SyntheticMapPinsTests.swift @@ -0,0 +1,98 @@ +import XCTest + +@testable import IPP + +/// Phase 5C: the floor map's offline sample. It has to be deterministic (so the +/// map is the same every time), usable (so the projection never has to throw +/// any of it away) and obviously not real. +final class SyntheticMapPinsTests: XCTestCase { + + func testEveryPinIsUsable() { + let pins = SyntheticMapPins.pins() + XCTAssertFalse(pins.isEmpty) + for pin in pins { + XCTAssertTrue(pin.isUsable, "\(pin)") + } + } + + func testTheSampleIsAsBigAsItsClustersSayAndSmallerThanTheDotCap() { + let expected = SyntheticMapPins.clusters.reduce(0) { $0 + $1.count } + XCTAssertEqual(SyntheticMapPins.pins().count, expected) + XCTAssertLessThanOrEqual(expected, FloorMap.Look.maxDots, "the sample is never thinned") + XCTAssertGreaterThan(expected, 40, "too few dots and it does not read as a map") + } + + func testTheSameSeedAlwaysGivesTheSameMap() { + XCTAssertEqual(SyntheticMapPins.pins(), SyntheticMapPins.pins()) + XCTAssertEqual(SyntheticMapPins.pins(seed: 7), SyntheticMapPins.pins(seed: 7)) + } + + func testDifferentSeedsGiveDifferentMaps() { + XCTAssertNotEqual(SyntheticMapPins.pins(seed: 1), SyntheticMapPins.pins(seed: 2)) + } + + func testThePinsStayNearTheirClusterCentres() { + // Box–Muller with an unclamped u1 would occasionally throw a pin across + // the Pacific and blow up the bounding-box fit; 6σ is a generous bound + // that a broken generator would still fail. + var index = 0 + let pins = SyntheticMapPins.pins() + for cluster in SyntheticMapPins.clusters { + for _ in 0.. TossController.Swipe { + TossController.Swipe(translation: SIMD2(sideways, -up), duration: duration) + } + + private var tuning: TossController.Tuning { TossController.Tuning() } + + // MARK: - Speed clamping + + func testZeroSwipeStillProducesAClampedForwardImpulse() { + let controller = TossController() + let flat = swipe() + + XCTAssertEqual(controller.launchSpeed(for: flat), tuning.minLaunchSpeed, accuracy: 1e-5) + + let velocity = controller.launchVelocity(for: flat, camera: camera) + XCTAssertEqual(simd_length(velocity), tuning.minLaunchSpeed, accuracy: 1e-4) + XCTAssertLessThan(velocity.z, 0, "a zero swipe must still travel away from the player") + XCTAssertGreaterThan(velocity.y, 0, "every throw is lofted") + XCTAssertEqual(velocity.x, 0, accuracy: 1e-5, "a straight swipe must not drift sideways") + } + + func testSlowShortSwipeClampsToTheMinimumSpeed() { + let controller = TossController() + // 30 pt over half a second — 60 pt/s, far below `slowFlick`. + XCTAssertEqual( + controller.launchSpeed(for: swipe(up: 30, duration: 0.5)), + tuning.minLaunchSpeed, + accuracy: 1e-5 + ) + } + + func testFastLongSwipeClampsToTheMaximumSpeed() { + let controller = TossController() + // 600 pt in 80 ms — 7500 pt/s, far above `fastFlick`. + XCTAssertEqual( + controller.launchSpeed(for: swipe(up: 600, duration: 0.08)), + tuning.maxLaunchSpeed, + accuracy: 1e-5 + ) + } + + func testSpeedRisesWithFlickSpeedBetweenTheClamps() { + let controller = TossController() + let gentle = controller.launchSpeed(for: swipe(up: 120, duration: 0.20)) // 600 pt/s + let firm = controller.launchSpeed(for: swipe(up: 200, duration: 0.15)) // ~1333 pt/s + let hard = controller.launchSpeed(for: swipe(up: 260, duration: 0.12)) // ~2167 pt/s + + XCTAssertLessThan(gentle, firm) + XCTAssertLessThan(firm, hard) + for speed in [gentle, firm, hard] { + XCTAssertGreaterThanOrEqual(speed, tuning.minLaunchSpeed) + XCTAssertLessThanOrEqual(speed, tuning.maxLaunchSpeed) + } + } + + func testAnImplausiblyBriefSwipeCannotDivideItsWayPastTheMaximum() { + let controller = TossController() + XCTAssertEqual( + controller.launchSpeed(for: swipe(up: 200, duration: 0)), + tuning.maxLaunchSpeed, + accuracy: 1e-5 + ) + } + + // MARK: - Direction + + func testEveryThrowIsLoftedByTheTunedArc() { + let controller = TossController() + let velocity = controller.launchVelocity(for: swipe(up: 200), camera: camera) + // Camera aims along −Z, so the loft shows up as up-over-forward. + XCTAssertEqual(velocity.y / -velocity.z, tuning.arc, accuracy: 1e-4) + } + + func testSidewaysSwipeDeflectsTowardTheSwipeWithoutAddingPower() { + let controller = TossController() + let right = controller.launchVelocity(for: swipe(up: 120, sideways: 300), camera: camera) + let left = controller.launchVelocity(for: swipe(up: 120, sideways: -300), camera: camera) + let straight = controller.launchVelocity(for: swipe(up: 120), camera: camera) + + XCTAssertGreaterThan(right.x, 0, "swiping right must throw right") + XCTAssertLessThan(left.x, 0, "swiping left must throw left") + XCTAssertEqual(right.x, -left.x, accuracy: 1e-5, "deflection must be symmetric") + + // Power comes from the upward component alone, so all three are the + // same speed in different directions. + XCTAssertEqual(simd_length(right), simd_length(straight), accuracy: 1e-4) + XCTAssertEqual(simd_length(left), simd_length(straight), accuracy: 1e-4) + } + + func testPurelySidewaysSwipeIsClampedToTheMinimumSpeedAndStillAimsForward() { + let controller = TossController() + let velocity = controller.launchVelocity(for: swipe(sideways: 400), camera: camera) + + XCTAssertEqual(simd_length(velocity), tuning.minLaunchSpeed, accuracy: 1e-4) + XCTAssertLessThan(velocity.z, 0) + XCTAssertGreaterThan(velocity.x, 0) + } + + func testLateralDeflectionIsCapped() { + let controller = TossController() + XCTAssertEqual( + controller.lateralDeflection(for: swipe(up: 100, sideways: 5000)), + tuning.maxLateral, + accuracy: 1e-5 + ) + XCTAssertEqual( + controller.lateralDeflection(for: swipe(up: 100, sideways: -5000)), + -tuning.maxLateral, + accuracy: 1e-5 + ) + } + + func testDirectionFollowsTheCameraRatherThanTheWorldAxes() { + let controller = TossController() + // Player turned 90° to face +X. + let turned = TossController.CameraBasis( + position: [0, 1, 0], + forward: [1, 0, 0], + right: [0, 0, 1] + ) + let velocity = controller.launchVelocity(for: swipe(up: 200), camera: turned) + XCTAssertGreaterThan(velocity.x, 0, "the throw must follow the camera's aim") + XCTAssertEqual(velocity.z, 0, accuracy: 1e-5) + XCTAssertGreaterThan(velocity.y, 0) + } + + func testCameraBasisIsReadFromAnARKitStyleTransform() { + // ARKit camera transform, in its own landscape-right axes: +x right, + // +y up, +z backward. + var transform = matrix_identity_float4x4 + transform.columns.3 = SIMD4(0.5, 1.2, -0.3, 1) + let basis = TossController.CameraBasis(transform: transform, orientation: .landscapeRight) + + XCTAssertEqual(basis.position, SIMD3(0.5, 1.2, -0.3)) + XCTAssertEqual(basis.forward, SIMD3(0, 0, -1)) + XCTAssertEqual(basis.right, SIMD3(1, 0, 0)) + } + + // MARK: - The sideways axis (Q4, Phase 5B task 5B.0) + // + // Gate 5 row 5-g5: "I only see straight ball launches even with diagonal + // swipes". ARKit hands out a camera transform in landscape-right axes + // whatever the device is doing, and IPP is portrait-locked, so the old + // `columns.0` reading was steering along the phone's long axis. + + /// The ARKit camera transform of a phone held **in portrait**, back camera + /// looking along −Z, tilted `pitch` radians downward at the table. + /// + /// In landscape-right axes that means `columns.0` (ARKit's "right") runs + /// down the phone's long axis and `columns.1` (ARKit's "up") runs across + /// the screen — which is the whole of Q4, in two columns. + private func portraitCameraTransform( + pitch: Float = 0, + position: SIMD3 = [0, 1.3, 0] + ) -> simd_float4x4 { + let c = cos(pitch) + let s = sin(pitch) + var transform = matrix_identity_float4x4 + transform.columns.0 = SIMD4(0, -c, s, 0) + transform.columns.1 = SIMD4(1, 0, 0, 0) + transform.columns.2 = SIMD4(0, s, c, 0) + transform.columns.3 = SIMD4(position, 1) + return transform + } + + func testPortraitBasisReadsRightAcrossTheScreenNotAlongThePhone() { + let pitch: Float = 0.35 + let transform = portraitCameraTransform(pitch: pitch) + + // The bug, stated as an assertion: ARKit's first column is very nearly + // world-vertical for this pose, so reading it as "right" steers the + // throw up and down. + let landscapeColumn = SIMD3( + transform.columns.0.x, transform.columns.0.y, transform.columns.0.z + ) + XCTAssertGreaterThan(abs(landscapeColumn.y), 0.9) + + let basis = TossController.CameraBasis(transform: transform) + XCTAssertEqual(basis.right.x, 1, accuracy: 1e-5, "portrait right is across the screen") + XCTAssertEqual(basis.right.y, 0, accuracy: 1e-5) + XCTAssertEqual(basis.right.z, 0, accuracy: 1e-5) + XCTAssertEqual(basis.forward.y, -sin(pitch), accuracy: 1e-5, "and the aim still points down") + XCTAssertEqual(basis.forward.z, -cos(pitch), accuracy: 1e-5) + } + + func testTheFourOrientationsAreQuarterTurnsOfEachOther() { + var transform = matrix_identity_float4x4 + transform.columns.0 = SIMD4(1, 0, 0, 0) + transform.columns.1 = SIMD4(0, 1, 0, 0) + + func right(_ orientation: TossController.ScreenOrientation) -> SIMD3 { + TossController.CameraBasis(transform: transform, orientation: orientation).right + } + + XCTAssertEqual(right(.landscapeRight), SIMD3(1, 0, 0)) + XCTAssertEqual(right(.landscapeLeft), SIMD3(-1, 0, 0)) + XCTAssertEqual(right(.portrait), SIMD3(0, 1, 0)) + XCTAssertEqual(right(.portraitUpsideDown), SIMD3(0, -1, 0)) + } + + func testPortraitIsTheDefaultOrientationBecauseTheAppIsPortraitLocked() { + let transform = portraitCameraTransform(pitch: 0.2) + XCTAssertEqual( + TossController.CameraBasis(transform: transform).right, + TossController.CameraBasis(transform: transform, orientation: .portrait).right + ) + } + + func testSideAxisIsHorizontalAndAcrossTheAimForEveryPose() { + for pitchDegrees in stride(from: Float(-40), through: 60, by: 10) { + for yawDegrees in stride(from: Float(0), through: 315, by: 45) { + let pitch = pitchDegrees * .pi / 180 + let yaw = yawDegrees * .pi / 180 + let turn = simd_float4x4(simd_quatf(angle: yaw, axis: [0, 1, 0])) + let transform = turn * portraitCameraTransform(pitch: pitch) + let basis = TossController.CameraBasis(transform: transform) + let side = basis.sideAxis + let label = "pitch \(pitchDegrees)° yaw \(yawDegrees)°" + + XCTAssertEqual(simd_length(side), 1, accuracy: 1e-4, label) + XCTAssertEqual( + simd_dot(side, TossController.worldUp), 0, accuracy: 1e-4, + "\(label): steering must be orthogonal to gravity" + ) + XCTAssertEqual( + simd_dot(side, basis.forward), 0, accuracy: 1e-4, + "\(label): steering must be orthogonal to the aim" + ) + } + } + } + + func testDiagonalFlickVeersSidewaysInsteadOfChangingHeight() { + let controller = TossController() + // Looking 20° down at a podium on a table, portrait, as at Gate 5. + let basis = TossController.CameraBasis(transform: portraitCameraTransform(pitch: 0.35)) + let side = basis.sideAxis + + let straight = controller.launchVelocity(for: swipe(up: 200), camera: basis) + let diagonal = controller.launchVelocity(for: swipe(up: 200, sideways: 260), camera: basis) + let mirrored = controller.launchVelocity(for: swipe(up: 200, sideways: -260), camera: basis) + let speed = controller.launchSpeed(for: swipe(up: 200)) + + // A straight flick has no sideways component at all… + XCTAssertEqual(simd_dot(straight, side), 0, accuracy: 1e-4) + // …and a diagonal one has a big one, in the direction of the swipe. + XCTAssertGreaterThan( + simd_dot(diagonal, side), 0.25 * speed, + "an up-and-right flick must actually veer right" + ) + XCTAssertEqual( + simd_dot(mirrored, side), -simd_dot(diagonal, side), accuracy: 1e-4, + "and up-and-left must mirror it" + ) + XCTAssertGreaterThan(diagonal.x, 0.25 * speed, "which here is world +x") + XCTAssertLessThan(mirrored.x, -0.25 * speed) + + // The height of the throw is what must *not* move: the deflection is + // horizontal, so the only change in the vertical component is the small + // one that comes from re-normalising a longer heading vector. + XCTAssertEqual( + diagonal.y, straight.y, + accuracy: 0.1 * abs(straight.y), + "steering must not trade itself for loft — that was the Q4 bug" + ) + XCTAssertEqual(simd_length(diagonal), simd_length(straight), accuracy: 1e-4) + } + + func testTheOldLandscapeReadingSteeredAlongAQuiteDifferentAxis() { + // A regression witness rather than a rule: with the same pose read as + // landscape-right — what the code did until Phase 5B — the steering + // axis is (nearly) perpendicular to the screen's real right-hand axis, + // so a rightward swipe pushed the ball somewhere else entirely. + let transform = portraitCameraTransform(pitch: 0.35) + let corrected = TossController.CameraBasis(transform: transform).sideAxis + let old = TossController.CameraBasis(transform: transform, orientation: .landscapeRight).sideAxis + + XCTAssertEqual(abs(simd_dot(corrected, old)), 0, accuracy: 1e-4) + } + + func testSideAxisFallsBackToTheAimWhenTheScreenAxisIsVertical() { + // A phone rolled fully onto its side would hand over a vertical + // "right"; there is nothing horizontal to recover from it, so the axis + // comes from the aim instead. + let rolled = TossController.CameraBasis( + position: [0, 1, 0], + forward: [0, 0, -1], + right: [0, 1, 0] + ) + XCTAssertEqual(rolled.sideAxis.x, 1, accuracy: 1e-5) + XCTAssertEqual(rolled.sideAxis.y, 0, accuracy: 1e-5) + XCTAssertEqual(rolled.sideAxis.z, 0, accuracy: 1e-5) + } + + func testSideAxisIsDefinedEvenForAnEntirelyDegenerateBasis() { + let broken = TossController.CameraBasis(position: .zero, forward: .zero, right: .zero) + let side = broken.sideAxis + XCTAssertTrue(side.x.isFinite && side.y.isFinite && side.z.isFinite) + XCTAssertEqual(simd_length(side), 1, accuracy: 1e-5) + } + + func testStraightDownAimStillHasADefinedSideAxis() { + let overhead = TossController.CameraBasis( + position: [0, 1, 0], + forward: [0, -1, 0], + right: [0, 1, 0] + ) + let side = overhead.sideAxis + XCTAssertTrue(side.x.isFinite && side.y.isFinite && side.z.isFinite) + XCTAssertEqual(simd_length(side), 1, accuracy: 1e-5) + XCTAssertEqual(simd_dot(side, TossController.worldUp), 0, accuracy: 1e-5) + } + + func testDegenerateCameraBasisDoesNotProduceNaN() { + let controller = TossController() + let broken = TossController.CameraBasis(position: .zero, forward: .zero, right: .zero) + let velocity = controller.launchVelocity(for: swipe(up: 200), camera: broken) + + XCTAssertTrue(velocity.x.isFinite && velocity.y.isFinite && velocity.z.isFinite) + XCTAssertEqual(simd_length(velocity), controller.launchSpeed(for: swipe(up: 200)), accuracy: 1e-4) + } + + // MARK: - Impulse + + func testImpulseIsVelocityScaledByBallMass() { + let controller = TossController() + let gesture = swipe(up: 220, sideways: 60) + let velocity = controller.launchVelocity(for: gesture, camera: camera) + let impulse = controller.impulse(for: gesture, camera: camera) + + XCTAssertEqual(impulse.x, velocity.x * tuning.ballMass, accuracy: 1e-6) + XCTAssertEqual(impulse.y, velocity.y * tuning.ballMass, accuracy: 1e-6) + XCTAssertEqual(impulse.z, velocity.z * tuning.ballMass, accuracy: 1e-6) + } + + func testLaunchOriginSitsInFrontOfAndBelowTheCamera() { + let controller = TossController() + let origin = controller.launchOrigin(camera: camera) + + XCTAssertEqual(origin.z, camera.position.z - tuning.spawnForwardOffset, accuracy: 1e-5) + XCTAssertEqual(origin.y, camera.position.y - tuning.spawnDownOffset, accuracy: 1e-5) + } + + // MARK: - Gesture gate + + func testOnlyAnUpwardFlickCountsAsAToss() { + let controller = TossController() + XCTAssertTrue(controller.isToss(swipe(up: tuning.minimumUpwardTravel))) + XCTAssertFalse(controller.isToss(swipe(up: tuning.minimumUpwardTravel - 1))) + XCTAssertFalse(controller.isToss(swipe(sideways: 400)), "a sideways drag is aiming, not a toss") + XCTAssertFalse(controller.isToss(swipe(up: -300)), "a downward drag is not a toss") + } + + func testARejectedGestureDoesNotConsumeTheRateLimit() { + var controller = TossController() + XCTAssertEqual(controller.flick(swipe(up: 10), camera: camera, at: 0), .rejected(.notAToss)) + // The failed gesture must not have started the 0.3 s clock. + guard case .launched = controller.flick(swipe(up: 200), camera: camera, at: 0.01) else { + return XCTFail("a real toss right after a non-toss must still launch") + } + } + + // MARK: - Rate limiting (FR-006) + + func testLaunchesAreRateLimitedToTheMinimumInterval() { + var controller = TossController() + let gesture = swipe(up: 200) + + guard case .launched = controller.flick(gesture, camera: camera, at: 10) else { + return XCTFail("the first toss must launch") + } + XCTAssertEqual( + controller.flick(gesture, camera: camera, at: 10 + tuning.minimumLaunchInterval - 0.01), + .rejected(.tooSoon) + ) + guard case .launched = controller.flick( + gesture, + camera: camera, + at: 10 + tuning.minimumLaunchInterval + ) else { + return XCTFail("a toss at exactly the interval must launch") + } + XCTAssertEqual(controller.liveBallCount, 2, "a rejected toss must not create a ball") + } + + // MARK: - Live-ball cap (FR-006, SC-006) + + func testLiveBallsAreCappedAndTheCapFreesUpOnRetirement() { + var controller = TossController() + let gesture = swipe(up: 200) + var launched: [TossController.BallID] = [] + + for step in 0.. = [] + for step in 0..<20 { + let outcome = controller.flick(swipe(up: 200), camera: camera, at: Double(step)) + if case .launched(let launch) = outcome { + XCTAssertTrue(seen.insert(launch.ball).inserted, "id \(launch.ball) was reused") + controller.retire(launch.ball) + } + } + XCTAssertEqual(seen.count, 20) + } + + // MARK: - Two-tier scoring (FR-005 amended at Gate 4, SC-002) + + /// Launches a ball and hands back its id. + private func launchBall( + _ controller: inout TossController, + at now: TimeInterval = 0 + ) -> TossController.BallID { + guard case .launched(let launch) = controller.flick(swipe(up: 200), camera: camera, at: now) else { + XCTFail("expected a launch") + return 0 + } + return launch.ball + } + + func testTouchingTheCupPaysOnePointExactlyOnce() { + var controller = TossController() + let ball = launchBall(&controller) + + XCTAssertFalse(controller.hasHit(ball)) + XCTAssertEqual( + controller.registerHit(ball), + TossController.Award(ball: ball, tier: .hit, points: tuning.hitPoints) + ) + XCTAssertTrue(controller.hasHit(ball)) + XCTAssertTrue(controller.hasScored(ball)) + + // A thrown ball rattles round twelve wall segments; only the first one + // is worth anything. + XCTAssertNil(controller.registerHit(ball)) + XCTAssertNil(controller.registerHit(ball)) + } + + func testLandingInsidePaysTenPointsExactlyOnce() { + var controller = TossController() + let ball = launchBall(&controller) + + XCTAssertFalse(controller.hasMade(ball)) + XCTAssertEqual( + controller.registerMake(ball), + TossController.Award(ball: ball, tier: .make, points: tuning.makePoints) + ) + XCTAssertTrue(controller.hasMade(ball)) + XCTAssertNil(controller.registerMake(ball), "a ball cannot be made twice") + } + + /// The heart of the owner's rule: a made ball is worth ten in total, not + /// eleven. The hit is absorbed, whichever tier arrives first. + func testTheMakeAbsorbsTheHitSoAMadeBallIsWorthTenInTotal() { + var controller = TossController() + + // Hit first — the usual order: the ball clips the rim or the cup floor + // on its way to settling. + let hitFirst = launchBall(&controller, at: 0) + let hit = controller.registerHit(hitFirst) + let make = controller.registerMake(hitFirst) + XCTAssertEqual(hit?.points, tuning.hitPoints) + XCTAssertEqual(make?.points, tuning.makePoints - tuning.hitPoints) + XCTAssertEqual((hit?.points ?? 0) + (make?.points ?? 0), tuning.makePoints) + + // Make first — a clean drop through the mouth, credited before the + // contact event is processed. Same total, and the hit pays nothing. + let makeFirst = launchBall(&controller, at: 1) + let cleanMake = controller.registerMake(makeFirst) + XCTAssertEqual(cleanMake?.points, tuning.makePoints) + XCTAssertNil(controller.registerHit(makeFirst), "a made ball cannot also collect the hit") + } + + func testTheTwoTiersHaveTheOwnersValues() { + XCTAssertEqual(tuning.hitPoints, 1) + XCTAssertEqual(tuning.makePoints, 10) + } + + func testBallsScoreIndependentlyOfEachOther() { + var controller = TossController() + var ids: [TossController.BallID] = [] + for step in 0..<3 { + ids.append(launchBall(&controller, at: Double(step))) + } + XCTAssertEqual(ids.count, 3) + + XCTAssertEqual(ids.compactMap { controller.registerHit($0) }.count, 3) + XCTAssertEqual(ids.compactMap { controller.registerHit($0) }.count, 0) + + // One of them goes in; the other two keep their single point. + XCTAssertEqual(controller.registerMake(ids[1])?.points, tuning.makePoints - tuning.hitPoints) + XCTAssertFalse(controller.hasMade(ids[0])) + XCTAssertFalse(controller.hasMade(ids[2])) + XCTAssertTrue(controller.hasHit(ids[0])) + } + + func testAnUnknownOrRetiredBallCannotScoreEitherTier() { + var controller = TossController() + XCTAssertNil(controller.registerHit(999), "a ball that was never launched cannot score") + XCTAssertNil(controller.registerMake(999)) + + var controller2 = TossController() + let ball = launchBall(&controller2) + controller2.retire(ball) + XCTAssertNil(controller2.registerHit(ball), "a culled ball cannot score late") + XCTAssertNil(controller2.registerMake(ball)) + XCTAssertFalse(controller2.hasScored(ball)) + } + + /// Ids are never reused, but retiring a ball must still wipe its tiers, or + /// a stale set would grow for the life of the session. + func testRetiringABallForgetsItsTiers() { + var controller = TossController() + let ball = launchBall(&controller) + _ = controller.registerHit(ball) + _ = controller.registerMake(ball) + XCTAssertTrue(controller.hasScored(ball)) + + controller.retire(ball) + XCTAssertFalse(controller.hasHit(ball)) + XCTAssertFalse(controller.hasMade(ball)) + } + + func testRetireAllClearsEveryBallAndTheRateLimit() { + var controller = TossController() + for step in 0..<3 { + _ = controller.flick(swipe(up: 200), camera: camera, at: Double(step)) + } + XCTAssertEqual(controller.liveBallCount, 3) + + controller.retireAll() + XCTAssertEqual(controller.liveBallCount, 0) + guard case .launched = controller.flick(swipe(up: 200), camera: camera, at: 2.0) else { + return XCTFail("relocating the podium must reset the rate limit too") + } + } + + // MARK: - Culling (FR-006) + + func testAFreshMovingBallIsNotCulled() { + let controller = TossController() + XCTAssertNil(controller.cullReason(age: 0.5, restingFor: 0, heightAboveAnchor: 0.4)) + } + + func testABallAtRestIsCulledAfterTheRestWindow() { + let controller = TossController() + XCTAssertNil(controller.cullReason(age: 2, restingFor: 0.9, heightAboveAnchor: 0.1)) + XCTAssertEqual( + controller.cullReason(age: 2, restingFor: tuning.restDuration, heightAboveAnchor: 0.1), + .atRest + ) + } + + func testAnOldBallIsCulledEvenWhileMoving() { + let controller = TossController() + XCTAssertEqual( + controller.cullReason(age: tuning.maximumAge, restingFor: 0, heightAboveAnchor: 0.1), + .expired + ) + } + + func testABallBelowTheAnchorPlaneIsCulledImmediately() { + let controller = TossController() + XCTAssertNil(controller.cullReason(age: 0.1, restingFor: 0, heightAboveAnchor: -0.2)) + XCTAssertEqual( + controller.cullReason(age: 0.1, restingFor: 0, heightAboveAnchor: tuning.minimumHeight - 0.01), + .outOfBounds + ) + } + + func testRestingTimeAccumulatesWhileStillAndResetsOnMovement() { + let controller = TossController() + var resting: TimeInterval = 0 + for _ in 0..<3 { + resting = controller.restingDuration(previous: resting, speed: 0.01, delta: 0.2) + } + XCTAssertEqual(resting, 0.6, accuracy: 1e-6) + + resting = controller.restingDuration(previous: resting, speed: 1.5, delta: 0.2) + XCTAssertEqual(resting, 0, "a ball that moves again is not at rest") + } + + // MARK: - Power range (Phase 4, task 4.0a — Gate 3 rows 3.1/3.7) + + /// The complaint was reach: the player had to walk the phone closer. The + /// fix is a materially higher ceiling, not a nudge. + func testTheHardestFlickIsMuchStrongerThanPhase3s() { + let controller = TossController() + XCTAssertGreaterThanOrEqual( + controller.launchSpeed(for: swipe(up: 600, duration: 0.08)), + 6.5, + "a hard flick must reach the cup from ~2 m without the player moving" + ) + XCTAssertGreaterThan(tuning.maxLaunchSpeed, 4.5, "Phase 3's ceiling was the problem") + } + + /// …and the price of that ceiling must not be paid by ordinary throws: + /// a middling flick still has to land in the 0.5–1.0 m band, which needs + /// roughly 2.4–3.5 m/s. + func testMidStrengthFlicksStayInTheSweetSpot() { + let controller = TossController() + let mid = controller.launchSpeed(for: swipe(up: 180, duration: 0.15)) // 1200 pt/s + XCTAssertGreaterThan(mid, 2.4) + XCTAssertLessThan(mid, 3.5) + + let easy = controller.launchSpeed(for: swipe(up: 150, duration: 0.15)) // 1000 pt/s + XCTAssertGreaterThan(easy, 2.0) + XCTAssertLessThan(easy, 3.0) + } + + /// The shaped curve must still be a curve, not a staircase: speed rises + /// with flick speed everywhere between the clamps. + func testTheShapedCurveIsMonotonicAcrossTheWholeRange() { + let controller = TossController() + var previous: Float = 0 + for flick in stride(from: Float(300), through: 2600, by: 50) { + let speed = controller.launchSpeed(for: swipe(up: flick * 0.1, duration: 0.1)) + XCTAssertGreaterThanOrEqual(speed, previous, "speed dipped at \(flick) pt/s") + XCTAssertGreaterThanOrEqual(speed, tuning.minLaunchSpeed) + XCTAssertLessThanOrEqual(speed, tuning.maxLaunchSpeed) + previous = speed + } + XCTAssertEqual(previous, tuning.maxLaunchSpeed, accuracy: 1e-5) + } + + /// `powerCurve` is the knob that re-spread the range; at 1 it must collapse + /// back to Phase 3's straight line, so the change is provably a re-shaping + /// and not a different formula. + func testAPowerCurveOfOneIsTheOldLinearMapping() { + var linear = TossController.Tuning() + linear.powerCurve = 1 + let controller = TossController(tuning: linear) + + let flick: Float = 1200 + let t = (flick - linear.slowFlick) / (linear.fastFlick - linear.slowFlick) + let expected = linear.minLaunchSpeed + t * (linear.maxLaunchSpeed - linear.minLaunchSpeed) + XCTAssertEqual( + controller.launchSpeed(for: swipe(up: flick * 0.1, duration: 0.1)), + expected, + accuracy: 1e-4 + ) + } + + func testTheCurveShapeSagsBelowTheStraightLine() { + var linear = TossController.Tuning() + linear.powerCurve = 1 + let gesture = swipe(up: 120, duration: 0.1) // 1200 pt/s + + let shaped = TossController().launchSpeed(for: gesture) + let straight = TossController(tuning: linear).launchSpeed(for: gesture) + XCTAssertLessThan(shaped, straight, "the curve exists to hold the middle down") + } + + // MARK: - Rim rescue (Phase 4, task 4.0b — Gate 3 row 3.2) + + private var ballRadius: Float { tuning.ballRadius } + /// The real geometry the AR side measures against. + @MainActor + private var rimOuterRadius: Float { PodiumBuilder.Metrics.cupRimOuterRadius } + + /// A ball balanced on the rim sits a full radius above it. + @MainActor + func testABallBalancedOnTheRimIsDetected() { + let controller = TossController() + let perched = TossController.CupPlacement( + heightAboveRim: ballRadius, + radialDistance: PodiumBuilder.Metrics.cupRimRingRadius + ) + XCTAssertTrue( + controller.isPerchedOnRim(perched, ballRadius: ballRadius, cupOuterRadius: rimOuterRadius) + ) + } + + /// A ball that actually went in sits *below* the rim, and must be left + /// alone — it has scored and the ordinary rest-cull should remove it. + @MainActor + func testABallRestingInsideTheCupIsNotTreatedAsPerched() { + let controller = TossController() + // Centre = cup floor thickness + radius, measured against the rim. + let inside = TossController.CupPlacement( + heightAboveRim: PodiumBuilder.Metrics.cupFloorThickness + ballRadius + - PodiumBuilder.Metrics.cupRimHeight, + radialDistance: 0 + ) + XCTAssertLessThan(inside.heightAboveRim, 0, "a ball in the cup is below the rim") + XCTAssertFalse( + controller.isPerchedOnRim(inside, ballRadius: ballRadius, cupOuterRadius: rimOuterRadius) + ) + } + + @MainActor + func testABallRestingElsewhereInTheSceneIsNotTreatedAsPerched() { + let controller = TossController() + // Right height, but way off to the side — on a step, or on the table. + let besideTheCup = TossController.CupPlacement( + heightAboveRim: ballRadius, + radialDistance: rimOuterRadius + ballRadius + 0.01 + ) + XCTAssertFalse( + controller.isPerchedOnRim(besideTheCup, ballRadius: ballRadius, cupOuterRadius: rimOuterRadius) + ) + + // Right place, but well below the mouth — under the cup, on the step. + let belowTheCup = TossController.CupPlacement(heightAboveRim: -0.12, radialDistance: 0.01) + XCTAssertFalse( + controller.isPerchedOnRim(belowTheCup, ballRadius: ballRadius, cupOuterRadius: rimOuterRadius) + ) + } + + /// The two cases have to be separated with margin on both sides, or a ball + /// in the cup gets shoved out of it. + @MainActor + func testTheRimThresholdSitsBetweenTheTwoRestingHeights() { + let controller = TossController() + let insideHeight = PodiumBuilder.Metrics.cupFloorThickness + ballRadius + - PodiumBuilder.Metrics.cupRimHeight + let perchedHeight = ballRadius + let threshold = -ballRadius * tuning.rimGraceFraction + + XCTAssertLessThan(insideHeight, threshold - 0.005, "too little margin for a ball in the cup") + XCTAssertGreaterThan(perchedHeight, threshold + 0.005, "too little margin for a ball on the rim") + _ = controller + } + + func testTheNudgeIsAHorizontalShoveWithADownwardBias() { + let controller = TossController() + for azimuth in stride(from: Float(0), to: 2 * .pi, by: 0.4) { + let velocity = controller.rimNudgeVelocity(azimuth: azimuth) + let horizontal = simd_length(SIMD2(velocity.x, velocity.z)) + XCTAssertEqual(horizontal, tuning.rimNudgeSpeed, accuracy: 1e-5) + XCTAssertLessThan(velocity.y, 0, "the shove must commit the ball to falling") + XCTAssertEqual( + velocity.y, + -tuning.rimNudgeSpeed * tuning.rimNudgeDownwardBias, + accuracy: 1e-6 + ) + } + } + + /// Opposite azimuths must mirror, so a random direction is as likely to + /// drop the ball into the cup as out of it — which is what Gate 3 asked for. + func testOppositeNudgesMirrorEachOther() { + let controller = TossController() + let right = controller.rimNudgeVelocity(azimuth: 0) + let left = controller.rimNudgeVelocity(azimuth: .pi) + XCTAssertEqual(right.x, -left.x, accuracy: 1e-5) + XCTAssertEqual(right.y, left.y, accuracy: 1e-6) + } + + func testTheNudgeImpulseIsTheNudgeVelocityTimesMass() { + let controller = TossController() + let velocity = controller.rimNudgeVelocity(azimuth: 1.1) + let impulse = controller.rimNudgeImpulse(azimuth: 1.1) + XCTAssertEqual(impulse.x, velocity.x * tuning.ballMass, accuracy: 1e-8) + XCTAssertEqual(impulse.y, velocity.y * tuning.ballMass, accuracy: 1e-8) + XCTAssertEqual(impulse.z, velocity.z * tuning.ballMass, accuracy: 1e-8) + } + + /// The rescue is bounded: a ball cannot be nudged forever. + func testNudgesAreCapped() { + let controller = TossController() + for count in 0.. TossController.CupPlacement { + TossController.CupPlacement( + heightAboveRim: -depthBelowRim, + radialDistance: radial, + heightAboveCupFloor: aboveFloor, + interiorRadius: interior + ) + } + + func testABallSittingInTheCupIsInside() { + let controller = TossController() + XCTAssertTrue(controller.isInsideCup(insidePlacement(), ballRadius: ballRadius)) + } + + /// The defect, as a unit test: a ball touching the cup's outer wall has its + /// centre a full radius *beyond* the wall — ~9 cm from the axis where the + /// rule allows ~2 — so it can never be inside, at any height, however hard + /// it is thrown at the front of the cup. + func testABallAgainstTheOutsideOfTheWallIsNeverInside() { + let controller = TossController() + for depth in stride(from: Float(0.001), through: 0.06, by: 0.005) { + let placement = insidePlacement( + depthBelowRim: depth, + radial: 0.051 + 0.006 + ballRadius, + aboveFloor: 0.065 - depth, + interior: 0.051 + ) + XCTAssertFalse( + controller.isInsideCup(placement, ballRadius: ballRadius), + "outer-wall contact \(depth) m below the rim read as inside" + ) + } + } + + /// The lower front of the cup specifically — the position the owner scored + /// from at Gate 4. Low on the wall, outside it, and the interior is + /// narrowest down there. + func testTheLowFrontOfTheCupIsNeverInside() { + let controller = TossController() + let lowFront = insidePlacement( + depthBelowRim: 0.055, + radial: 0.041 + 0.006 + ballRadius, + aboveFloor: 0.004, + interior: 0.041 + ) + XCTAssertFalse(controller.isInsideCup(lowFront, ballRadius: ballRadius)) + } + + func testABallBelowTheCupIsNotInside() { + let controller = TossController() + // On the stem or the step: right under the axis, but below the floor. + let underneath = insidePlacement(radial: 0, aboveFloor: -0.02) + XCTAssertFalse(controller.isInsideCup(underneath, ballRadius: ballRadius)) + } + + func testABallAtOrAboveTheRimIsNotInside() { + let controller = TossController() + let atTheRim = insidePlacement(depthBelowRim: 0, aboveFloor: 0.065, interior: 0.054) + XCTAssertFalse(controller.isInsideCup(atTheRim, ballRadius: ballRadius)) + + let perched = insidePlacement(depthBelowRim: -ballRadius, aboveFloor: 0.10, interior: 0.054) + XCTAssertFalse(controller.isInsideCup(perched, ballRadius: ballRadius)) + } + + /// Only the *whole* ball counts as in. A ball whose centre is inside but + /// whose body still sticks out through the wall line is on its way in or + /// out, not landed. + func testTheWholeBallHasToFitWithinTheWall() { + let controller = TossController() + let interior: Float = 0.051 + let justFits = insidePlacement(radial: interior - ballRadius, interior: interior) + XCTAssertTrue(controller.isInsideCup(justFits, ballRadius: ballRadius)) + + let stickingOut = insidePlacement( + radial: interior - ballRadius + tuning.insideRadialTolerance + 0.002, + interior: interior + ) + XCTAssertFalse(controller.isInsideCup(stickingOut, ballRadius: ballRadius)) + } + + /// Inside and perched must be mutually exclusive, or the rim rescue would + /// shove a ball that has just scored. + func testInsideAndPerchedAreMutuallyExclusive() { + let controller = TossController() + for depth in stride(from: Float(-0.05), through: 0.06, by: 0.002) { + let placement = insidePlacement(depthBelowRim: depth, radial: 0.005) + let inside = controller.isInsideCup(placement, ballRadius: ballRadius) + let perched = controller.isPerchedOnRim( + placement, + ballRadius: ballRadius, + cupOuterRadius: 0.06 + ) + XCTAssertFalse(inside && perched, "a ball at \(depth) is both inside and perched") + } + } + + // MARK: - The dwell that turns "inside" into "landed" + + func testContainmentAccumulatesWhileInsideAndResetsOnLeaving() { + let controller = TossController() + var contained: TimeInterval = 0 + for _ in 0..<3 { + contained = controller.containedDuration(previous: contained, isInside: true, delta: 1 / 60) + } + XCTAssertEqual(contained, 3.0 / 60, accuracy: 1e-9) + + contained = controller.containedDuration(previous: contained, isInside: false, delta: 1 / 60) + XCTAssertEqual(contained, 0, "a ball that leaves the cup starts again") + } + + /// A ball crossing the cup at speed cannot dwell: the region where it + /// counts as inside is a few centimetres across, so it is gone within a + /// frame or two — which is exactly the tunnelling case the defect fix has + /// to reject. + func testAFlyThroughNeverSettlesButARestingBallDoes() throws { + let controller = TossController() + var contained: TimeInterval = 0 + // Two frames inside, then out — a ball punched through the wall. + for isInside in [true, true, false] { + contained = controller.containedDuration( + previous: contained, + isInside: isInside, + delta: 1 / 60 + ) + XCTAssertFalse(controller.hasSettledInside(containedFor: contained)) + } + + // A ball that stays put crosses the threshold within a frame of the + // nominal dwell and stays across it. (Which side of the exact boundary + // frame 6 lands on is at the mercy of accumulating 1/60 in binary; the + // behaviour either side of it is not.) + var settledAt: Int? + for frame in 1...30 { + contained = controller.containedDuration(previous: contained, isInside: true, delta: 1 / 60) + let settled = controller.hasSettledInside(containedFor: contained) + if settled, settledAt == nil { settledAt = frame } + + let elapsed = Double(frame) / 60 + if elapsed < tuning.insideDwell - 1.0 / 60 { + XCTAssertFalse(settled, "frame \(frame) settled too early") + } + if elapsed > tuning.insideDwell + 1.0 / 60 { + XCTAssertTrue(settled, "frame \(frame) has not settled yet") + } + } + let crossing = try XCTUnwrap(settledAt, "a ball resting in the cup never settled") + XCTAssertEqual(Double(crossing) / 60, tuning.insideDwell, accuracy: 1.0 / 60) + } + + func testTheDwellIsShortEnoughToFeelInstantAndLongEnoughToFilter() { + XCTAssertGreaterThanOrEqual(tuning.insideDwell, 3.0 / 60, "one stray frame must not score") + XCTAssertLessThanOrEqual(tuning.insideDwell, 0.25, "the player would notice the delay") + } + + // MARK: - Touch-anchored spawn (Phase 5, task 5.0c — FR-004 amended) + + /// The camera's screen basis for these tests: aiming along −Z, +X to the + /// right of the screen and +Y up it. + private func ray(right: Float, up: Float) -> TossController.TouchRay { + TossController.TouchRay( + origin: camera.position, + direction: simd_normalize(SIMD3(right, up, -1)) + ) + } + + private func spawnOffset(_ touch: TossController.TouchRay?) -> SIMD3 { + TossController().launchOrigin(camera: camera, touch: touch) - camera.position + } + + func testATouchInTheMiddleSpawnsOnTheAimAxis() { + let offset = spawnOffset(ray(right: 0, up: 0)) + XCTAssertEqual(offset.x, 0, accuracy: 1e-5) + XCTAssertEqual(offset.y, 0, accuracy: 1e-5) + XCTAssertEqual(offset.z, -tuning.spawnForwardOffset, accuracy: 1e-5) + } + + /// Each corner has to put the ball on its own side of the aim, and all four + /// at the same depth — the finger moves the spawn sideways, never nearer. + func testEachCornerSpawnsOnItsOwnSideAtTheSameDepth() { + let corners: [(name: String, right: Float, up: Float)] = [ + ("top-left", -0.5, 0.8), + ("top-right", 0.5, 0.8), + ("bottom-left", -0.5, -0.8), + ("bottom-right", 0.5, -0.8) + ] + for corner in corners { + let offset = spawnOffset(ray(right: corner.right, up: corner.up)) + XCTAssertEqual( + offset.z, + -tuning.spawnForwardOffset, + accuracy: 1e-5, + "\(corner.name) changed the spawn depth" + ) + XCTAssertEqual( + offset.x.sign, + corner.right.sign, + "\(corner.name) spawned on the wrong side" + ) + XCTAssertEqual(offset.y.sign, corner.up.sign, "\(corner.name) spawned at the wrong height") + XCTAssertGreaterThan( + simd_length(SIMD2(offset.x, offset.y)), + 0.05, + "\(corner.name) barely moved — the spawn does not follow the finger" + ) + } + // Opposite corners mirror each other exactly. + let topLeft = spawnOffset(ray(right: -0.5, up: 0.8)) + let bottomRight = spawnOffset(ray(right: 0.5, up: -0.8)) + XCTAssertEqual(topLeft.x, -bottomRight.x, accuracy: 1e-5) + XCTAssertEqual(topLeft.y, -bottomRight.y, accuracy: 1e-5) + } + + func testTheSidewaysOffsetIsClamped() { + let offset = spawnOffset(ray(right: 3, up: 0)) + XCTAssertEqual( + simd_length(SIMD2(offset.x, offset.y)), + tuning.maxSpawnLateral, + accuracy: 1e-5 + ) + XCTAssertEqual(offset.z, -tuning.spawnForwardOffset, accuracy: 1e-5) + } + + /// A ray that points behind the player — a bad projection, or a touch + /// outside the frustum — must still spawn the ball in front of them. + func testARayPointingBackwardsStillSpawnsInFrontOfTheCamera() { + let backwards = TossController.TouchRay(origin: camera.position, direction: [0, 0, 1]) + let offset = spawnOffset(backwards) + XCTAssertLessThan(offset.z, 0, "the ball spawned behind the player") + XCTAssertGreaterThanOrEqual(-offset.z, tuning.minSpawnDepth - 1e-5) + } + + func testEverySpawnStaysInsideItsDepthAndOffsetBounds() { + for right in stride(from: Float(-6), through: 6, by: 0.5) { + for up in stride(from: Float(-6), through: 6, by: 0.5) { + let offset = spawnOffset(ray(right: right, up: up)) + let depth = -offset.z + XCTAssertGreaterThanOrEqual(depth, tuning.minSpawnDepth - 1e-4, "(\(right), \(up))") + XCTAssertLessThanOrEqual(depth, tuning.maxSpawnDepth + 1e-4, "(\(right), \(up))") + XCTAssertLessThanOrEqual( + simd_length(SIMD2(offset.x, offset.y)), + tuning.maxSpawnLateral + 1e-4, + "(\(right), \(up))" + ) + XCTAssertTrue(offset.x.isFinite && offset.y.isFinite && offset.z.isFinite) + } + } + } + + func testADegenerateRayFallsBackToTheAimAxis() { + let broken = TossController.TouchRay(origin: camera.position, direction: .zero) + let offset = spawnOffset(broken) + XCTAssertEqual(offset.x, 0, accuracy: 1e-5) + XCTAssertEqual(offset.y, 0, accuracy: 1e-5) + XCTAssertEqual(offset.z, -tuning.spawnForwardOffset, accuracy: 1e-5) + } + + /// No touch at all keeps the pre-Gate-4 spawn, so nothing regresses if the + /// AR view cannot project the point. + func testNoTouchKeepsTheFixedSpawn() { + let controller = TossController() + XCTAssertEqual( + controller.launchOrigin(camera: camera, touch: nil), + controller.launchOrigin(camera: camera) + ) + } + + /// …and a real flick actually carries the touch through to the launch. + func testAFlickSpawnsAtTheTouchPoint() { + var controller = TossController() + let touch = ray(right: 0.4, up: -0.7) + guard case .launched(let launch) = controller.flick( + swipe(up: 200), + camera: camera, + at: 0, + touch: touch + ) else { + return XCTFail("expected a launch") + } + XCTAssertEqual(launch.origin, controller.launchOrigin(camera: camera, touch: touch)) + XCTAssertNotEqual(launch.origin, controller.launchOrigin(camera: camera)) + // The aim and the power are untouched by where the finger started. + XCTAssertEqual( + launch.velocity, + controller.launchVelocity(for: swipe(up: 200), camera: camera) + ) + } + + // MARK: - Tuning is the single knob for Gate 3 + + func testTuningOverridesFlowThroughToTheLaunch() { + var tweaked = TossController.Tuning() + tweaked.minLaunchSpeed = 5 + tweaked.maxLaunchSpeed = 5 + tweaked.ballMass = 1 + var controller = TossController(tuning: tweaked) + + guard case .launched(let launch) = controller.flick(swipe(up: 200), camera: camera, at: 0) else { + return XCTFail("expected a launch") + } + XCTAssertEqual(simd_length(launch.velocity), 5, accuracy: 1e-4) + XCTAssertEqual(simd_length(launch.impulse), 5, accuracy: 1e-4) + } +} diff --git a/ios/project.yml b/ios/project.yml index fd5f357..b545380 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -28,3 +28,34 @@ targets: ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor CODE_SIGN_STYLE: Automatic + + # Unit tests for the pure, off-device logic: the AR mini-game's rulebook + # (toss physics, rounds, best score, podium/floor-map geometry) and the + # backend locator / map-pin decoding. Hosted by the IPP app, so the suites + # read the app's real Info.plist. No test needs a running backend. + # Run: xcodebuild test -scheme IPP -destination 'platform=iOS Simulator,name=iPhone 17 Pro' + IPPTests: + type: bundle.unit-test + platform: iOS + sources: + - path: IPPTests + dependencies: + - target: IPP + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.nonturing.ipp.tests + GENERATE_INFOPLIST_FILE: YES + CODE_SIGN_STYLE: Automatic + +schemes: + IPP: + build: + targets: + IPP: all + IPPTests: [test] + run: + config: Debug + test: + config: Debug + targets: + - IPPTests