diff --git a/frontend/src/assets/img/app-icons/skyride.svg b/frontend/src/assets/img/app-icons/skyride.svg new file mode 100644 index 0000000..00e99ca --- /dev/null +++ b/frontend/src/assets/img/app-icons/skyride.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 4bac4c3..01be3d3 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -41,6 +41,12 @@ describe('app registry', () => { labelKey: 'Apps.garage.name', route: '/apps/garage', }) + expect(PHONE_APPS.find((app) => app.id === 'skyride')).toMatchObject({ + category: 'utilities', + gridOrder: 25, + labelKey: 'Apps.skyride.name', + route: '/apps/skyride', + }) expect(PHONE_APPS.find((app) => app.id === 'calendar')).toMatchObject({ gridOrder: 21, labelKey: 'Apps.calendar.name', @@ -109,6 +115,7 @@ describe('app registry', () => { expect(isPhoneAppId('camera')).toBe(true) expect(isPhoneAppId('photos')).toBe(true) expect(isPhoneAppId('clock')).toBe(true) + expect(isPhoneAppId('skyride')).toBe(true) expect( PHONE_APPS.filter((app) => app.category === 'games').map((app) => app.id), ).toEqual([ diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index ecba1bf..16fb02a 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -4,6 +4,7 @@ import { Blocks, Camera, CarFront, + CarTaxiFront, CalendarDays, Clock3, Gamepad2, @@ -58,6 +59,7 @@ import localPagesIcon from '@/assets/img/app-icons/local-pages.webp' import flareIcon from '@/assets/img/app-icons/flare.svg' import flipTokIcon from '@/assets/img/app-icons/fliptok.webp' import picstagramIcon from '@/assets/img/app-icons/picstagram.webp' +import skyRideIcon from '@/assets/img/app-icons/skyride.svg' import type { LaunchablePhoneAppDefinition, LaunchablePhoneAppId, @@ -219,6 +221,20 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ labelKey: 'Apps.map.name', route: '/apps/map', }, + { + category: 'utilities', + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/SkyRideApp.vue')), + ), + dockOrder: null, + gridOrder: 25, + icon: markRaw(CarTaxiFront), + iconClass: 'app-icon--skyride', + iconImage: skyRideIcon, + id: 'skyride', + labelKey: 'Apps.skyride.name', + route: '/apps/skyride', + }, { category: 'social', component: markRaw( diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 9230f41..cfd0c6e 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -1022,6 +1022,217 @@ const defaultLocales: LocaleTree = { default: 'The garage request failed.', }, }, + skyride: { + name: 'SkyRide', + loading: 'Loading SkyRide', + unavailable: 'SkyRide unavailable', + tryAgain: 'Try Again', + mode: { + rider: 'Ride', + driver: 'Drive', + }, + riderEyebrow: 'Ride anywhere', + whereTo: 'Where to?', + pickup: 'Pickup', + destination: 'Destination', + currentLocation: 'Current location', + notSelected: 'Not selected', + location: 'Choose a location', + chooseLocation: { + pickup: 'Set pickup', + destination: 'Choose destination', + }, + chooseOnMap: 'Choose on map', + chooseOnMapBody: 'Tap a precise point on the Los Santos map.', + pickOnMap: { + pickup: 'Tap your pickup point', + destination: 'Tap your destination', + }, + tapMapHint: 'Drag and zoom the map, then tap to place the pin.', + mapPoint: 'Map pin', + map: 'Map', + useGps: 'Use your live position', + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + resetMap: 'Reset map view', + savedPlaces: 'Saved places', + savedPlace: 'Saved place', + quickDestinations: 'Quick destinations', + quickLocations: { + 'legion-square': 'Legion Square', + 'diamond-casino': 'Diamond Casino', + airport: 'Los Santos Airport', + vinewood: 'Vinewood', + }, + viewRides: 'View Ride Options', + change: 'Change', + requestRide: 'Request SkyRide', + serviceMeta: '{eta} min away ยท {seats} seats', + distanceMeters: '{distance} m', + distanceKilometers: '{distance} km', + distanceMiles: '{distance} mi', + durationMinutes: '{minutes} min', + fare: 'Trip fare', + calculatedFare: 'Calculated', + customFare: 'Own price', + calculatedFareBody: 'Distance, time, and selected service included.', + customFareRange: 'Allowed range: {minimum} to {maximum}', + applyCustomFare: 'Apply Own Price', + perKilometer: '{price} / km', + perMile: '{price} / mi', + rideStatus: 'Ride status', + services: { + taxi: { name: 'Taxi' }, + comfort: { name: 'Comfort' }, + xl: { name: 'XL' }, + premium: { name: 'Premium' }, + }, + ride: 'SkyRide', + driver: { + online: 'You are online', + offline: 'You are offline', + onlineBody: 'New ride requests can reach you now.', + offlineBody: 'Go online when you are ready to drive.', + toggleStatus: 'Toggle driver status', + today: 'Today', + requests: 'Nearby requests', + accept: 'Accept Ride', + noRequests: 'No nearby requests', + noRequestsBody: 'New requests will appear here automatically.', + goOnline: 'Go online to receive rides', + goOnlineBody: 'Switch your driver status on to see nearby requests.', + statusBody: { + searching: 'A passenger is waiting for a driver.', + accepted: 'Navigate to the passenger pickup.', + driver_arriving: 'You are heading to the passenger.', + arrived: 'Confirm when the passenger is in the vehicle.', + in_progress: 'Follow the route to the destination.', + completed: 'The ride has been completed.', + cancelled: 'This ride was cancelled.', + }, + actions: { + arrive: 'I Have Arrived', + start: 'Start Ride', + complete: 'Complete Ride', + }, + }, + passenger: 'Passenger', + vehicle: 'Vehicle', + navigate: 'Navigate', + call: 'Call', + message: 'Message', + cancelRide: 'Cancel Ride', + cancelTitle: 'Cancel this ride?', + cancelBody: + 'Your driver or passenger will be notified. A cancellation fee may apply.', + safetyNote: 'Trip details are shared only while this ride is active.', + waypointSet: 'Route added to your GPS.', + status: { + searching: 'Finding a driver', + accepted: 'Driver found', + driver_arriving: 'Driver en route', + arrived: 'Driver arrived', + in_progress: 'On the way', + completed: 'Completed', + cancelled: 'Cancelled', + }, + statusBody: { + searching: 'We are matching you with a nearby driver.', + accepted: 'Your driver is preparing to pick you up.', + driver_arriving: 'Your driver is on the way to your pickup.', + arrived: 'Your driver is waiting at the pickup point.', + in_progress: 'You are on the way to your destination.', + completed: 'You have arrived at your destination.', + cancelled: 'This ride is no longer active.', + }, + navigation: 'SkyRide navigation', + tabs: { + home: 'Home', + rides: 'Rides', + activity: 'Activity', + messages: 'Messages', + profile: 'Profile', + }, + rides: 'Your Rides', + ridesBody: 'Review completed and cancelled rides.', + noRides: 'No rides yet', + noRidesBody: 'Completed and cancelled rides will appear here.', + activity: 'Activity', + activityBody: 'Recent trip updates and milestones.', + noActivity: 'No recent activity', + noActivityBody: 'Ride updates will appear here.', + messages: 'Messages', + messagesBody: 'Contact your active driver or passenger.', + noMessages: 'No active contact', + noMessagesBody: 'Book or accept a ride to contact the other person.', + openMessages: 'Open Messages', + account: 'Account', + paymentMethod: 'Payment Method', + paymentMethods: { + bank: 'Bank', + cash: 'Cash', + default: 'Payment method', + }, + safety: 'Safety Center', + safetyBody: 'Help, trip sharing, and emergency information', + driverMode: 'Driver Mode', + driverModeBody: 'Accept ride requests and track your earnings', + completedRides: 'Completed', + cancelledRides: 'Cancelled', + acceptance: 'Acceptance', + rating: 'Rating', + notAvailable: 'N/A', + memberSince: 'SkyRide member since {date}', + rideComplete: 'Ride Complete', + rateRide: 'Rate your ride', + rateRideBody: 'How was your trip? Your feedback helps every rider.', + ratingValue: '{rating} stars', + tip: 'Add a tip', + noTip: 'No tip', + comment: 'Comment', + commentPlaceholder: 'Share an optional note...', + submitRating: 'Submit Rating', + notNow: 'Not Now', + ratingSaved: 'Thanks for your feedback.', + errors: { + skyride_unavailable: 'SkyRide is currently unavailable.', + invalid_location: 'Choose a valid pickup and destination.', + pickup_too_far: 'Move closer to the selected pickup point.', + invalid_route: 'No suitable route was found for this ride.', + invalid_custom_fare: 'Choose an own price inside the allowed range.', + quote_not_found: 'These ride options are no longer available.', + quote_expired: 'These ride options expired. Request a new quote.', + active_ride_exists: 'Finish or cancel your active ride first.', + invalid_driver_status: 'That driver status is not available.', + driver_not_eligible: 'This account is not eligible for Driver Mode.', + driver_offline: 'Go online before accepting a ride.', + driver_unavailable: 'That driver is no longer available.', + driver_vehicle_required: + "Sit in the driver's seat of a vehicle before accepting a ride.", + ride_not_found: 'This ride is no longer available.', + invalid_ride_status: 'This action is not available for the ride.', + not_ride_participant: 'You are not part of this ride.', + driver_too_far: 'Move closer to the required ride location.', + passenger_too_far: 'The passenger is not close enough to start.', + destination_too_far: + 'Move closer to the destination to complete the ride.', + payout_failed: 'The driver payout could not be completed.', + refund_failed: 'The ride refund could not be completed.', + invalid_cancel_reason: 'Choose a valid cancellation reason.', + invalid_rating: 'Choose a rating from one to five stars.', + invalid_tip: 'Choose a valid tip amount.', + invalid_comment: 'The rating comment is not valid.', + tip_failed: 'The tip could not be processed.', + insufficient_funds: + 'Your selected payment method has insufficient funds.', + rate_limited: 'Please wait a moment before trying again.', + device_not_open: 'Open the phone again to use SkyRide.', + device_not_owned: 'This phone does not belong to this character.', + device_locked: 'Unlock this phone to use SkyRide.', + request_failed: 'SkyRide could not complete the request.', + default: 'SkyRide could not complete the request.', + }, + }, calculator: { name: 'Calculator' }, snake: { name: 'Snake', diff --git a/frontend/src/stores/skyride.test.ts b/frontend/src/stores/skyride.test.ts new file mode 100644 index 0000000..5a691a1 --- /dev/null +++ b/frontend/src/stores/skyride.test.ts @@ -0,0 +1,203 @@ +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { useSkyRideStore } from '@/stores/skyride' +import type { + SkyRideBootstrap, + SkyRideLocation, + SkyRideQuote, + SkyRideRide, +} from '@/types/skyride' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ nuiCall: vi.fn() })) + +const mockNuiCall = vi.mocked(nuiCall) +const pickup: SkyRideLocation = { + coords: { x: -1037.7, y: -2737.8, z: 20.1 }, + label: 'Los Santos International', +} +const destination: SkyRideLocation = { + coords: { x: 215.8, y: -810.1, z: 30.7 }, + label: 'Pillbox Hill', +} +const ride: SkyRideRide = { + createdAt: 1_755_000_000, + currency: '$', + destination, + driver: null, + id: 'ride-1', + pickup, + price: 48, + serviceClass: 'comfort', + status: 'searching', + updatedAt: 1_755_000_000, +} +const bootstrap: SkyRideBootstrap = { + activeRide: null, + availableRequests: [ride], + driverEligible: true, + driverOnline: false, + history: [], + pendingRating: null, + profile: { + acceptanceRate: 94, + avatarUrl: null, + cancelledRides: 2, + completedRides: 128, + currency: '$', + defaultPaymentMethod: 'Bank', + earningsToday: 320, + id: 'profile-1', + memberSince: 1_700_000_000, + name: 'Alex Morgan', + rating: 4.9, + }, + quickLocations: [destination], +} +const quote: SkyRideQuote = { + destination, + distance: 5.2, + distanceMeters: 5200, + distanceUnit: 'kilometer', + durationSeconds: 640, + expiresAt: 1_755_000_120, + options: [ + { + available: true, + calculatedPrice: 48, + currency: '$', + etaMinutes: 4, + fareMode: 'calculated', + maximumCustomPrice: 144, + minimumCustomPrice: 24, + price: 48, + pricePerDistanceUnit: 24, + quoteId: 'quote-comfort', + seats: 4, + serviceClass: 'comfort', + }, + ], + pickup, +} + +describe('SkyRide store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + it('hydrates the complete server-authoritative snapshot', async () => { + mockNuiCall.mockResolvedValueOnce({ data: bootstrap, success: true }) + const skyride = useSkyRideStore() + skyride.quote = quote + + expect(await skyride.bootstrap()).toBe(true) + expect(skyride.profile).toEqual(bootstrap.profile) + expect(skyride.driverEligible).toBe(true) + expect(skyride.availableRequests).toEqual([ride]) + expect(skyride.quote).toBeNull() + expect(mockNuiCall).toHaveBeenCalledWith('skyride:bootstrap') + }) + + it('requests a quote with pickup and destination coordinates', async () => { + mockNuiCall.mockResolvedValueOnce({ data: quote, success: true }) + const skyride = useSkyRideStore() + + expect((await skyride.createQuote(pickup, destination)).success).toBe(true) + expect(skyride.quote).toEqual(quote) + expect(mockNuiCall).toHaveBeenCalledWith('skyride:quote', { + destination, + pickup, + }) + }) + + it('books with the opaque quote id and never sends a client price', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { activeRide: ride }, + success: true, + }) + const skyride = useSkyRideStore() + skyride.quote = quote + + const response = await skyride.requestRide(quote.options[0]) + + expect(response.success).toBe(true) + expect(skyride.activeRide).toEqual(ride) + expect(skyride.quote).toBeNull() + expect(mockNuiCall).toHaveBeenCalledWith('skyride:request', { + quoteId: 'quote-comfort', + }) + }) + + it('asks the server to bind a validated own price to a new quote', async () => { + const customQuote = { + ...quote, + options: [ + { + ...quote.options[0], + fareMode: 'custom' as const, + price: 60, + quoteId: 'quote-comfort-custom', + }, + ], + } + mockNuiCall.mockResolvedValueOnce({ data: customQuote, success: true }) + const skyride = useSkyRideStore() + + await skyride.createQuote(pickup, destination, { + price: 60, + serviceClass: 'comfort', + }) + + expect(mockNuiCall).toHaveBeenCalledWith('skyride:quote', { + customFare: { price: 60, serviceClass: 'comfort' }, + destination, + pickup, + }) + }) + + it('preserves the active ride when a lifecycle action is rejected', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'ride_state_changed', + success: false, + }) + const skyride = useSkyRideStore() + skyride.activeRide = ride + + await skyride.performRideAction('start', ride.id) + + expect(skyride.activeRide).toEqual(ride) + expect(skyride.error).toBe('ride_state_changed') + }) + + it('applies live snapshots including an explicit cleared ride', () => { + const skyride = useSkyRideStore() + skyride.activeRide = ride + skyride.driverOnline = false + + skyride.applyUpdate({ activeRide: null, driverOnline: true }) + + expect(skyride.activeRide).toBeNull() + expect(skyride.driverOnline).toBe(true) + }) + + it('submits rating details and clears the pending rating on success', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { history: [{ ...ride, status: 'completed' }] }, + success: true, + }) + const skyride = useSkyRideStore() + skyride.pendingRating = { ...ride, status: 'completed' } + + await skyride.rateRide(ride.id, 5, 10, 'Excellent') + + expect(skyride.pendingRating).toBeNull() + expect(mockNuiCall).toHaveBeenCalledWith('skyride:rate', { + comment: 'Excellent', + rating: 5, + rideId: ride.id, + tip: 10, + }) + }) +}) diff --git a/frontend/src/stores/skyride.ts b/frontend/src/stores/skyride.ts new file mode 100644 index 0000000..12cf15a --- /dev/null +++ b/frontend/src/stores/skyride.ts @@ -0,0 +1,204 @@ +import { defineStore } from 'pinia' + +import type { + SkyRideBootstrap, + SkyRideCoordinates, + SkyRideCustomFareInput, + SkyRideHistoryResponse, + SkyRideLocation, + SkyRideQuote, + SkyRideQuoteOption, + SkyRideRide, + SkyRideStateUpdate, +} from '@/types/skyride' +import { nuiCall, type NuiResponse } from '@/utils/nui' + +type RideAction = 'accept' | 'arrive' | 'start' | 'complete' + +export const useSkyRideStore = defineStore('skyride', { + state: () => ({ + activeRide: null as SkyRideRide | null, + availableRequests: [] as SkyRideRide[], + driverEligible: false, + driverOnline: false, + error: '', + history: [] as SkyRideRide[], + isActionPending: false, + isLoading: false, + pendingRating: null as SkyRideRide | null, + profile: null as SkyRideBootstrap['profile'] | null, + quickLocations: [] as SkyRideLocation[], + quote: null as SkyRideQuote | null, + }), + actions: { + applyUpdate(update: SkyRideStateUpdate): void { + if (update.activeRide !== undefined) this.activeRide = update.activeRide + if (update.availableRequests !== undefined) + this.availableRequests = update.availableRequests + if (update.driverOnline !== undefined) + this.driverOnline = update.driverOnline + if (update.history !== undefined) this.history = update.history + if (update.pendingRating !== undefined) + this.pendingRating = update.pendingRating + if (update.profile !== undefined) this.profile = update.profile + }, + async bootstrap(): Promise { + this.isLoading = true + const response = await nuiCall('skyride:bootstrap') + this.isLoading = false + if (!response.success || !response.data) { + this.error = response.error ?? 'request_failed' + return false + } + + this.activeRide = response.data.activeRide + this.availableRequests = response.data.availableRequests + this.driverEligible = response.data.driverEligible + this.driverOnline = response.data.driverOnline + this.history = response.data.history + this.pendingRating = response.data.pendingRating + this.profile = response.data.profile + this.quickLocations = response.data.quickLocations + this.quote = null + this.error = '' + return true + }, + async loadHistory(): Promise { + const response = await nuiCall('skyride:history') + if (!response.success || !response.data) { + this.error = response.error ?? 'request_failed' + return false + } + this.history = response.data.items + this.error = '' + return true + }, + async createQuote( + pickup: SkyRideLocation, + destination: SkyRideLocation, + customFare?: SkyRideCustomFareInput, + ): Promise> { + this.isActionPending = true + const response = await nuiCall('skyride:quote', { + ...(customFare ? { customFare } : {}), + destination, + pickup, + }) + this.isActionPending = false + if (response.success && response.data) { + this.quote = response.data + this.error = '' + } else { + this.error = response.error ?? 'request_failed' + } + return response + }, + async requestRide( + option: Pick, + ): Promise> { + this.isActionPending = true + const response = await nuiCall('skyride:request', { + quoteId: option.quoteId, + }) + this.isActionPending = false + if (response.success) { + if (response.data) this.applyUpdate(response.data) + this.quote = null + this.error = '' + } else { + this.error = response.error ?? 'request_failed' + } + return response + }, + async setDriverStatus( + online: boolean, + ): Promise> { + this.isActionPending = true + const response = await nuiCall( + 'skyride:set-driver-status', + { online }, + ) + this.isActionPending = false + if (response.success) { + this.driverOnline = response.data?.driverOnline ?? online + if (response.data) this.applyUpdate(response.data) + this.error = '' + } else { + this.error = response.error ?? 'request_failed' + } + return response + }, + async performRideAction( + action: RideAction, + rideId: string, + ): Promise> { + this.isActionPending = true + const response = await nuiCall(`skyride:${action}`, { + rideId, + }) + this.isActionPending = false + if (response.success) { + if (response.data) this.applyUpdate(response.data) + this.error = '' + } else { + this.error = response.error ?? 'request_failed' + } + return response + }, + async cancelRide( + rideId: string, + reason?: string, + ): Promise> { + this.isActionPending = true + const response = await nuiCall('skyride:cancel', { + rideId, + ...(reason ? { reason } : {}), + }) + this.isActionPending = false + if (response.success) { + if (response.data) this.applyUpdate(response.data) + this.error = '' + } else { + this.error = response.error ?? 'request_failed' + } + return response + }, + async rateRide( + rideId: string, + rating: number, + tip: number, + comment: string, + ): Promise> { + this.isActionPending = true + const response = await nuiCall('skyride:rate', { + comment, + rating, + rideId, + tip, + }) + this.isActionPending = false + if (response.success) { + if (response.data) this.applyUpdate(response.data) + this.pendingRating = null + this.error = '' + } else { + this.error = response.error ?? 'request_failed' + } + return response + }, + async getPlayerCoordinates(): Promise< + NuiResponse<{ coords: SkyRideCoordinates }> + > { + return nuiCall<{ coords: SkyRideCoordinates }>( + 'skyride:get-player-coords', + ) + }, + async setWaypoint(coords: SkyRideCoordinates): Promise { + return nuiCall('map:setWaypoint', { coords }) + }, + clearQuote(): void { + this.quote = null + this.error = '' + }, + }, +}) diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index 064c4c4..5987788 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -30,6 +30,7 @@ export type PhoneAppId = | 'flare' | 'fliptok' | 'picstagram' + | 'skyride' export type LaunchablePhoneAppId = PhoneAppId diff --git a/frontend/src/types/skyride.ts b/frontend/src/types/skyride.ts new file mode 100644 index 0000000..53f8c40 --- /dev/null +++ b/frontend/src/types/skyride.ts @@ -0,0 +1,145 @@ +export type SkyRideMode = 'rider' | 'driver' + +export type SkyRideServiceClass = 'taxi' | 'comfort' | 'xl' | 'premium' + +export type SkyRideDistanceUnit = 'kilometer' | 'mile' + +export type SkyRideFareMode = 'calculated' | 'custom' + +export type SkyRideRideStatus = + | 'searching' + | 'accepted' + | 'driver_arriving' + | 'arrived' + | 'in_progress' + | 'completed' + | 'cancelled' + +export type SkyRideCoordinates = { + x: number + y: number + z: number +} + +export type SkyRideLocation = { + coords: SkyRideCoordinates + id?: string + label: string +} + +export type SkyRidePerson = { + avatarUrl: string | null + id: string + name: string + phoneNumber?: string + rating: number + trips: number +} + +export type SkyRideVehicle = { + color: string + model: string + plate: string +} + +export type SkyRideDriver = SkyRidePerson & { + location?: SkyRideCoordinates + vehicle: SkyRideVehicle +} + +export type SkyRidePassenger = Pick< + SkyRidePerson, + 'avatarUrl' | 'id' | 'name' | 'phoneNumber' | 'rating' | 'trips' +> + +export type SkyRideQuoteOption = { + available: boolean + calculatedPrice: number + currency: string + etaMinutes: number + fareMode: SkyRideFareMode + maximumCustomPrice: number + minimumCustomPrice: number + price: number + pricePerDistanceUnit: number + quoteId: string + seats: number + serviceClass: SkyRideServiceClass +} + +export type SkyRideCustomFareInput = { + price: number + serviceClass: SkyRideServiceClass +} + +export type SkyRideQuote = { + destination: SkyRideLocation + distance: number + distanceMeters: number + distanceUnit: SkyRideDistanceUnit + durationSeconds: number + expiresAt: number + options: SkyRideQuoteOption[] + pickup: SkyRideLocation +} + +export type SkyRideRide = { + createdAt: number + currency: string + destination: SkyRideLocation + driver: SkyRideDriver | null + finalPrice?: number + id: string + passenger?: SkyRidePassenger + pickup: SkyRideLocation + price: number + serviceClass: SkyRideServiceClass + status: SkyRideRideStatus + updatedAt: number +} + +export type SkyRideProfile = { + acceptanceRate: number | null + avatarUrl: string | null + cancelledRides: number + completedRides: number + currency: string + defaultPaymentMethod: string + earningsToday: number | null + id: string + memberSince: number + name: string + rating: number +} + +export type SkyRideBootstrap = { + activeRide: SkyRideRide | null + availableRequests: SkyRideRide[] + driverEligible: boolean + driverOnline: boolean + history: SkyRideRide[] + pendingRating: SkyRideRide | null + profile: SkyRideProfile + quickLocations: SkyRideLocation[] +} + +export type SkyRideStateUpdate = Partial< + Pick< + SkyRideBootstrap, + | 'activeRide' + | 'availableRequests' + | 'driverOnline' + | 'history' + | 'pendingRating' + | 'profile' + > +> + +export type SkyRideHistoryResponse = { + items: SkyRideRide[] +} + +export type SkyRideChangedMessage = { + data: SkyRideStateUpdate + type: 'skyride:changed' +} diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index ef14b71..d4f5a57 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -89,6 +89,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< weather: { enabled: true, sounds: true }, banking: { enabled: true, sounds: true }, garage: { enabled: true, sounds: true }, + skyride: { enabled: true, sounds: true }, mail: { enabled: true, sounds: true }, map: { enabled: true, sounds: true }, notes: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/SkyRideApp.vue b/frontend/src/views/apps/SkyRideApp.vue new file mode 100644 index 0000000..2882862 --- /dev/null +++ b/frontend/src/views/apps/SkyRideApp.vue @@ -0,0 +1,3099 @@ + + + + + diff --git a/frontend/testserver/index.cjs b/frontend/testserver/index.cjs index 5ffcff7..335adf1 100644 --- a/frontend/testserver/index.cjs +++ b/frontend/testserver/index.cjs @@ -18,6 +18,10 @@ function isoTime(offsetMilliseconds) { return new Date(Date.now() + offsetMilliseconds).toISOString() } +function unixTime(offsetSeconds = 0) { + return Math.floor(Date.now() / 1000) + offsetSeconds +} + let authenticated = true let draft = null const radioData = { @@ -464,6 +468,155 @@ const mockGarageVehicles = [ let mockGarageValet = null +const skyRideProfile = { + acceptanceRate: 96, + avatarUrl: + 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=240&q=80', + cancelledRides: 3, + completedRides: 146, + currency: '$', + defaultPaymentMethod: 'bank', + earningsToday: 284, + id: 'skyride-profile-demo', + memberSince: unixTime(-214 * 24 * 60 * 60), + name: 'Alex Morgan', + rating: 4.92, +} +const skyRideDriver = { + avatarUrl: + 'https://images.unsplash.com/photo-1531123897727-8f129e1688ce?auto=format&fit=crop&w=240&q=80', + id: 'skyride-driver-nova', + location: { x: -121.8, y: -904.6, z: 29.3 }, + name: 'Nova Hayes', + phoneNumber: '5550199', + rating: 4.97, + trips: 842, + vehicle: { + color: 'Obsidian Black', + model: 'Enus Deity', + plate: 'RIDE 01', + }, +} +const skyRidePassenger = { + avatarUrl: skyRideProfile.avatarUrl, + id: skyRideProfile.id, + name: skyRideProfile.name, + phoneNumber: '5550142', + rating: skyRideProfile.rating, + trips: skyRideProfile.completedRides, +} + +function skyRideWithoutLiveContact(ride) { + const passenger = { ...ride.passenger } + delete passenger.phoneNumber + let driver = null + if (ride.driver) { + driver = { ...ride.driver } + delete driver.location + delete driver.phoneNumber + } + return { ...ride, driver, passenger } +} + +const skyRideQuickLocations = [ + { + coords: { x: -265.1, y: -960.2, z: 31.2 }, + id: 'legion-square', + label: 'Legion Square', + }, + { + coords: { x: 925.2, y: 46.4, z: 81.1 }, + id: 'diamond-casino', + label: 'Diamond Casino', + }, + { + coords: { x: -1037.7, y: -2737.8, z: 20.2 }, + id: 'airport', + label: 'Los Santos Airport', + }, + { + coords: { x: -594.4, y: -929.9, z: 23.9 }, + id: 'vinewood', + label: 'Vinewood', + }, +] +let skyRideSequence = 4 +let skyRideDriverOnline = false +let skyRideActiveRide = null +let skyRidePendingRating = null +let skyRideAvailableRequests = [ + { + createdAt: unixTime(-90), + currency: '$', + destination: skyRideQuickLocations[1], + driver: null, + id: 'skyride-request-demo', + passenger: { + avatarUrl: + 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=240&q=80', + id: 'skyride-passenger-jordan', + name: 'Jordan Lee', + rating: 4.84, + trips: 38, + }, + pickup: skyRideQuickLocations[0], + price: 42, + serviceClass: 'comfort', + status: 'searching', + updatedAt: unixTime(-90), + }, +] +let skyRideHistory = [ + skyRideWithoutLiveContact({ + createdAt: unixTime(-22 * 60 * 60), + currency: '$', + destination: skyRideQuickLocations[3], + driver: skyRideDriver, + finalPrice: 31, + id: 'skyride-history-1', + passenger: skyRidePassenger, + pickup: skyRideQuickLocations[0], + price: 31, + serviceClass: 'taxi', + status: 'completed', + updatedAt: unixTime(-21 * 60 * 60), + }), + skyRideWithoutLiveContact({ + createdAt: unixTime(-3 * 24 * 60 * 60), + currency: '$', + destination: skyRideQuickLocations[2], + driver: skyRideDriver, + finalPrice: 68, + id: 'skyride-history-2', + passenger: skyRidePassenger, + pickup: skyRideQuickLocations[1], + price: 68, + serviceClass: 'premium', + status: 'completed', + updatedAt: unixTime(-3 * 24 * 60 * 60 + 18 * 60), + }), +] +const skyRideQuotes = new Map() + +function skyRideBootstrap() { + return { + activeRide: skyRideActiveRide, + availableRequests: skyRideAvailableRequests, + driverEligible: true, + driverOnline: skyRideDriverOnline, + history: skyRideHistory, + pendingRating: skyRidePendingRating, + profile: skyRideProfile, + quickLocations: skyRideQuickLocations, + } +} + +function skyRideUpdate(fields) { + return Object.fromEntries( + fields.map((field) => [field, skyRideBootstrap()[field]]), + ) +} + let contactSequence = 2 const contacts = [ { @@ -2309,6 +2462,377 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true }) return } + if (endpoint === 'skyride:get-player-coords') { + response.json({ + success: true, + data: { coords: { x: -265.1, y: -960.2, z: 31.2 } }, + }) + return + } + if (endpoint === 'skyride:bootstrap') { + response.json({ success: true, data: skyRideBootstrap() }) + return + } + if (endpoint === 'skyride:history') { + response.json({ success: true, data: { items: skyRideHistory } }) + return + } + if (endpoint === 'skyride:quote') { + const pickup = request.body.pickup + const destination = request.body.destination + if (!pickup?.coords || !destination?.coords) { + response.json({ success: false, error: 'invalid_location' }) + return + } + + const distanceMeters = Math.max( + 750, + Math.round( + Math.hypot( + Number(destination.coords.x) - Number(pickup.coords.x), + Number(destination.coords.y) - Number(pickup.coords.y), + ) * 1.25, + ), + ) + const durationSeconds = Math.max(60, Math.round(distanceMeters / 12)) + const distanceUnit = 'kilometer' + const distance = distanceMeters / 1000 + const expiresAt = unixTime(2 * 60) + const definitions = [ + { + baseFare: 12, + etaMinutes: 3, + minimumFare: 15, + pricePerKilometer: 18, + pricePerMile: 29, + pricePerMinute: 1, + seats: 4, + serviceClass: 'taxi', + }, + { + baseFare: 16, + etaMinutes: 5, + minimumFare: 22, + pricePerKilometer: 24, + pricePerMile: 39, + pricePerMinute: 2, + seats: 4, + serviceClass: 'comfort', + }, + { + baseFare: 20, + etaMinutes: 7, + minimumFare: 28, + pricePerKilometer: 30, + pricePerMile: 48, + pricePerMinute: 2, + seats: 6, + serviceClass: 'xl', + }, + { + baseFare: 28, + etaMinutes: 8, + minimumFare: 38, + pricePerKilometer: 40, + pricePerMile: 64, + pricePerMinute: 3, + seats: 4, + serviceClass: 'premium', + }, + ] + const requestedCustomFare = request.body.customFare + if ( + requestedCustomFare !== undefined && + (!definitions.some( + (definition) => + definition.serviceClass === requestedCustomFare?.serviceClass, + ) || + !Number.isInteger(requestedCustomFare?.price)) + ) { + response.json({ success: false, error: 'invalid_custom_fare' }) + return + } + const options = definitions.map((definition) => { + const pricePerDistanceUnit = + distanceUnit === 'mile' + ? definition.pricePerMile + : definition.pricePerKilometer + const calculatedPrice = Math.round( + Math.max( + definition.minimumFare, + definition.baseFare + + distance * pricePerDistanceUnit + + (durationSeconds / 60) * definition.pricePerMinute, + ), + ) + const minimumCustomPrice = Math.round( + Math.max(5, definition.minimumFare, calculatedPrice * 0.5), + ) + const maximumCustomPrice = Math.round( + Math.min(100000, calculatedPrice * 3), + ) + const usesCustomFare = + requestedCustomFare?.serviceClass === definition.serviceClass + const requestedPrice = Number(requestedCustomFare?.price) + if ( + usesCustomFare && + (!Number.isInteger(requestedPrice) || + requestedPrice < minimumCustomPrice || + requestedPrice > maximumCustomPrice) + ) { + return null + } + const option = { + available: true, + calculatedPrice, + currency: '$', + etaMinutes: definition.etaMinutes, + fareMode: usesCustomFare ? 'custom' : 'calculated', + maximumCustomPrice, + minimumCustomPrice, + price: usesCustomFare ? requestedPrice : calculatedPrice, + pricePerDistanceUnit, + quoteId: 'skyride-quote-' + Date.now() + '-' + definition.serviceClass, + seats: definition.seats, + serviceClass: definition.serviceClass, + } + skyRideQuotes.set(option.quoteId, { + destination, + expiresAt, + option, + pickup, + }) + return option + }) + if (options.some((option) => option === null)) { + response.json({ success: false, error: 'invalid_custom_fare' }) + return + } + + response.json({ + success: true, + data: { + destination, + distance, + distanceMeters, + distanceUnit, + durationSeconds, + expiresAt, + options, + pickup, + }, + }) + return + } + if (endpoint === 'skyride:request') { + const quote = skyRideQuotes.get(String(request.body.quoteId ?? '')) + if (!quote) { + response.json({ success: false, error: 'quote_not_found' }) + return + } + if (quote.expiresAt <= unixTime()) { + response.json({ success: false, error: 'quote_expired' }) + return + } + if (skyRideActiveRide) { + response.json({ success: false, error: 'active_ride_exists' }) + return + } + + const now = unixTime() + const ride = { + createdAt: now, + currency: quote.option.currency, + destination: quote.destination, + driver: null, + id: `skyride-ride-${skyRideSequence++}`, + passenger: skyRidePassenger, + pickup: quote.pickup, + price: quote.option.price, + serviceClass: quote.option.serviceClass, + status: 'searching', + updatedAt: now, + } + skyRideActiveRide = ride + skyRideAvailableRequests = [ + skyRideWithoutLiveContact(ride), + ...skyRideAvailableRequests, + ] + response.json({ + success: true, + data: skyRideUpdate(['activeRide']), + }) + return + } + if (endpoint === 'skyride:set-driver-status') { + if (typeof request.body.online !== 'boolean') { + response.json({ success: false, error: 'invalid_driver_status' }) + return + } + skyRideDriverOnline = request.body.online + response.json({ + success: true, + data: skyRideUpdate(['driverOnline', 'availableRequests']), + }) + return + } + if (endpoint === 'skyride:accept') { + if (!skyRideDriverOnline) { + response.json({ success: false, error: 'driver_offline' }) + return + } + const ride = skyRideAvailableRequests.find( + (item) => item.id === request.body.rideId, + ) + if (!ride) { + response.json({ success: false, error: 'ride_not_found' }) + return + } + if (skyRideActiveRide && skyRideActiveRide.id !== ride.id) { + response.json({ success: false, error: 'active_ride_exists' }) + return + } + + skyRideActiveRide = { + ...ride, + driver: skyRideDriver, + status: 'accepted', + updatedAt: unixTime(), + } + skyRideAvailableRequests = skyRideAvailableRequests.filter( + (item) => item.id !== ride.id, + ) + response.json({ + success: true, + data: skyRideUpdate(['activeRide', 'availableRequests']), + }) + return + } + if ( + endpoint === 'skyride:arrive' || + endpoint === 'skyride:start' || + endpoint === 'skyride:complete' + ) { + if (!skyRideActiveRide || skyRideActiveRide.id !== request.body.rideId) { + response.json({ success: false, error: 'ride_not_found' }) + return + } + + const transition = { + 'skyride:arrive': { + from: ['accepted', 'driver_arriving'], + to: 'arrived', + }, + 'skyride:start': { from: ['arrived'], to: 'in_progress' }, + 'skyride:complete': { from: ['in_progress'], to: 'completed' }, + }[endpoint] + if (!transition.from.includes(skyRideActiveRide.status)) { + response.json({ success: false, error: 'invalid_ride_status' }) + return + } + + const updatedRide = { + ...skyRideActiveRide, + ...(transition.to === 'completed' + ? { finalPrice: skyRideActiveRide.price } + : {}), + status: transition.to, + updatedAt: unixTime(), + } + if (transition.to === 'completed') { + const passengerRide = updatedRide.passenger?.id === skyRideProfile.id + skyRideProfile.completedRides += 1 + if (!passengerRide) { + skyRideProfile.earningsToday += Math.round( + updatedRide.finalPrice * 0.85, + ) + } + const historyRide = skyRideWithoutLiveContact(updatedRide) + skyRideHistory = [historyRide, ...skyRideHistory] + skyRidePendingRating = passengerRide ? historyRide : null + skyRideActiveRide = null + response.json({ + success: true, + data: skyRideUpdate([ + 'activeRide', + 'history', + 'pendingRating', + 'profile', + ]), + }) + return + } + + skyRideActiveRide = updatedRide + response.json({ + success: true, + data: skyRideUpdate(['activeRide']), + }) + return + } + if (endpoint === 'skyride:cancel') { + const ride = + skyRideActiveRide?.id === request.body.rideId + ? skyRideActiveRide + : skyRideAvailableRequests.find( + (item) => item.id === request.body.rideId, + ) + if (!ride) { + response.json({ success: false, error: 'ride_not_found' }) + return + } + + const cancelledRide = { + ...ride, + status: 'cancelled', + updatedAt: unixTime(), + } + skyRideAvailableRequests = skyRideAvailableRequests.filter( + (item) => item.id !== ride.id, + ) + if (skyRideActiveRide?.id === ride.id) skyRideActiveRide = null + skyRidePendingRating = null + skyRideHistory = [ + skyRideWithoutLiveContact(cancelledRide), + ...skyRideHistory, + ] + response.json({ + success: true, + data: skyRideUpdate([ + 'activeRide', + 'availableRequests', + 'history', + 'pendingRating', + ]), + }) + return + } + if (endpoint === 'skyride:rate') { + const rating = Number(request.body.rating) + const tip = Number(request.body.tip) + if ( + !skyRidePendingRating || + skyRidePendingRating.id !== request.body.rideId + ) { + response.json({ success: false, error: 'ride_not_found' }) + return + } + if (!Number.isInteger(rating) || rating < 1 || rating > 5) { + response.json({ success: false, error: 'invalid_rating' }) + return + } + if (!Number.isFinite(tip) || tip < 0 || tip > 10000) { + response.json({ success: false, error: 'invalid_tip' }) + return + } + + skyRidePendingRating = null + response.json({ + success: true, + data: skyRideUpdate(['history', 'pendingRating']), + }) + return + } if (endpoint === 'banking:overview') { response.json({ success: true, data: bankingOverview() }) return diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 35ad48b..c685b97 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -320,3 +320,100 @@ Config.Calendar = { FutureSeconds = 5 * 365 * 24 * 60 * 60, ReminderPollSeconds = 15, } + +Config.SkyRide = { + PaymentAccount = "bank", + Currency = "$", + DistanceUnit = "kilometer", -- kilometer or mile + QuoteLifetimeSeconds = 120, + HistoryLimit = 50, + ReadsPerMinute = 120, + ActionsPerMinute = 30, + QuotesPerMinute = 20, + RecoveryIntervalSeconds = 15, + PaymentPendingRecoverySeconds = 30, + PickupSelectionRadius = 150.0, + ArrivalRadius = 40.0, + CompletionRadius = 80.0, + MinimumDistanceMeters = 250, + MaximumDistanceMeters = 40000, + RouteDistanceMultiplier = 1.25, + AverageSpeedMetersPerSecond = 12.0, + DriverPayoutPercent = 85, + RatingCommentMaxLength = 300, + MaximumTip = 10000, + CustomFare = { + Enabled = true, + MinimumPrice = 5, + MaximumPrice = 100000, + MinimumCalculatedMultiplier = 0.5, + MaximumCalculatedMultiplier = 3.0, + }, + DriverJobs = { + taxi = 0, + }, + Services = { + { + Id = "taxi", + Seats = 4, + EtaMinutes = 3, + BaseFare = 12, + PricePerKilometer = 18, + PricePerMile = 29, + PricePerMinute = 1, + MinimumFare = 15, + }, + { + Id = "comfort", + Seats = 4, + EtaMinutes = 5, + BaseFare = 16, + PricePerKilometer = 24, + PricePerMile = 39, + PricePerMinute = 2, + MinimumFare = 22, + }, + { + Id = "xl", + Seats = 6, + EtaMinutes = 7, + BaseFare = 20, + PricePerKilometer = 30, + PricePerMile = 48, + PricePerMinute = 2, + MinimumFare = 28, + }, + { + Id = "premium", + Seats = 4, + EtaMinutes = 8, + BaseFare = 28, + PricePerKilometer = 40, + PricePerMile = 64, + PricePerMinute = 3, + MinimumFare = 38, + }, + }, + QuickLocations = { + { + Id = "legion-square", + Label = "Legion Square", + Coords = { x = -265.1, y = -960.2, z = 31.2 }, + }, + { + Id = "diamond-casino", + Label = "Diamond Casino", + Coords = { x = 925.2, y = 46.4, z = 81.1 }, + }, + { + Id = "airport", + Label = "Los Santos Airport", + Coords = { x = -1037.7, y = -2737.8, z = 20.2 }, + }, + { + Id = "vinewood", + Label = "Vinewood", + Coords = { x = -594.4, y = -929.9, z = 23.9 }, + }, + }, +} diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index c58e896..03ecb67 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -396,6 +396,95 @@ Locales["en"] = { request_failed = "The garage request failed.", default = "The garage request failed.", }, }, + skyride = { + name = "SkyRide", loading = "Loading SkyRide", unavailable = "SkyRide unavailable", tryAgain = "Try Again", + mode = { rider = "Ride", driver = "Drive" }, + riderEyebrow = "Ride anywhere", whereTo = "Where to?", pickup = "Pickup", destination = "Destination", + currentLocation = "Current location", notSelected = "Not selected", location = "Choose a location", + chooseLocation = { pickup = "Set pickup", destination = "Choose destination" }, + chooseOnMap = "Choose on map", chooseOnMapBody = "Tap a precise point on the Los Santos map.", + pickOnMap = { pickup = "Tap your pickup point", destination = "Tap your destination" }, + tapMapHint = "Drag and zoom the map, then tap to place the pin.", mapPoint = "Map pin", map = "Map", useGps = "Use your live position", + zoomIn = "Zoom in", zoomOut = "Zoom out", resetMap = "Reset map view", + savedPlaces = "Saved places", savedPlace = "Saved place", quickDestinations = "Quick destinations", + quickLocations = { ["legion-square"] = "Legion Square", ["diamond-casino"] = "Diamond Casino", airport = "Los Santos Airport", vinewood = "Vinewood" }, + viewRides = "View Ride Options", change = "Change", requestRide = "Request SkyRide", + serviceMeta = "{eta} min away ยท {seats} seats", distanceMeters = "{distance} m", + distanceKilometers = "{distance} km", distanceMiles = "{distance} mi", durationMinutes = "{minutes} min", + fare = "Trip fare", calculatedFare = "Calculated", customFare = "Own price", + calculatedFareBody = "Distance, time, and selected service included.", + customFareRange = "Allowed range: {minimum} to {maximum}", applyCustomFare = "Apply Own Price", + perKilometer = "{price} / km", perMile = "{price} / mi", rideStatus = "Ride status", + services = { + taxi = { name = "Taxi" }, comfort = { name = "Comfort" }, xl = { name = "XL" }, premium = { name = "Premium" }, + }, + ride = "SkyRide", + driver = { + online = "You are online", offline = "You are offline", + onlineBody = "New ride requests can reach you now.", offlineBody = "Go online when you are ready to drive.", + toggleStatus = "Toggle driver status", today = "Today", requests = "Nearby requests", accept = "Accept Ride", + noRequests = "No nearby requests", noRequestsBody = "New requests will appear here automatically.", + goOnline = "Go online to receive rides", goOnlineBody = "Switch your driver status on to see nearby requests.", + statusBody = { + searching = "A passenger is waiting for a driver.", accepted = "Navigate to the passenger pickup.", + driver_arriving = "You are heading to the passenger.", arrived = "Confirm when the passenger is in the vehicle.", + in_progress = "Follow the route to the destination.", completed = "The ride has been completed.", + cancelled = "This ride was cancelled.", + }, + actions = { arrive = "I Have Arrived", start = "Start Ride", complete = "Complete Ride" }, + }, + passenger = "Passenger", vehicle = "Vehicle", navigate = "Navigate", call = "Call", message = "Message", + cancelRide = "Cancel Ride", cancelTitle = "Cancel this ride?", + cancelBody = "Your driver or passenger will be notified. A cancellation fee may apply.", + safetyNote = "Trip details are shared only while this ride is active.", waypointSet = "Route added to your GPS.", + status = { + searching = "Finding a driver", accepted = "Driver found", driver_arriving = "Driver en route", + arrived = "Driver arrived", in_progress = "On the way", completed = "Completed", cancelled = "Cancelled", + }, + statusBody = { + searching = "We are matching you with a nearby driver.", accepted = "Your driver is preparing to pick you up.", + driver_arriving = "Your driver is on the way to your pickup.", arrived = "Your driver is waiting at the pickup point.", + in_progress = "You are on the way to your destination.", completed = "You have arrived at your destination.", + cancelled = "This ride is no longer active.", + }, + navigation = "SkyRide navigation", + tabs = { home = "Home", rides = "Rides", activity = "Activity", messages = "Messages", profile = "Profile" }, + rides = "Your Rides", ridesBody = "Review completed and cancelled rides.", noRides = "No rides yet", + noRidesBody = "Completed and cancelled rides will appear here.", activity = "Activity", + activityBody = "Recent trip updates and milestones.", noActivity = "No recent activity", + noActivityBody = "Ride updates will appear here.", messages = "Messages", + messagesBody = "Contact your active driver or passenger.", noMessages = "No active contact", + noMessagesBody = "Book or accept a ride to contact the other person.", openMessages = "Open Messages", + account = "Account", paymentMethod = "Payment Method", paymentMethods = { bank = "Bank", cash = "Cash", default = "Payment method" }, safety = "Safety Center", + safetyBody = "Help, trip sharing, and emergency information", driverMode = "Driver Mode", + driverModeBody = "Accept ride requests and track your earnings", completedRides = "Completed", + cancelledRides = "Cancelled", acceptance = "Acceptance", rating = "Rating", notAvailable = "N/A", + memberSince = "SkyRide member since {date}", rideComplete = "Ride Complete", rateRide = "Rate your ride", + rateRideBody = "How was your trip? Your feedback helps every rider.", ratingValue = "{rating} stars", + tip = "Add a tip", noTip = "No tip", comment = "Comment", commentPlaceholder = "Share an optional note...", + submitRating = "Submit Rating", notNow = "Not Now", ratingSaved = "Thanks for your feedback.", + errors = { + skyride_unavailable = "SkyRide is currently unavailable.", + invalid_location = "Choose a valid pickup and destination.", quote_not_found = "These ride options are no longer available.", + pickup_too_far = "Move closer to the selected pickup point.", invalid_route = "No suitable route was found for this ride.", + invalid_custom_fare = "Choose an own price inside the allowed range.", + quote_expired = "These ride options expired. Request a new quote.", active_ride_exists = "Finish or cancel your active ride first.", + invalid_driver_status = "That driver status is not available.", driver_not_eligible = "This account is not eligible for Driver Mode.", + driver_offline = "Go online before accepting a ride.", driver_unavailable = "That driver is no longer available.", + driver_vehicle_required = "Sit in the driver's seat of a vehicle before accepting a ride.", + ride_not_found = "This ride is no longer available.", not_ride_participant = "You are not part of this ride.", + invalid_ride_status = "This action is not available for the ride.", invalid_rating = "Choose a rating from one to five stars.", + driver_too_far = "Move closer to the required ride location.", passenger_too_far = "The passenger is not close enough to start.", + destination_too_far = "Move closer to the destination to complete the ride.", payout_failed = "The driver payout could not be completed.", + refund_failed = "The ride refund could not be completed.", invalid_cancel_reason = "Choose a valid cancellation reason.", + invalid_tip = "Choose a valid tip amount.", invalid_comment = "The rating comment is not valid.", + tip_failed = "The tip could not be processed.", insufficient_funds = "Your selected payment method has insufficient funds.", + rate_limited = "Please wait a moment before trying again.", request_failed = "SkyRide could not complete the request.", + device_not_open = "Open the phone again to use SkyRide.", device_not_owned = "This phone does not belong to this character.", + device_locked = "Unlock this phone to use SkyRide.", + default = "SkyRide could not complete the request.", + }, + }, calculator = { name = "Calculator" }, snake = { name = "Snake", backToMenu = "Back to game menu", board = "Snake game board", diff --git a/sky_phone/fxmanifest.lua b/sky_phone/fxmanifest.lua index 64fa820..50e24fb 100644 --- a/sky_phone/fxmanifest.lua +++ b/sky_phone/fxmanifest.lua @@ -27,6 +27,7 @@ client_scripts { 'source/client/animations.lua', 'source/client/camera.lua', 'source/client/garage.lua', + 'source/client/skyride.lua', 'source/bridge/client/radio.lua', 'source/client/main.lua', 'source/client/radio.lua', @@ -61,6 +62,7 @@ server_scripts { 'source/server/fliptok.lua', 'source/server/picstagram.lua', 'source/server/map.lua', + 'source/server/skyride.lua', 'source/server/calendar.lua', 'source/server/radio.lua', } diff --git a/sky_phone/source/client/skyride.lua b/sky_phone/source/client/skyride.lua new file mode 100644 index 0000000..abf5862 --- /dev/null +++ b/sky_phone/source/client/skyride.lua @@ -0,0 +1,120 @@ +local server_callbacks = { + "skyride:bootstrap", + "skyride:quote", + "skyride:request", + "skyride:set-driver-status", + "skyride:accept", + "skyride:arrive", + "skyride:start", + "skyride:complete", + "skyride:cancel", + "skyride:rate", + "skyride:history", +} + +local function normalize_person(person) + if type(person) ~= "table" then + return + end + if person.avatarUrl == false then + person.avatarUrl = json.null + end +end + +local function normalize_ride(ride) + if type(ride) ~= "table" then + return + end + normalize_person(ride.passenger) + if ride.driver == false then + ride.driver = json.null + return + end + if type(ride.driver) ~= "table" then + return + end + normalize_person(ride.driver) + local vehicle = ride.driver.vehicle + local model_hash = type(vehicle) == "table" and tonumber(vehicle.model) or nil + if not model_hash then + return + end + local display_name = GetDisplayNameFromVehicleModel(model_hash) + local label = display_name and GetLabelText(display_name) or nil + if label and label ~= "NULL" and label ~= "CARNOTFOUND" then + vehicle.model = label + end +end + +local function normalize_rides(rides) + if type(rides) ~= "table" then + return + end + for index = 1, #rides do + normalize_ride(rides[index]) + end +end + +local function normalize_state(data) + if type(data) ~= "table" then + return + end + if data.activeRide == false then + data.activeRide = json.null + else + normalize_ride(data.activeRide) + end + if data.pendingRating == false then + data.pendingRating = json.null + else + normalize_ride(data.pendingRating) + end + normalize_rides(data.availableRequests) + normalize_rides(data.history) + normalize_rides(data.items) + if type(data.profile) == "table" then + if data.profile.acceptanceRate == false then + data.profile.acceptanceRate = json.null + end + if data.profile.avatarUrl == false then + data.profile.avatarUrl = json.null + end + if data.profile.earningsToday == false then + data.profile.earningsToday = json.null + end + end +end + +for index = 1, #server_callbacks do + local callback_name = server_callbacks[index] + RegisterNUICallback(callback_name, function(data, cb) + local result = Bridge.Callbacks.Trigger("sky_phone:" .. callback_name, data or {}) + if not result then + cb({ success = false, error = "request_failed" }) + return + end + if result.success and type(result.data) == "table" then + normalize_state(result.data) + end + cb(result) + end) +end + +RegisterNUICallback("skyride:get-player-coords", function(_, cb) + local coords = GetEntityCoords(PlayerPedId()) + cb({ + success = true, + data = { + coords = { + x = coords.x, + y = coords.y, + z = coords.z, + }, + }, + }) +end) + +RegisterNetEvent("sky_phone:skyride:changed", function(data) + normalize_state(data) + SendNUIMessage({ type = "skyride:changed", data = data }) +end) diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 70ea649..5250e10 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -1517,6 +1517,125 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_skyride_profiles", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { + name = "owner_identifier", + type = "VARCHAR(80) NOT NULL", + characterSet = "ascii", + collation = "ascii_bin", + }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + }, + primaryKey = "id", + uniqueKeys = { + { name = "uniq_sky_phone_skyride_owner", columns = "(`owner_identifier`)" }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, + { + name = "sky_phone_skyride_rides", + columns = { + { name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" }, + { + name = "passenger_profile_id", + type = "CHAR(36) NOT NULL", + characterSet = "ascii", + collation = "ascii_bin", + }, + { + name = "driver_profile_id", + type = "CHAR(36) NULL", + characterSet = "ascii", + collation = "ascii_bin", + }, + { name = "passenger_name", type = "VARCHAR(80) NOT NULL" }, + { name = "driver_name", type = "VARCHAR(80) NULL" }, + { + name = "status", + type = "ENUM('payment_pending','searching','accepted','arrived','in_progress','completing','completed','cancelled') NOT NULL", + }, + { + name = "service_class", + type = "ENUM('taxi','comfort','xl','premium') NOT NULL", + }, + { name = "pickup_label", type = "VARCHAR(80) NOT NULL" }, + { name = "pickup_x", type = "DECIMAL(10,3) NOT NULL" }, + { name = "pickup_y", type = "DECIMAL(10,3) NOT NULL" }, + { name = "pickup_z", type = "DECIMAL(10,3) NOT NULL" }, + { name = "destination_label", type = "VARCHAR(80) NOT NULL" }, + { name = "destination_x", type = "DECIMAL(10,3) NOT NULL" }, + { name = "destination_y", type = "DECIMAL(10,3) NOT NULL" }, + { name = "destination_z", type = "DECIMAL(10,3) NOT NULL" }, + { name = "distance_meters", type = "INT UNSIGNED NOT NULL" }, + { name = "duration_seconds", type = "INT UNSIGNED NOT NULL" }, + { name = "price", type = "INT UNSIGNED NOT NULL" }, + { name = "payout_amount", type = "INT UNSIGNED NOT NULL" }, + { + name = "currency", + type = "VARCHAR(8) NOT NULL", + characterSet = "ascii", + collation = "ascii_general_ci", + }, + { name = "driver_vehicle_model", type = "VARCHAR(64) NULL" }, + { name = "driver_vehicle_color", type = "VARCHAR(64) NULL" }, + { name = "driver_vehicle_plate", type = "VARCHAR(16) NULL" }, + { name = "cancelled_by", type = "ENUM('passenger','driver','system') NULL" }, + { name = "cancel_reason", type = "VARCHAR(32) NULL" }, + { name = "passenger_rating", type = "TINYINT UNSIGNED NULL" }, + { name = "rating_comment", type = "VARCHAR(300) NULL" }, + { name = "tip_amount", type = "INT UNSIGNED NOT NULL DEFAULT 0" }, + { + name = "refund_status", + type = "ENUM('none','pending','processing','completed') NOT NULL DEFAULT 'none'", + }, + { + name = "tip_status", + type = "ENUM('none','processing','completed','failed') NOT NULL DEFAULT 'none'", + }, + { name = "created_at", type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP" }, + { + name = "updated_at", + type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", + }, + { name = "accepted_at", type = "DATETIME NULL" }, + { name = "arrived_at", type = "DATETIME NULL" }, + { name = "started_at", type = "DATETIME NULL" }, + { name = "completed_at", type = "DATETIME NULL" }, + { name = "cancelled_at", type = "DATETIME NULL" }, + { name = "refunded_at", type = "DATETIME NULL" }, + { name = "paid_out_at", type = "DATETIME NULL" }, + }, + primaryKey = "id", + indexes = { + { + name = "idx_sky_phone_skyride_passenger", + columns = "(`passenger_profile_id`, `status`, `updated_at`)", + }, + { + name = "idx_sky_phone_skyride_driver", + columns = "(`driver_profile_id`, `status`, `updated_at`)", + }, + { name = "idx_sky_phone_skyride_requests", columns = "(`status`, `created_at`)" }, + { + name = "idx_sky_phone_skyride_refunds", + columns = "(`refund_status`, `status`, `updated_at`)", + }, + }, + foreignKeys = { + { + column = "passenger_profile_id", + references = "`sky_phone_skyride_profiles` (`id`) ON DELETE CASCADE", + }, + { + column = "driver_profile_id", + references = "`sky_phone_skyride_profiles` (`id`) ON DELETE SET NULL", + }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, } Bridge.Database.Migrate("sky_phone", schema) diff --git a/sky_phone/source/server/skyride.lua b/sky_phone/source/server/skyride.lua new file mode 100644 index 0000000..1a27f35 --- /dev/null +++ b/sky_phone/source/server/skyride.lua @@ -0,0 +1,1801 @@ +Bridge.Database.AfterMigration("sky_phone", function() +local allowed_cancel_reasons = { + changed_mind = true, + driver_delay = true, + emergency = true, + other = true, + wrong_pickup = true, +} +local quotes = {} +local online_drivers = {} +local operation_locks = {} +local services = {} + +if Config.SkyRide.DistanceUnit ~= "kilometer" and Config.SkyRide.DistanceUnit ~= "mile" then + error(("[sky_phone] Invalid SkyRide distance unit '%s'."):format(tostring(Config.SkyRide.DistanceUnit))) +end + +for index = 1, #Config.SkyRide.Services do + local service = Config.SkyRide.Services[index] + if service.Id ~= "taxi" and service.Id ~= "comfort" and service.Id ~= "xl" and service.Id ~= "premium" then + error(("[sky_phone] Invalid SkyRide service class '%s'."):format(tostring(service.Id))) + end + if services[service.Id] then + error(("[sky_phone] Duplicate SkyRide service class '%s'."):format(service.Id)) + end + services[service.Id] = service +end + +local ride_select = [[ + SELECT r.*, + passenger.`owner_identifier` AS `passenger_identifier`, + driver.`owner_identifier` AS `driver_identifier`, + UNIX_TIMESTAMP(r.`created_at`) AS `created_at_unix`, + UNIX_TIMESTAMP(r.`updated_at`) AS `updated_at_unix`, + UNIX_TIMESTAMP(r.`accepted_at`) AS `accepted_at_unix`, + UNIX_TIMESTAMP(r.`arrived_at`) AS `arrived_at_unix`, + UNIX_TIMESTAMP(r.`started_at`) AS `started_at_unix`, + UNIX_TIMESTAMP(r.`completed_at`) AS `completed_at_unix`, + UNIX_TIMESTAMP(r.`cancelled_at`) AS `cancelled_at_unix` + FROM `sky_phone_skyride_rides` r + INNER JOIN `sky_phone_skyride_profiles` passenger + ON passenger.`id` = r.`passenger_profile_id` + LEFT JOIN `sky_phone_skyride_profiles` driver + ON driver.`id` = r.`driver_profile_id` +]] + +local function affected_rows(result) + if type(result) == "number" then + return result + end + return type(result) == "table" and tonumber(result.affectedRows) or 0 +end + +local function player_name(source) + local firstname = Bridge.Framework.GetFirstname(source) + local lastname = Bridge.Framework.GetLastname(source) + local name = ((firstname or "") .. " " .. (lastname or "")):match("^%s*(.-)%s*$") + if name == "" then + name = GetPlayerName(source) or ("Player %s"):format(source) + end + return name:sub(1, 80) +end + +local function get_player_coords(source) + local ped = GetPlayerPed(source) + if not ped or ped == 0 or not DoesEntityExist(ped) then + return nil + end + local coords = GetEntityCoords(ped) + return { x = coords.x, y = coords.y, z = coords.z } +end + +local function distance_between(left, right) + local x = left.x - right.x + local y = left.y - right.y + local z = left.z - right.z + return math.sqrt((x * x) + (y * y) + (z * z)) +end + +local function is_near(source, location, radius) + local coords = get_player_coords(source) + return coords and distance_between(coords, location.coords) <= radius +end + +local function validate_location(value) + if type(value) ~= "table" or type(value.coords) ~= "table" then + return nil + end + local label = type(value.label) == "string" and value.label:match("^%s*(.-)%s*$") or nil + local label_length = label and utf8.len(label) or nil + local x = tonumber(value.coords.x) + local y = tonumber(value.coords.y) + local z = tonumber(value.coords.z) + local id = value.id + if not label_length + or label_length < 1 + or label_length > 80 + or not x + or not y + or not z + or x ~= x + or y ~= y + or z ~= z + or math.abs(x) > 10000.0 + or math.abs(y) > 10000.0 + or z < -1000.0 + or z > 3000.0 + or (id ~= nil and (type(id) ~= "string" or #id > 40 or not id:match("^[%w_-]+$"))) + then + return nil + end + return { + coords = { x = x, y = y, z = z }, + id = id, + label = label, + } +end + +local function quick_locations() + local result = {} + for index = 1, #Config.SkyRide.QuickLocations do + local location = Config.SkyRide.QuickLocations[index] + result[index] = { + coords = { + x = location.Coords.x, + y = location.Coords.y, + z = location.Coords.z, + }, + id = location.Id, + label = location.Label, + } + end + return result +end + +local function generate_uuid() + local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) + local id = rows[1] and rows[1].id + if type(id) ~= "string" then + error("[sky_phone] Database did not generate a SkyRide id.") + end + return id +end + +local function discard_quote_group(group) + for quote_id, quote in pairs(quotes) do + if quote.group == group then + quotes[quote_id] = nil + end + end +end + +local function require_profile(source) + local session, error_response = SkyPhone.RequireSession(source) + if not session then + return nil, error_response + end + local identifier = Bridge.Framework.GetIdentifier(source) + if type(identifier) ~= "string" or identifier == "" or #identifier > 80 then + return nil, { success = false, error = "skyride_unavailable" } + end + Bridge.Database.Query([[ + INSERT IGNORE INTO `sky_phone_skyride_profiles` (`id`, `owner_identifier`) + SELECT UUID(), ? + ]], { identifier }) + local rows = Bridge.Database.Query([[ + SELECT `id`, `owner_identifier`, UNIX_TIMESTAMP(`created_at`) AS `created_at_unix` + FROM `sky_phone_skyride_profiles` + WHERE `owner_identifier` = ? + LIMIT 1 + ]], { identifier }) + if not rows[1] then + error(("[sky_phone] Failed to load SkyRide profile for source %s."):format(tostring(source))) + end + return rows[1] +end + +local function driver_eligible(source) + local job = Bridge.Framework.GetJob(source) + local minimum_grade = Config.SkyRide.DriverJobs[job.name] + return minimum_grade ~= nil and (tonumber(job.grade) or 0) >= minimum_grade +end + +local function driver_online(source, profile) + local state = online_drivers[source] + if not state or state.profile_id ~= profile.id or not driver_eligible(source) then + online_drivers[source] = nil + return false + end + return true +end + +local function current_vehicle(source) + local ped = GetPlayerPed(source) + if not ped or ped == 0 or not DoesEntityExist(ped) then + return nil + end + local entity = GetVehiclePedIsIn(ped, false) + if not entity or entity == 0 or not DoesEntityExist(entity) then + return nil + end + if GetPedInVehicleSeat(entity, -1) ~= ped then + return nil + end + local vehicle = { + color = "", + model = "", + plate = "", + } + vehicle.model = tostring(GetEntityModel(entity) or "") + vehicle.plate = tostring(GetVehicleNumberPlateText(entity) or ""):match("^%s*(.-)%s*$"):sub(1, 16) + return vehicle +end + +local function profile_snapshot(source, profile) + local rows = Bridge.Database.Query([[ + SELECT + (SELECT COUNT(*) + FROM `sky_phone_skyride_rides` + WHERE `status` = 'completed' + AND (`passenger_profile_id` = ? OR `driver_profile_id` = ?)) AS `completed_rides`, + (SELECT COUNT(*) + FROM `sky_phone_skyride_rides` + WHERE `status` = 'cancelled' + AND (`passenger_profile_id` = ? OR `driver_profile_id` = ?)) AS `cancelled_rides`, + (SELECT AVG(`passenger_rating`) + FROM `sky_phone_skyride_rides` + WHERE `driver_profile_id` = ? AND `passenger_rating` IS NOT NULL) AS `rating`, + (SELECT COALESCE(SUM(`payout_amount` + `tip_amount`), 0) + FROM `sky_phone_skyride_rides` + WHERE `driver_profile_id` = ? + AND `status` = 'completed' + AND DATE(`completed_at`) = CURRENT_DATE) AS `earnings_today` + ]], { profile.id, profile.id, profile.id, profile.id, profile.id, profile.id }) + local stats = rows[1] or {} + return { + acceptanceRate = false, + avatarUrl = false, + cancelledRides = tonumber(stats.cancelled_rides) or 0, + completedRides = tonumber(stats.completed_rides) or 0, + currency = Config.SkyRide.Currency, + defaultPaymentMethod = Config.SkyRide.PaymentAccount, + earningsToday = driver_eligible(source) and (tonumber(stats.earnings_today) or 0) or false, + id = profile.id, + memberSince = tonumber(profile.created_at_unix) or os.time(), + name = player_name(source), + rating = math.floor(((tonumber(stats.rating) or 5.0) * 100) + 0.5) / 100, + } +end + +local function select_ride_rows(where_clause, parameters, limit) + local values = {} + for index = 1, #parameters do + values[index] = parameters[index] + end + values[#values + 1] = limit + return Bridge.Database.Query( + ride_select .. " WHERE " .. where_clause .. " ORDER BY r.`updated_at` DESC, r.`id` DESC LIMIT ?", + values + ) +end + +local function profile_metrics(rows) + local profile_ids = {} + local seen = {} + for index = 1, #rows do + local row = rows[index] + for _, profile_id in ipairs({ row.passenger_profile_id, row.driver_profile_id }) do + if profile_id and not seen[profile_id] then + seen[profile_id] = true + profile_ids[#profile_ids + 1] = profile_id + end + end + end + if #profile_ids == 0 then + return {} + end + local placeholders = {} + for index = 1, #profile_ids do + placeholders[index] = "?" + end + local metrics_rows = Bridge.Database.Query(([[ + SELECT profile.`id`, + (SELECT AVG(ride.`passenger_rating`) + FROM `sky_phone_skyride_rides` ride + WHERE ride.`driver_profile_id` = profile.`id` + AND ride.`passenger_rating` IS NOT NULL) AS `rating`, + (SELECT COUNT(*) + FROM `sky_phone_skyride_rides` ride + WHERE ride.`status` = 'completed' + AND (ride.`passenger_profile_id` = profile.`id` + OR ride.`driver_profile_id` = profile.`id`)) AS `trips` + FROM `sky_phone_skyride_profiles` profile + WHERE profile.`id` IN (%s) + ]]):format(table.concat(placeholders, ", ")), profile_ids) + local metrics = {} + for index = 1, #metrics_rows do + local row = metrics_rows[index] + metrics[row.id] = { + rating = math.floor(((tonumber(row.rating) or 5.0) * 100) + 0.5) / 100, + trips = tonumber(row.trips) or 0, + } + end + return metrics +end + +local function source_for_identifier(identifier) + if not identifier then + return nil + end + for _, player_source in ipairs(Bridge.Framework.GetPlayers()) do + local source = tonumber(player_source) or player_source + if Bridge.Framework.GetIdentifier(source) == identifier then + return source + end + end + return nil +end + +local function phone_number_for_source(source) + local session = source and SkyPhone.RequireSession(source) or nil + if not session then + return nil + end + local device = SkyPhone.LoadDevice(session.imei) + return device and device.phone_number or nil +end + +local function online_source_for_identifier(identifier) + if not identifier then + return nil + end + for source, state in pairs(online_drivers) do + if state.identifier == identifier and GetPlayerName(source) then + return source + end + end + return nil +end + +local function person_dto(id, name, metrics) + return { + avatarUrl = false, + id = id, + name = name or "", + rating = metrics and metrics.rating or 5.0, + trips = metrics and metrics.trips or 0, + } +end + +local function ride_dtos(rows) + local metrics = profile_metrics(rows) + local result = {} + for index = 1, #rows do + local row = rows[index] + local status = row.status == "completing" and "in_progress" or row.status + local passenger = person_dto( + row.passenger_profile_id, + row.passenger_name, + metrics[row.passenger_profile_id] + ) + local driver = false + if row.driver_profile_id then + driver = person_dto(row.driver_profile_id, row.driver_name, metrics[row.driver_profile_id]) + driver.vehicle = { + color = row.driver_vehicle_color or "", + model = row.driver_vehicle_model or "", + plate = row.driver_vehicle_plate or "", + } + if status == "accepted" or status == "arrived" or status == "in_progress" then + local source = online_source_for_identifier(row.driver_identifier) + local location = source and get_player_coords(source) or nil + if location then + driver.location = location + end + end + end + local ride = { + acceptedAt = row.accepted_at_unix and tonumber(row.accepted_at_unix) or nil, + arrivedAt = row.arrived_at_unix and tonumber(row.arrived_at_unix) or nil, + cancelledAt = row.cancelled_at_unix and tonumber(row.cancelled_at_unix) or nil, + cancelledBy = row.cancelled_by, + completedAt = row.completed_at_unix and tonumber(row.completed_at_unix) or nil, + createdAt = tonumber(row.created_at_unix) or 0, + currency = row.currency, + destination = { + coords = { + x = tonumber(row.destination_x) or 0.0, + y = tonumber(row.destination_y) or 0.0, + z = tonumber(row.destination_z) or 0.0, + }, + label = row.destination_label, + }, + distanceMeters = tonumber(row.distance_meters) or 0, + driver = driver, + durationSeconds = tonumber(row.duration_seconds) or 0, + id = row.id, + passenger = passenger, + pickup = { + coords = { + x = tonumber(row.pickup_x) or 0.0, + y = tonumber(row.pickup_y) or 0.0, + z = tonumber(row.pickup_z) or 0.0, + }, + label = row.pickup_label, + }, + price = tonumber(row.price) or 0, + serviceClass = row.service_class, + startedAt = row.started_at_unix and tonumber(row.started_at_unix) or nil, + status = status, + updatedAt = tonumber(row.updated_at_unix) or 0, + } + if status == "completed" then + ride.finalPrice = tonumber(row.price) or 0 + end + if row.passenger_rating then + ride.rating = tonumber(row.passenger_rating) + end + result[index] = ride + end + return result +end + +local function active_ride(profile_id) + local rows = select_ride_rows([[ + (r.`passenger_profile_id` = ? OR r.`driver_profile_id` = ?) + AND r.`status` IN ('searching','accepted','arrived','in_progress','completing') + ]], { profile_id, profile_id }, 2) + if #rows > 1 then + Bridge.Debug( + "error", + "[sky_phone] SkyRide profile '%s' has multiple active rides.", + tostring(profile_id) + ) + end + local rides = ride_dtos(rows) + if rides[1] and rows[1] then + local passenger_number = phone_number_for_source(source_for_identifier(rows[1].passenger_identifier)) + if passenger_number then + rides[1].passenger.phoneNumber = passenger_number + end + if type(rides[1].driver) == "table" then + local driver_number = phone_number_for_source(source_for_identifier(rows[1].driver_identifier)) + if driver_number then + rides[1].driver.phoneNumber = driver_number + end + end + end + return rides[1], rows[1] +end + +local function available_requests(profile_id) + local rows = select_ride_rows([[ + r.`status` = 'searching' AND r.`passenger_profile_id` <> ? + ]], { profile_id }, Config.SkyRide.HistoryLimit) + return ride_dtos(rows) +end + +local function ride_history(profile_id) + local rows = select_ride_rows([[ + (r.`passenger_profile_id` = ? OR r.`driver_profile_id` = ?) + AND r.`status` IN ('completed','cancelled') + ]], { profile_id, profile_id }, Config.SkyRide.HistoryLimit) + return ride_dtos(rows) +end + +local function pending_rating(profile_id) + local rows = select_ride_rows([[ + r.`passenger_profile_id` = ? + AND r.`status` = 'completed' + AND r.`passenger_rating` IS NULL + AND r.`tip_status` = 'none' + ]], { profile_id }, 1) + local rides = ride_dtos(rows) + return rides[1] +end + +local function ride_by_id(ride_id) + local rows = select_ride_rows("r.`id` = ?", { ride_id }, 1) + local rides = ride_dtos(rows) + return rides[1], rows[1] +end + +local function process_pending_refund(row) + if not row or (row.status ~= "payment_pending" and row.status ~= "cancelled") then + return false + end + if row.refund_status == "completed" then + if row.status ~= "payment_pending" then + return true + end + local delete_success, delete_result = pcall( + Bridge.Database.Query, + [[ + DELETE FROM `sky_phone_skyride_rides` + WHERE `id` = ? AND `status` = 'payment_pending' + AND `refund_status` = 'completed' + ]], + { row.id } + ) + if not delete_success then + Bridge.Debug( + "error", + "[sky_phone] Failed to remove refunded payment-pending SkyRide '%s': %s", + row.id, + tostring(delete_result) + ) + return false + end + return true + end + if row.refund_status ~= "pending" then + return false + end + local price = tonumber(row.price) or 0 + local passenger_source = source_for_identifier(row.passenger_identifier) + if price > 0 and not passenger_source then + return false + end + local claim_success, claim_result = pcall( + Bridge.Database.Query, + [[ + UPDATE `sky_phone_skyride_rides` + SET `refund_status` = 'processing' + WHERE `id` = ? AND `status` IN ('payment_pending','cancelled') + AND `refund_status` = 'pending' + ]], + { row.id } + ) + if not claim_success then + Bridge.Debug( + "error", + "[sky_phone] Failed to claim pending refund for SkyRide '%s': %s", + row.id, + tostring(claim_result) + ) + return false + end + if affected_rows(claim_result) ~= 1 then + return false + end + local money_success = true + local money_result = true + if price > 0 then + money_success, money_result = pcall( + Bridge.Framework.AddMoney, + passenger_source, + Config.SkyRide.PaymentAccount, + price + ) + end + if not money_success then + Bridge.Debug( + "error", + "[sky_phone] Framework refund crashed for SkyRide '%s'; processing state prevents an automatic duplicate refund and requires manual reconciliation: %s", + row.id, + tostring(money_result) + ) + return false + end + if not money_result then + local reset_success, reset_result = pcall( + Bridge.Database.Query, + [[ + UPDATE `sky_phone_skyride_rides` + SET `refund_status` = 'pending' + WHERE `id` = ? AND `refund_status` = 'processing' + ]], + { row.id } + ) + if not reset_success or affected_rows(reset_result) ~= 1 then + Bridge.Debug( + "error", + "[sky_phone] Refund failed and its claim could not be reset for SkyRide '%s'; manual reconciliation is required.", + row.id + ) + end + Bridge.Debug( + "error", + "[sky_phone] Framework refund failed for SkyRide '%s': %s", + row.id, + tostring(money_result) + ) + return false + end + local finalize_success, finalize_result = pcall( + Bridge.Database.Query, + [[ + UPDATE `sky_phone_skyride_rides` + SET `refund_status` = 'completed', `refunded_at` = CURRENT_TIMESTAMP + WHERE `id` = ? AND `refund_status` = 'processing' + ]], + { row.id } + ) + if not finalize_success or affected_rows(finalize_result) ~= 1 then + Bridge.Debug( + "error", + "[sky_phone] Refund was paid but could not be finalized for SkyRide '%s'; processing state prevents an automatic duplicate refund.", + row.id + ) + return false + end + if passenger_source then + TriggerClientEvent("sky_phone:banking:changed", passenger_source) + end + if row.status == "payment_pending" then + local delete_success, delete_result = pcall( + Bridge.Database.Query, + [[ + DELETE FROM `sky_phone_skyride_rides` + WHERE `id` = ? AND `status` = 'payment_pending' + AND `refund_status` = 'completed' + ]], + { row.id } + ) + if not delete_success or affected_rows(delete_result) ~= 1 then + Bridge.Debug( + "error", + "[sky_phone] Refunded payment-pending SkyRide '%s' could not be removed; recovery will retry cleanup.", + row.id + ) + return false + end + end + return true +end + +local function transition_tip_status(ride_id, expected_status, next_status) + local update_success, update_result = pcall( + Bridge.Database.Query, + [[ + UPDATE `sky_phone_skyride_rides` + SET `tip_status` = ? + WHERE `id` = ? AND `tip_status` = ? + ]], + { next_status, ride_id, expected_status } + ) + if not update_success or affected_rows(update_result) ~= 1 then + Bridge.Debug( + "error", + "[sky_phone] Failed to move SkyRide '%s' tip state from '%s' to '%s'.", + ride_id, + expected_status, + next_status + ) + return false + end + return true +end + +local function state_snapshot(source, profile, fields) + local result = {} + if fields.activeRide then + result.activeRide = active_ride(profile.id) or false + end + if fields.availableRequests then + result.availableRequests = driver_online(source, profile) and available_requests(profile.id) or {} + end + if fields.driverOnline then + result.driverOnline = driver_online(source, profile) + end + if fields.history then + result.history = ride_history(profile.id) + end + if fields.pendingRating then + result.pendingRating = pending_rating(profile.id) or false + end + if fields.profile then + result.profile = profile_snapshot(source, profile) + end + return result +end + +local function push_update(source, fields) + local profile = require_profile(source) + if not profile then + return + end + TriggerClientEvent("sky_phone:skyride:changed", source, state_snapshot(source, profile, fields)) +end + +local function push_participants(row, fields, excluded_source) + for _, identifier in ipairs({ row.passenger_identifier, row.driver_identifier }) do + local source = source_for_identifier(identifier) + if source and source ~= excluded_source then + push_update(source, fields) + end + end +end + +local function push_available_requests(excluded_source) + for source in pairs(online_drivers) do + if source ~= excluded_source then + push_update(source, { availableRequests = true }) + end + end +end + +local function run_locked(key, callback) + if operation_locks[key] then + return nil, { success = false, error = "rate_limited" } + end + operation_locks[key] = true + local success, result = pcall(callback) + operation_locks[key] = nil + if not success then + error(result) + end + return result +end + +local function valid_ride_id(data) + return type(data) == "table" + and type(data.rideId) == "string" + and #data.rideId == 36 + and data.rideId:match("^[%x%-]+$") + and data.rideId + or nil +end + +local function transition_driver_ride(source, data, expected_statuses, next_status, timestamp_column, radius, location_kind) + if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + if not driver_online(source, profile) then + return { success = false, error = "driver_offline" } + end + local ride_id = valid_ride_id(data) + if not ride_id then + return { success = false, error = "ride_not_found" } + end + local result, lock_error = run_locked("profile:" .. profile.id, function() + local _, row = ride_by_id(ride_id) + if not row then + return { success = false, error = "ride_not_found" } + end + if row.driver_profile_id ~= profile.id then + return { success = false, error = "not_ride_participant" } + end + if not expected_statuses[row.status] then + return { success = false, error = "invalid_ride_status" } + end + local location = location_kind == "pickup" and { + coords = { + x = tonumber(row.pickup_x), + y = tonumber(row.pickup_y), + z = tonumber(row.pickup_z), + }, + } or { + coords = { + x = tonumber(row.destination_x), + y = tonumber(row.destination_y), + z = tonumber(row.destination_z), + }, + } + if not is_near(source, location, radius) then + return { + success = false, + error = location_kind == "pickup" and "driver_too_far" or "destination_too_far", + } + end + if next_status == "in_progress" or next_status == "completing" then + local passenger_source = source_for_identifier(row.passenger_identifier) + if not passenger_source or not is_near(passenger_source, location, radius) then + return { success = false, error = "passenger_too_far" } + end + end + local status_placeholders = {} + local parameters = { next_status } + for status in pairs(expected_statuses) do + status_placeholders[#status_placeholders + 1] = "?" + parameters[#parameters + 1] = status + end + parameters[#parameters + 1] = ride_id + parameters[#parameters + 1] = profile.id + local update = Bridge.Database.Query(([[ + UPDATE `sky_phone_skyride_rides` + SET `status` = ?, `%s` = CURRENT_TIMESTAMP + WHERE `status` IN (%s) AND `id` = ? AND `driver_profile_id` = ? + ]]):format(timestamp_column, table.concat(status_placeholders, ", ")), parameters) + if affected_rows(update) ~= 1 then + return { success = false, error = "invalid_ride_status" } + end + local _, updated_row = ride_by_id(ride_id) + push_participants(updated_row, { activeRide = true }, source) + return { + success = true, + data = state_snapshot(source, profile, { activeRide = true }), + } + end) + return result or lock_error +end + +Bridge.Callbacks.Register("sky_phone:skyride:bootstrap", function(source) + if not SkyPhone.AllowOperation(source, "skyride_read", Config.SkyRide.ReadsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local online = driver_online(source, profile) + return { + success = true, + data = { + activeRide = active_ride(profile.id) or false, + availableRequests = online and available_requests(profile.id) or {}, + driverEligible = driver_eligible(source), + driverOnline = online, + history = ride_history(profile.id), + pendingRating = pending_rating(profile.id) or false, + profile = profile_snapshot(source, profile), + quickLocations = quick_locations(), + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:skyride:history", function(source) + if not SkyPhone.AllowOperation(source, "skyride_read", Config.SkyRide.ReadsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + return { success = true, data = { items = ride_history(profile.id) } } +end) + +Bridge.Callbacks.Register("sky_phone:skyride:quote", function(source, data) + if not SkyPhone.AllowOperation(source, "skyride_quote", Config.SkyRide.QuotesPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local pickup = validate_location(data and data.pickup) + local destination = validate_location(data and data.destination) + if not pickup or not destination then + return { success = false, error = "invalid_location" } + end + if not is_near(source, pickup, Config.SkyRide.PickupSelectionRadius) then + return { success = false, error = "pickup_too_far" } + end + local custom_fare = data and data.customFare or nil + if custom_fare ~= nil then + local custom_service = type(custom_fare) == "table" and custom_fare.serviceClass or nil + local custom_price = type(custom_fare) == "table" and custom_fare.price or nil + if not Config.SkyRide.CustomFare.Enabled + or type(custom_service) ~= "string" + or not services[custom_service] + or type(custom_price) ~= "number" + or custom_price ~= custom_price + or custom_price ~= math.floor(custom_price) + then + return { success = false, error = "invalid_custom_fare" } + end + custom_fare = { + price = custom_price, + service_class = custom_service, + } + end + local direct_distance = distance_between(pickup.coords, destination.coords) + local distance_meters = math.floor((direct_distance * Config.SkyRide.RouteDistanceMultiplier) + 0.5) + if distance_meters < Config.SkyRide.MinimumDistanceMeters + or distance_meters > Config.SkyRide.MaximumDistanceMeters + then + return { success = false, error = "invalid_route" } + end + local duration_seconds = math.max( + 60, + math.floor((distance_meters / Config.SkyRide.AverageSpeedMetersPerSecond) + 0.5) + ) + local now = os.time() + local expires_at = now + Config.SkyRide.QuoteLifetimeSeconds + local quote_group = generate_uuid() + local options = {} + local distance_unit = Config.SkyRide.DistanceUnit + local distance_value = distance_meters / (distance_unit == "mile" and 1609.344 or 1000) + for index = 1, #Config.SkyRide.Services do + local service = Config.SkyRide.Services[index] + local price_per_distance_unit = distance_unit == "mile" + and service.PricePerMile + or service.PricePerKilometer + local calculated_price = math.floor(math.max( + service.MinimumFare, + service.BaseFare + + (distance_value * price_per_distance_unit) + + ((duration_seconds / 60) * service.PricePerMinute) + ) + 0.5) + local minimum_custom_price = math.floor(math.max( + Config.SkyRide.CustomFare.MinimumPrice, + service.MinimumFare, + calculated_price * Config.SkyRide.CustomFare.MinimumCalculatedMultiplier + ) + 0.5) + local maximum_custom_price = math.floor(math.min( + Config.SkyRide.CustomFare.MaximumPrice, + calculated_price * Config.SkyRide.CustomFare.MaximumCalculatedMultiplier + ) + 0.5) + local uses_custom_fare = custom_fare and custom_fare.service_class == service.Id + if uses_custom_fare + and (custom_fare.price < minimum_custom_price or custom_fare.price > maximum_custom_price) + then + discard_quote_group(quote_group) + return { success = false, error = "invalid_custom_fare" } + end + local price = uses_custom_fare and custom_fare.price or calculated_price + local quote_id = quote_group .. ":" .. service.Id + local option = { + available = true, + calculatedPrice = calculated_price, + currency = Config.SkyRide.Currency, + etaMinutes = service.EtaMinutes, + fareMode = uses_custom_fare and "custom" or "calculated", + maximumCustomPrice = maximum_custom_price, + minimumCustomPrice = minimum_custom_price, + price = price, + pricePerDistanceUnit = price_per_distance_unit, + quoteId = quote_id, + seats = service.Seats, + serviceClass = service.Id, + } + quotes[quote_id] = { + destination = destination, + distance_meters = distance_meters, + duration_seconds = duration_seconds, + expires_at = expires_at, + group = quote_group, + option = option, + pickup = pickup, + profile_id = profile.id, + source = source, + } + options[index] = option + end + return { + success = true, + data = { + destination = destination, + distance = distance_value, + distanceMeters = distance_meters, + distanceUnit = distance_unit, + durationSeconds = duration_seconds, + expiresAt = expires_at, + options = options, + pickup = pickup, + }, + } +end) + +Bridge.Callbacks.Register("sky_phone:skyride:request", function(source, data) + if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local quote_id = type(data) == "table" and data.quoteId or nil + if type(quote_id) ~= "string" or #quote_id > 64 then + return { success = false, error = "quote_not_found" } + end + local quote = quotes[quote_id] + if not quote or quote.source ~= source or quote.profile_id ~= profile.id then + return { success = false, error = "quote_not_found" } + end + if quote.expires_at <= os.time() then + quotes[quote_id] = nil + return { success = false, error = "quote_expired" } + end + if not is_near(source, quote.pickup, Config.SkyRide.PickupSelectionRadius) then + return { success = false, error = "pickup_too_far" } + end + local result, lock_error = run_locked("profile:" .. profile.id, function() + local pending_rows = select_ride_rows([[ + r.`passenger_profile_id` = ? AND r.`status` = 'payment_pending' + ]], { profile.id }, 1) + if active_ride(profile.id) or pending_rows[1] then + return { success = false, error = "active_ride_exists" } + end + local ride_id = generate_uuid() + local price = quote.option.price + local payout = math.floor((price * Config.SkyRide.DriverPayoutPercent / 100) + 0.5) + Bridge.Database.Query([[ + INSERT INTO `sky_phone_skyride_rides` + (`id`, `passenger_profile_id`, `passenger_name`, `status`, `service_class`, + `pickup_label`, `pickup_x`, `pickup_y`, `pickup_z`, + `destination_label`, `destination_x`, `destination_y`, `destination_z`, + `distance_meters`, `duration_seconds`, `price`, `payout_amount`, `currency`) + VALUES (?, ?, ?, 'payment_pending', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ]], { + ride_id, + profile.id, + player_name(source), + quote.option.serviceClass, + quote.pickup.label, + quote.pickup.coords.x, + quote.pickup.coords.y, + quote.pickup.coords.z, + quote.destination.label, + quote.destination.coords.x, + quote.destination.coords.y, + quote.destination.coords.z, + quote.distance_meters, + quote.duration_seconds, + price, + payout, + quote.option.currency, + }) + local charge_success = true + local charge_result = true + if price > 0 then + charge_success, charge_result = pcall( + Bridge.Framework.RemoveMoney, + source, + Config.SkyRide.PaymentAccount, + price + ) + end + if not charge_success then + discard_quote_group(quote.group) + Bridge.Debug( + "error", + "[sky_phone] Fare debit crashed for SkyRide '%s'; payment state is ambiguous and requires manual reconciliation: %s", + ride_id, + tostring(charge_result) + ) + return { success = false, error = "refund_failed" } + end + if not charge_result then + local delete_success, delete_result = pcall( + Bridge.Database.Query, + "DELETE FROM `sky_phone_skyride_rides` WHERE `id` = ? AND `status` = 'payment_pending'", + { ride_id } + ) + if not delete_success or affected_rows(delete_result) ~= 1 then + Bridge.Debug( + "error", + "[sky_phone] Failed to remove uncharged payment-pending SkyRide '%s'.", + ride_id + ) + end + return { success = false, error = "insufficient_funds" } + end + discard_quote_group(quote.group) + if price > 0 then + local capture_success, capture_result = pcall( + Bridge.Database.Query, + [[ + UPDATE `sky_phone_skyride_rides` + SET `refund_status` = 'pending' + WHERE `id` = ? AND `status` = 'payment_pending' + AND `refund_status` = 'none' + ]], + { ride_id } + ) + if not capture_success then + Bridge.Debug( + "error", + "[sky_phone] Fare was charged but its recovery marker failed for SkyRide '%s': %s. Manual reconciliation is required.", + ride_id, + tostring(capture_result) + ) + return { success = false, error = "refund_failed" } + end + if affected_rows(capture_result) ~= 1 then + local refund_success, refund_result = pcall( + Bridge.Framework.AddMoney, + source, + Config.SkyRide.PaymentAccount, + price + ) + if refund_success and refund_result then + local delete_success, delete_result = pcall( + Bridge.Database.Query, + "DELETE FROM `sky_phone_skyride_rides` WHERE `id` = ? AND `status` = 'payment_pending'", + { ride_id } + ) + if delete_success and affected_rows(delete_result) == 1 then + TriggerClientEvent("sky_phone:banking:changed", source) + return { success = false, error = "request_failed" } + end + end + Bridge.Debug( + "error", + "[sky_phone] Fare recovery failed before activating SkyRide '%s'. Manual reconciliation is required.", + ride_id + ) + return { success = false, error = "refund_failed" } + end + end + local update_success, update = pcall(Bridge.Database.Query, [[ + UPDATE `sky_phone_skyride_rides` + SET `status` = 'searching', `refund_status` = 'none' + WHERE `id` = ? AND `status` = 'payment_pending' + AND `refund_status` = ? + ]], { ride_id, price > 0 and "pending" or "none" }) + if not update_success or affected_rows(update) ~= 1 then + local load_success, _, pending_row = pcall(ride_by_id, ride_id) + if load_success and pending_row and pending_row.status == "searching" then + update_success = true + elseif load_success and pending_row and price > 0 and pending_row.refund_status == "pending" then + local refunded = process_pending_refund(pending_row) + if not refunded then + return { success = false, error = "refund_failed" } + end + return { success = false, error = "request_failed" } + elseif price > 0 then + Bridge.Debug( + "error", + "[sky_phone] Could not verify fare recovery after SkyRide '%s' activation failed; manual reconciliation is required.", + ride_id + ) + return { success = false, error = "refund_failed" } + else + local delete_success, delete_result = pcall( + Bridge.Database.Query, + [[ + DELETE FROM `sky_phone_skyride_rides` + WHERE `id` = ? AND `status` = 'payment_pending' + AND `refund_status` = 'none' + ]], + { ride_id } + ) + if not delete_success or affected_rows(delete_result) ~= 1 then + Bridge.Debug( + "error", + "[sky_phone] Failed to clean payment-pending SkyRide '%s' after request activation failed.", + ride_id + ) + end + if not update_success then + Bridge.Debug( + "error", + "[sky_phone] Failed to activate SkyRide '%s': %s", + ride_id, + tostring(update) + ) + end + return { success = false, error = "request_failed" } + end + end + TriggerClientEvent("sky_phone:banking:changed", source) + push_available_requests(source) + return { + success = true, + data = state_snapshot(source, profile, { + activeRide = true, + availableRequests = true, + }), + } + end) + return result or lock_error +end) + +Bridge.Callbacks.Register("sky_phone:skyride:set-driver-status", function(source, data) + if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + if type(data) ~= "table" or type(data.online) ~= "boolean" then + return { success = false, error = "invalid_driver_status" } + end + if data.online and not driver_eligible(source) then + return { success = false, error = "driver_not_eligible" } + end + local _, ride_row = active_ride(profile.id) + if not data.online and ride_row and ride_row.driver_profile_id == profile.id then + return { success = false, error = "active_ride_exists" } + end + if data.online and ride_row and ride_row.passenger_profile_id == profile.id and not ride_row.driver_profile_id then + return { success = false, error = "active_ride_exists" } + end + online_drivers[source] = data.online and { + identifier = profile.owner_identifier, + profile_id = profile.id, + } or nil + return { + success = true, + data = state_snapshot(source, profile, { + driverOnline = true, + availableRequests = true, + }), + } +end) + +Bridge.Callbacks.Register("sky_phone:skyride:accept", function(source, data) + if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + if not driver_online(source, profile) then + return { success = false, error = "driver_offline" } + end + local ride_id = valid_ride_id(data) + if not ride_id then + return { success = false, error = "ride_not_found" } + end + local result, lock_error = run_locked("profile:" .. profile.id, function() + if active_ride(profile.id) then + return { success = false, error = "active_ride_exists" } + end + local _, row = ride_by_id(ride_id) + if not row then + return { success = false, error = "ride_not_found" } + end + if row.status ~= "searching" then + return { success = false, error = "invalid_ride_status" } + end + if row.passenger_profile_id == profile.id then + return { success = false, error = "not_ride_participant" } + end + local vehicle = current_vehicle(source) + if not vehicle then + return { success = false, error = "driver_vehicle_required" } + end + local update = Bridge.Database.Query([[ + UPDATE `sky_phone_skyride_rides` + SET `driver_profile_id` = ?, `driver_name` = ?, `status` = 'accepted', + `driver_vehicle_model` = ?, `driver_vehicle_color` = ?, `driver_vehicle_plate` = ?, + `accepted_at` = CURRENT_TIMESTAMP + WHERE `id` = ? AND `status` = 'searching' AND `driver_profile_id` IS NULL + ]], { + profile.id, + player_name(source), + vehicle.model, + vehicle.color, + vehicle.plate, + ride_id, + }) + if affected_rows(update) ~= 1 then + return { success = false, error = "invalid_ride_status" } + end + local _, updated_row = ride_by_id(ride_id) + push_participants(updated_row, { activeRide = true, availableRequests = true }, source) + push_available_requests(source) + return { + success = true, + data = state_snapshot(source, profile, { + activeRide = true, + availableRequests = true, + }), + } + end) + return result or lock_error +end) + +Bridge.Callbacks.Register("sky_phone:skyride:arrive", function(source, data) + return transition_driver_ride( + source, + data, + { accepted = true }, + "arrived", + "arrived_at", + Config.SkyRide.ArrivalRadius, + "pickup" + ) +end) + +Bridge.Callbacks.Register("sky_phone:skyride:start", function(source, data) + return transition_driver_ride( + source, + data, + { arrived = true }, + "in_progress", + "started_at", + Config.SkyRide.ArrivalRadius, + "pickup" + ) +end) + +Bridge.Callbacks.Register("sky_phone:skyride:complete", function(source, data) + if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + if not driver_online(source, profile) then + return { success = false, error = "driver_offline" } + end + local ride_id = valid_ride_id(data) + if not ride_id then + return { success = false, error = "ride_not_found" } + end + local result, lock_error = run_locked("ride:" .. ride_id, function() + local _, row = ride_by_id(ride_id) + if not row then + return { success = false, error = "ride_not_found" } + end + if row.driver_profile_id ~= profile.id then + return { success = false, error = "not_ride_participant" } + end + if row.status ~= "in_progress" then + return { success = false, error = "invalid_ride_status" } + end + local destination = { + coords = { + x = tonumber(row.destination_x), + y = tonumber(row.destination_y), + z = tonumber(row.destination_z), + }, + } + if not is_near(source, destination, Config.SkyRide.CompletionRadius) then + return { success = false, error = "destination_too_far" } + end + local passenger_source = source_for_identifier(row.passenger_identifier) + if not passenger_source or not is_near(passenger_source, destination, Config.SkyRide.CompletionRadius) then + return { success = false, error = "passenger_too_far" } + end + local completing = Bridge.Database.Query([[ + UPDATE `sky_phone_skyride_rides` + SET `status` = 'completing' + WHERE `id` = ? AND `driver_profile_id` = ? AND `status` = 'in_progress' + ]], { ride_id, profile.id }) + if affected_rows(completing) ~= 1 then + return { success = false, error = "invalid_ride_status" } + end + local payout = tonumber(row.payout_amount) or 0 + local payout_success = true + local payout_result = true + if payout > 0 then + payout_success, payout_result = pcall( + Bridge.Framework.AddMoney, + source, + Config.SkyRide.PaymentAccount, + payout + ) + end + if not payout_success then + Bridge.Debug( + "error", + "[sky_phone] Driver payout crashed for SkyRide '%s'; completing state prevents an ambiguous retry and requires manual reconciliation: %s", + ride_id, + tostring(payout_result) + ) + return { success = false, error = "payout_failed" } + end + if not payout_result then + local reset_success, reset_result = pcall(Bridge.Database.Query, [[ + UPDATE `sky_phone_skyride_rides` + SET `status` = 'in_progress' + WHERE `id` = ? AND `status` = 'completing' + ]], { ride_id }) + if not reset_success or affected_rows(reset_result) ~= 1 then + Bridge.Debug( + "error", + "[sky_phone] Driver payout failed and SkyRide '%s' could not be restored to in-progress.", + ride_id + ) + end + return { success = false, error = "payout_failed" } + end + local completed_success, completed = pcall(Bridge.Database.Query, [[ + UPDATE `sky_phone_skyride_rides` + SET `status` = 'completed', `completed_at` = CURRENT_TIMESTAMP, + `paid_out_at` = CURRENT_TIMESTAMP + WHERE `id` = ? AND `status` = 'completing' + ]], { ride_id }) + if not completed_success then + Bridge.Debug( + "error", + "[sky_phone] SkyRide '%s' completion query crashed after payout; completing state blocks an ambiguous retry and requires manual reconciliation: %s", + ride_id, + tostring(completed) + ) + return { success = false, error = "payout_failed" } + end + if affected_rows(completed) ~= 1 then + local compensation_success = true + local compensation_result = true + if payout > 0 then + compensation_success, compensation_result = pcall( + Bridge.Framework.RemoveMoney, + source, + Config.SkyRide.PaymentAccount, + payout + ) + end + if compensation_success and compensation_result then + local reset_success, reset_result = pcall(Bridge.Database.Query, [[ + UPDATE `sky_phone_skyride_rides` + SET `status` = 'in_progress', `completed_at` = NULL, `paid_out_at` = NULL + WHERE `id` = ? AND `status` = 'completing' + ]], { ride_id }) + if reset_success and affected_rows(reset_result) == 1 then + if payout > 0 then + TriggerClientEvent("sky_phone:banking:changed", source) + end + Bridge.Debug( + "error", + "[sky_phone] Compensated driver payout after SkyRide '%s' completion could not be finalized.", + ride_id + ) + return { success = false, error = "payout_failed" } + end + Bridge.Debug( + "error", + "[sky_phone] Driver payout was compensated but SkyRide '%s' could not be restored; manual reconciliation is required.", + ride_id + ) + return { success = false, error = "payout_failed" } + end + Bridge.Debug( + "error", + "[sky_phone] Failed to compensate payout for unfinalized SkyRide '%s'; completing state prevents an automatic duplicate payout: %s", + ride_id, + tostring(compensation_result) + ) + return { success = false, error = "payout_failed" } + end + TriggerClientEvent("sky_phone:banking:changed", source) + local _, updated_row = ride_by_id(ride_id) + push_participants(updated_row, { + activeRide = true, + history = true, + pendingRating = true, + profile = true, + }, source) + return { + success = true, + data = state_snapshot(source, profile, { + activeRide = true, + history = true, + pendingRating = true, + profile = true, + }), + } + end) + return result or lock_error +end) + +Bridge.Callbacks.Register("sky_phone:skyride:cancel", function(source, data) + if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local ride_id = valid_ride_id(data) + if not ride_id then + return { success = false, error = "ride_not_found" } + end + local reason = data.reason + if reason ~= nil and (type(reason) ~= "string" or not allowed_cancel_reasons[reason]) then + return { success = false, error = "invalid_cancel_reason" } + end + local result, lock_error = run_locked("ride:" .. ride_id, function() + local _, row = ride_by_id(ride_id) + if not row then + return { success = false, error = "ride_not_found" } + end + local role + if row.passenger_profile_id == profile.id then + role = "passenger" + elseif row.driver_profile_id == profile.id then + role = "driver" + else + return { success = false, error = "not_ride_participant" } + end + if row.status ~= "searching" + and row.status ~= "accepted" + and row.status ~= "arrived" + and row.status ~= "in_progress" + then + return { success = false, error = "invalid_ride_status" } + end + if row.status == "searching" and role ~= "passenger" then + return { success = false, error = "not_ride_participant" } + end + local update = Bridge.Database.Query([[ + UPDATE `sky_phone_skyride_rides` + SET `status` = 'cancelled', `cancelled_by` = ?, `cancel_reason` = ?, + `cancelled_at` = CURRENT_TIMESTAMP, `refund_status` = 'pending' + WHERE `id` = ? AND `status` IN ('searching','accepted','arrived','in_progress') + AND `refund_status` = 'none' + ]], { role, reason, ride_id }) + if affected_rows(update) ~= 1 then + return { success = false, error = "invalid_ride_status" } + end + local _, updated_row = ride_by_id(ride_id) + if not process_pending_refund(updated_row) then + Bridge.Debug( + "info", + "[sky_phone] Refund for cancelled SkyRide '%s' remains queued for server recovery.", + ride_id + ) + end + _, updated_row = ride_by_id(ride_id) + push_participants(updated_row, { + activeRide = true, + availableRequests = true, + history = true, + pendingRating = true, + profile = true, + }, source) + push_available_requests(source) + return { + success = true, + data = state_snapshot(source, profile, { + activeRide = true, + availableRequests = true, + history = true, + pendingRating = true, + profile = true, + }), + } + end) + return result or lock_error +end) + +Bridge.Callbacks.Register("sky_phone:skyride:rate", function(source, data) + if not SkyPhone.AllowOperation(source, "skyride_action", Config.SkyRide.ActionsPerMinute, 60) then + return { success = false, error = "rate_limited" } + end + local profile, error_response = require_profile(source) + if not profile then + return error_response + end + local ride_id = valid_ride_id(data) + if not ride_id then + return { success = false, error = "ride_not_found" } + end + local rating = tonumber(data.rating) + local tip = tonumber(data.tip) or 0 + local comment = type(data.comment) == "string" and data.comment:match("^%s*(.-)%s*$") or "" + local comment_length = utf8.len(comment) + if not rating or rating ~= math.floor(rating) or rating < 1 or rating > 5 then + return { success = false, error = "invalid_rating" } + end + if not tip or tip ~= math.floor(tip) or tip < 0 or tip > Config.SkyRide.MaximumTip then + return { success = false, error = "invalid_tip" } + end + if not comment_length or comment_length > Config.SkyRide.RatingCommentMaxLength then + return { success = false, error = "invalid_comment" } + end + local result, lock_error = run_locked("ride:" .. ride_id, function() + local _, row = ride_by_id(ride_id) + if not row then + return { success = false, error = "ride_not_found" } + end + if row.passenger_profile_id ~= profile.id then + return { success = false, error = "not_ride_participant" } + end + if row.status ~= "completed" or row.passenger_rating ~= nil or row.tip_status ~= "none" then + return { success = false, error = "invalid_ride_status" } + end + local driver_source = source_for_identifier(row.driver_identifier) + if tip > 0 and not driver_source then + return { success = false, error = "driver_unavailable" } + end + if not transition_tip_status(ride_id, "none", "processing") then + return { success = false, error = "invalid_ride_status" } + end + if tip > 0 then + local debit_success, debit_result = pcall( + Bridge.Framework.RemoveMoney, + source, + Config.SkyRide.PaymentAccount, + tip + ) + if not debit_success then + transition_tip_status(ride_id, "processing", "failed") + Bridge.Debug( + "error", + "[sky_phone] Passenger tip debit crashed for SkyRide '%s'; manual reconciliation is required: %s", + ride_id, + tostring(debit_result) + ) + return { success = false, error = "tip_failed" } + end + if not debit_result then + transition_tip_status(ride_id, "processing", "none") + return { success = false, error = "insufficient_funds" } + end + local credit_success, credit_result = pcall( + Bridge.Framework.AddMoney, + driver_source, + Config.SkyRide.PaymentAccount, + tip + ) + if not credit_success then + transition_tip_status(ride_id, "processing", "failed") + Bridge.Debug( + "error", + "[sky_phone] Driver tip credit crashed for SkyRide '%s'; retry is blocked for manual reconciliation: %s", + ride_id, + tostring(credit_result) + ) + return { success = false, error = "tip_failed" } + end + if not credit_result then + local refund_success, refund_result = pcall( + Bridge.Framework.AddMoney, + source, + Config.SkyRide.PaymentAccount, + tip + ) + if refund_success and refund_result then + transition_tip_status(ride_id, "processing", "none") + TriggerClientEvent("sky_phone:banking:changed", source) + else + transition_tip_status(ride_id, "processing", "failed") + Bridge.Debug( + "error", + "[sky_phone] Passenger refund after rejected driver tip credit is ambiguous or failed for SkyRide '%s'; retry is blocked for manual reconciliation: %s", + ride_id, + tostring(refund_result) + ) + end + return { success = false, error = "tip_failed" } + end + end + local update_success, update = pcall(Bridge.Database.Query, [[ + UPDATE `sky_phone_skyride_rides` + SET `passenger_rating` = ?, `rating_comment` = ?, `tip_amount` = ?, + `tip_status` = 'completed' + WHERE `id` = ? AND `passenger_profile_id` = ? + AND `status` = 'completed' AND `passenger_rating` IS NULL + AND `tip_status` = 'processing' + ]], { rating, comment, tip, ride_id, profile.id }) + if not update_success then + Bridge.Debug( + "error", + "[sky_phone] Rating query crashed after tip transfer for SkyRide '%s'; processing state blocks an ambiguous retry and requires manual reconciliation: %s", + ride_id, + tostring(update) + ) + return { success = false, error = "tip_failed" } + end + if affected_rows(update) ~= 1 then + if tip == 0 then + transition_tip_status(ride_id, "processing", "none") + return { success = false, error = "tip_failed" } + end + local driver_debit_success, driver_debit_result = pcall( + Bridge.Framework.RemoveMoney, + driver_source, + Config.SkyRide.PaymentAccount, + tip + ) + if not driver_debit_success or not driver_debit_result then + transition_tip_status(ride_id, "processing", "failed") + Bridge.Debug( + "error", + "[sky_phone] Rating persistence failed and driver tip debit was ambiguous or rejected for SkyRide '%s'; passenger refund was not attempted and manual reconciliation is required: %s", + ride_id, + tostring(driver_debit_result) + ) + return { success = false, error = "tip_failed" } + end + TriggerClientEvent("sky_phone:banking:changed", driver_source) + local passenger_refund_success, passenger_refund_result = pcall( + Bridge.Framework.AddMoney, + source, + Config.SkyRide.PaymentAccount, + tip + ) + if passenger_refund_success and passenger_refund_result then + TriggerClientEvent("sky_phone:banking:changed", source) + transition_tip_status(ride_id, "processing", "none") + Bridge.Debug( + "error", + "[sky_phone] Compensated rating persistence failure for SkyRide '%s'.", + ride_id + ) + else + transition_tip_status(ride_id, "processing", "failed") + Bridge.Debug( + "error", + "[sky_phone] Driver tip was removed but passenger refund was ambiguous or rejected for SkyRide '%s'; manual reconciliation is required: %s", + ride_id, + tostring(passenger_refund_result) + ) + end + return { success = false, error = "tip_failed" } + end + if tip > 0 then + TriggerClientEvent("sky_phone:banking:changed", source) + TriggerClientEvent("sky_phone:banking:changed", driver_source) + end + local _, updated_row = ride_by_id(ride_id) + push_participants(updated_row, { history = true, pendingRating = true, profile = true }, source) + return { + success = true, + data = state_snapshot(source, profile, { + history = true, + pendingRating = true, + profile = true, + }), + } + end) + return result or lock_error +end) + +AddEventHandler("playerDropped", function() + local player_source = source + online_drivers[player_source] = nil + for quote_id, quote in pairs(quotes) do + if quote.source == player_source then + quotes[quote_id] = nil + end + end +end) + +CreateThread(function() + while true do + Wait(30000) + local now = os.time() + for quote_id, quote in pairs(quotes) do + if quote.expires_at <= now or not GetPlayerName(quote.source) then + quotes[quote_id] = nil + end + end + for source in pairs(online_drivers) do + if not GetPlayerName(source) then + online_drivers[source] = nil + end + end + end +end) + +CreateThread(function() + local reconciliation_rows = Bridge.Database.Query([[ + SELECT + SUM(`refund_status` = 'processing') AS `processing_refunds`, + SUM(`tip_status` IN ('processing','failed')) AS `unresolved_tips`, + SUM(`status` = 'completing') AS `unresolved_payouts`, + SUM(`status` = 'payment_pending' AND `refund_status` = 'none') AS `ambiguous_payments` + FROM `sky_phone_skyride_rides` + ]], {}) + local reconciliation = reconciliation_rows[1] or {} + if (tonumber(reconciliation.processing_refunds) or 0) > 0 + or (tonumber(reconciliation.unresolved_tips) or 0) > 0 + or (tonumber(reconciliation.unresolved_payouts) or 0) > 0 + or (tonumber(reconciliation.ambiguous_payments) or 0) > 0 + then + Bridge.Debug( + "error", + "[sky_phone] SkyRide has unresolved cross-system payments (refunds=%s, tips=%s, payouts=%s, ambiguous fares=%s); manual reconciliation is required.", + tostring(reconciliation.processing_refunds or 0), + tostring(reconciliation.unresolved_tips or 0), + tostring(reconciliation.unresolved_payouts or 0), + tostring(reconciliation.ambiguous_payments or 0) + ) + end + while true do + local recovery_success, recovery_rows = pcall( + Bridge.Database.Query, + ride_select .. (([[ + WHERE r.`refund_status` IN ('pending','completed') + AND ( + r.`status` = 'cancelled' + OR ( + r.`status` = 'payment_pending' + AND r.`updated_at` <= DATE_SUB( + CURRENT_TIMESTAMP, + INTERVAL %d SECOND + ) + ) + ) + ORDER BY r.`updated_at` ASC + LIMIT 50 + ]]):format(math.floor(Config.SkyRide.PaymentPendingRecoverySeconds))) + ) + if recovery_success then + for index = 1, #recovery_rows do + process_pending_refund(recovery_rows[index]) + end + else + Bridge.Debug( + "error", + "[sky_phone] SkyRide refund recovery query failed: %s", + tostring(recovery_rows) + ) + end + Wait(math.floor(Config.SkyRide.RecoveryIntervalSeconds * 1000)) + end +end) + +AddEventHandler("onResourceStop", function(resource_name) + if resource_name ~= GetCurrentResourceName() then + return + end + quotes = {} + online_drivers = {} + operation_locks = {} +end) +end) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index 7bafea1..a146dc2 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -614,3 +614,60 @@ CREATE TABLE IF NOT EXISTS `sky_phone_picstagram_moderation_audit` ( KEY `idx_sky_phone_picstagram_audit_target` (`target_type`,`target_id`,`created_at`), FOREIGN KEY (`report_id`) REFERENCES `sky_phone_picstagram_reports` (`id`) ON DELETE SET NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_skyride_profiles` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `owner_identifier` VARCHAR(80) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_sky_phone_skyride_owner` (`owner_identifier`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `sky_phone_skyride_rides` ( + `id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `passenger_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `driver_profile_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL, + `passenger_name` VARCHAR(80) NOT NULL, + `driver_name` VARCHAR(80) NULL, + `status` ENUM('payment_pending','searching','accepted','arrived','in_progress','completing','completed','cancelled') NOT NULL, + `service_class` ENUM('taxi','comfort','xl','premium') NOT NULL, + `pickup_label` VARCHAR(80) NOT NULL, + `pickup_x` DECIMAL(10,3) NOT NULL, + `pickup_y` DECIMAL(10,3) NOT NULL, + `pickup_z` DECIMAL(10,3) NOT NULL, + `destination_label` VARCHAR(80) NOT NULL, + `destination_x` DECIMAL(10,3) NOT NULL, + `destination_y` DECIMAL(10,3) NOT NULL, + `destination_z` DECIMAL(10,3) NOT NULL, + `distance_meters` INT UNSIGNED NOT NULL, + `duration_seconds` INT UNSIGNED NOT NULL, + `price` INT UNSIGNED NOT NULL, + `payout_amount` INT UNSIGNED NOT NULL, + `currency` VARCHAR(8) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `driver_vehicle_model` VARCHAR(64) NULL, + `driver_vehicle_color` VARCHAR(64) NULL, + `driver_vehicle_plate` VARCHAR(16) NULL, + `cancelled_by` ENUM('passenger','driver','system') NULL, + `cancel_reason` VARCHAR(32) NULL, + `passenger_rating` TINYINT UNSIGNED NULL, + `rating_comment` VARCHAR(300) NULL, + `tip_amount` INT UNSIGNED NOT NULL DEFAULT 0, + `refund_status` ENUM('none','pending','processing','completed') NOT NULL DEFAULT 'none', + `tip_status` ENUM('none','processing','completed','failed') NOT NULL DEFAULT 'none', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `accepted_at` DATETIME NULL, + `arrived_at` DATETIME NULL, + `started_at` DATETIME NULL, + `completed_at` DATETIME NULL, + `cancelled_at` DATETIME NULL, + `refunded_at` DATETIME NULL, + `paid_out_at` DATETIME NULL, + PRIMARY KEY (`id`), + KEY `idx_sky_phone_skyride_passenger` (`passenger_profile_id`,`status`,`updated_at`), + KEY `idx_sky_phone_skyride_driver` (`driver_profile_id`,`status`,`updated_at`), + KEY `idx_sky_phone_skyride_requests` (`status`,`created_at`), + KEY `idx_sky_phone_skyride_refunds` (`refund_status`,`status`,`updated_at`), + FOREIGN KEY (`passenger_profile_id`) REFERENCES `sky_phone_skyride_profiles` (`id`) ON DELETE CASCADE, + FOREIGN KEY (`driver_profile_id`) REFERENCES `sky_phone_skyride_profiles` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;