Tom_Neverwinter

chocobo_racing 02282026

Feb 28th, 2026
254
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 108.39 KB | None | 0 0
  1. require('scripts/globals/chocobo_names')
  2. -----------------------------------
  3. -- Chocobo Racing
  4. -- https://www.bg-wiki.com/ffxi/Category:Chocobo_Racing
  5. -- https://ffxiclopedia.fandom.com/wiki/Chocobo_Racing_Guide
  6. --
  7. -- ============================================================================
  8. -- 🛑 STRICT EMULATION WARNING 🛑
  9. -- THIS IS EMULATION. DO NOT INVENT NEW MECHANICS, PACKET STRUCTURES,
  10. -- OR 'SMART' LOGIC. Everything must strictly align with observed retail
  11. -- behavior and verified packet captures. If retail FFXI didn't do it,
  12. -- DO NOT add it.
  13. -- ============================================================================
  14. -----------------------------------
  15. require('scripts/globals/packet')
  16. -----------------------------------
  17. xi = xi or {}
  18. xi.chocoboRacing = xi.chocoboRacing or {}
  19. xi.chocoboRacing.registeredPlayers = xi.chocoboRacing.registeredPlayers or {}
  20. xi.chocoboRacing.activeBets = xi.chocoboRacing.activeBets or {}
  21.  
  22. -- FOR HEAVILY-IN-DEVELOPMENT TESTING, you can force these setting:
  23. -- Ensure these are moved to main settings files before release.
  24. xi.settings.main.ENABLE_CHOCOBO_RACING = true
  25. xi.settings.main.DEBUG_CHOCOBO_RACING = true
  26.  
  27. -- Packet Flow Reference (from analysis of 18+ retail captures):
  28. --
  29. -- The race lifecycle uses these packets in coordination:
  30. -- 0x069 (ChocoboRacingSys) — Race simulation data: params, stats, sections, results
  31. -- 0x05D (EventUpdateString) — Chocobo display names (4 per packet, 2 packets = 8 lanes)
  32. -- 0x05C (PendingNum) — Event work params (numeric updates during cutscene)
  33. -- 0x034 (EventNum) — Starts race cutscene events (numeric params)
  34. -- 0x033 (EventStr) — Starts events WITH STRING PARAMS (player chocobo name)
  35. -- 0x036 (TalkNum) — NPC announcer dialogue (zone text IDs from SevMess table)
  36. -- 0x027 (TalkNumWork2) — NPC dialogue with string+number params (race results)
  37. -- 0x052 (EventUcOff) — Releases client from pending state (Mode=1 in all captures)
  38. -- 0x05B (WPOS) — Entity position updates (camera/NPC movement during race)
  39. -- 0x00E (CHAR_NPC) — NPC entity spawn/update (jockey/chocobo models)
  40. --
  41. -- NOTE: 0x052 Mode=1 packets are generated automatically by the engine when
  42. -- player:updateEvent() is called. They reset PTR_RecPendingFlag to 0, allowing
  43. -- the client to continue processing. We do NOT need to send them manually.
  44. --
  45. -- SPECTATOR vs PLAYER-ENTERED RACES:
  46. -- Spectator races (Multi-Race/Race#2/Race#4 captures):
  47. -- - 0x034 starts event 210 with UniqueNo = Rungaga NPC
  48. -- - RACE_PARAMS_0 = 0x28 (40), category = 0x80000000+
  49. -- - All 8 lanes populated, NPC-only
  50. -- Player-entered races ('Entering a chocobo' capture):
  51. -- - 0x033 starts event with player's chocobo name (String[0-3] = 'IrisAudace')
  52. -- - RACE_PARAMS_0 = 0x24 (36), category = 0x6030 (different encoding!)
  53. -- - Only 4 of 8 lanes may be populated
  54. -- - Player's chocobo is always in lane 1
  55. -- - Mode 2 has fewer populated racer entries
  56. --
  57. -- ============================================================================
  58. -- FULL PACKET AUDIT (all observed packet types across all captures)
  59. -- ============================================================================
  60. --
  61. -- INCOMING (Server -> Client) -- 24 unique packet types observed:
  62. --
  63. -- RACE-CRITICAL (must implement for races to work):
  64. -- 0x069 ChocoboRacingSys Race sim data (params/stats/sections/results) [HANDLED]
  65. -- 0x05D EventUpdateString Chocobo display names (4 per packet) [HANDLED]
  66. -- 0x05C PendingNum Event work params (numeric updates in CS) [HANDLED]
  67. -- 0x034 EventNum Starts spectator race event (Event 210) [HANDLED]
  68. -- 0x033 EventStr Starts player-entered event (Event 469) [NPC SCRIPT]
  69. -- 0x052 EventUcOff Releases client pending state (Mode=1) [AUTO/engine]
  70. --
  71. -- RACE-SUPPORT (enhances race experience):
  72. -- 0x036 TalkNum NPC announcer dialogue (SevMess text IDs) [HANDLED]
  73. -- 0x027 TalkNumWork2 NPC dialogue with string+number params [NPC SCRIPT]
  74. -- 0x00E CHAR_NPC NPC entity spawn/update (jockeys/chocobos) [HANDLED]
  75. -- 0x067 (Unknown) Entity extra data (Mode 2=PC refresh, [ENGINE]
  76. -- Mode 3=NPC name/metadata on state change)
  77. -- 0x05B WPOS (incoming) Entity position updates from server [VIA setPos()]
  78. --
  79. -- ENVIRONMENTAL (zone infrastructure, not race-specific):
  80. -- 0x00D CHAR_PC Player character data (position/flags/model) [ENGINE]
  81. -- 0x017 CHAT_STD Standard chat messages [ENGINE]
  82. -- 0x01D ITEM_SAME Inventory container load state [ENGINE]
  83. -- 0x028 BATTLE2 Battle action results [ENGINE N/A]
  84. -- 0x029 BATTLE_MESSAGE Combat messages [ENGINE N/A]
  85. -- 0x02D BATTLE_MESSAGE2 End-of-combat messages (EXP/chains) [ENGINE N/A]
  86. -- 0x037 SERVERSTATUS Entity status/flags update [ENGINE]
  87. -- 0x057 WEATHER Zone weather updates [ENGINE]
  88. -- 0x0DF GROUP_ATTR Party member info updates [ENGINE]
  89. -- 0x0F4 TRACKING_LIST Wide Scan entries [ENGINE]
  90. -- 0x0F6 TRACKING_STATE Wide Scan list start/end [ENGINE]
  91. -- 0x110 (Unity) Unity/Sparks/Deeds info [ENGINE]
  92. -- 0x111 (RoE Active) Records of Eminence active quest log [ENGINE]
  93. -- 0x112 (RoE Complete) Records of Eminence completion flags [ENGINE]
  94. --
  95. -- OUTGOING (Client -> Server) -- 5 unique packet types observed:
  96. -- 0x015 POS Client position update (every frame) [ENGINE]
  97. -- 0x016 (Unknown) Client -> server updates [ENGINE]
  98. -- 0x05B EVENT_OPTION Client event response (option selection) [VIA updateEvent]
  99. -- 0x03A ITEM_STACK Inventory sort request [ENGINE]
  100. -- 0x0F4 TRACKING_REQ Wide Scan request [ENGINE]
  101. --
  102. -- Legend:
  103. -- [HANDLED] = Implemented in this module
  104. -- [NPC SCRIPT] = Belongs in NPC attendant script (see event469_reference)
  105. -- [INVESTIGATE] = Needs further analysis
  106. -- [AUTO/engine] = Generated automatically by engine internals
  107. -- [ENGINE] = Handled by game engine, not our responsibility
  108. -- [ENGINE N/A] = Engine handles it; not relevant to racing
  109. -- [VIA xxx] = Indirectly handled through named API call
  110. --
  111. -- REMAINING TODO:
  112. -- - Polish navmesh coordinates once FFXI server pathing is confirmed.
  113. --
  114. -- RESOLVED:
  115. -- 0x00E -- Custom packet builder implemented natively via sendDebugPacket.
  116. -- Spawns visible jockey/chocobo entities dynamically. FFXI FFXI FFXI
  117. -- 0x067 -- Analyzed: Mode 2 = periodic player entity refresh (every 2-3 min),
  118. -- Mode 3 = NPC name/metadata push when NPC state changes (race staff NPCs
  119. -- ActIndex 0xB6-0xC1). Both generated automatically by the engine.
  120. -- ============================================================================
  121.  
  122. -- ============================================================================
  123. -- SERVER-BASE COMPLIANCE CHECKLIST (verified against server-base ref code)
  124. -- ============================================================================
  125. -- [OK] Packet format: 0xC8 (200) byte arrays, 1-indexed Lua tables
  126. -- [OK] sendDebugPacket: Confirmed C++ binding (lua_baseentity.cpp:1012)
  127. -- [OK] Mode 1: Race number + category LE encoding
  128. -- [OK] Mode 2: 12-byte blocks aligned (name1/name2/speed/stam/skill/race+color/silks/saddle/item)
  129. -- [OK] Mode 3: Section data with ParamIndex/ParamSize
  130. -- [OK] Mode 5: Ready signal
  131. -- [OK] startEvent(210, ...): Spectator race event trigger with 8 numeric params
  132. -- [OK] updateEvent(): GP_SERV_COMMAND_PENDINGNUM for numeric CS updates
  133. -- [OK] updateEventString(): GP_SERV_COMMAND_PENDINGSTR for name delivery (4 strings)
  134. -- [OK] onEventUpdate/onEventFinish: Zone.lua delegates to xi.chocoboRacing.*
  135. -- [OK] DefaultActions.lua: NPC name->event mapping (no text needed, all preset)
  136. -- [OK] NPC pools with name1/name2 indices from chocobo_names.lua
  137. -- [OK] Category constants match: 0x80000000-0x80000003
  138. -- [OK] Race scheduling: 15-min slots, 8-slot 2-hour cycle
  139. -- [OK] require('scripts/globals/chocobo_names')
  140. --
  141. -- [DIFF] getLocalTextVar: Server-base uses it but C++ binding doesn't exist.
  142. -- We use currentRaceData for string retrieval instead. Functionally identical.
  143. -- [DIFF] Mode 4: Server-base uses winnings+combo+flags format.
  144. -- We use nibble-packed placements. Both approaches are valid —
  145. -- server-base's format is closer to retail captures.
  146. -- [DIFF] sendPacket vs sendDebugPacket: Server-base calls sendPacket (alias).
  147. -- Functionally identical to sendDebugPacket.
  148. --
  149. -- [OK] Chocobuck system (getChocobucks/addChocobucks/purchaseItem)
  150. -- [OK] Player registration (registerPlayer/getStatsForRacing)
  151. -- [OK] Free run fee management (getFreeRunFee)
  152. -- [OK] Equipment/saddle stat modifications (applyEffects)
  153. -- [OK] Betting event handler (onBettingEventFinish)
  154. -- [OK] Full payout processing (processPayouts)
  155. -- [OK] NPC interaction handler (onNPCInteraction)
  156. -- [OK] Zone tick integration (onZoneTick)
  157. -- ============================================================================
  158.  
  159. -- Notes:
  160. -- Since there is a timed element, packet elements, and a lot of data to fill in,
  161. -- it makes sense to move pretty much everything apart from the CS handling down
  162. -- into core. While developing, things can stay up here, but as this approaches
  163. -- a stable state everything should be pushed down.
  164.  
  165. -- To Run:
  166. -- !exec xi.chocoboRacing.startRace()
  167.  
  168. -- https://github.com/atom0s/XiPackets/blob/main/world/server/0x0069/README.md
  169.  
  170. -----------------------------------
  171. -- Constants
  172. -----------------------------------
  173. local constants = {
  174. -- Packet 0x0069 Control Modes
  175. MODE = {
  176. REGISTRATION = 0x01, -- Updates RacingParams (Weather, Track, League)
  177. CHOCOBO_DATA = 0x02, -- Updates ChocoboParams (Stats, Names, Jockey Silks, Saddles)
  178. SECTION_DATA = 0x03, -- Updates SectionParams (Movement Pathing)
  179. RESULT_DATA = 0x04, -- Updates ResultParams (Winners, Winnings, Final Pathing)
  180. READY_SIGNAL = 0x05, -- Finalizes setup and tells client to start the race sequence
  181. },
  182.  
  183. -- Race number constant (used in Mode 1 RaceParams)
  184. RACE_PARAMS_0 = 0x28, -- 40 decimal, confirmed from retail captures
  185.  
  186. -- Race League Categories (Packet Mode 1 - RaceParams bytes 12-15)
  187. -- NOTE: High byte is always 0x80 (set in packet builder)
  188. CATEGORY = {
  189. C4_REGULAR = 0x80000000, -- 2147483648 - Beginner League
  190. C1_CRYSTAL_STAKES = 0x80000001, -- 2147483649 - Elite League
  191. C2_REGULAR = 0x80000002, -- 2147483650 - Expert League
  192. C3_REGULAR = 0x80000003, -- 2147483651 - Standard League
  193. },
  194.  
  195. -- Jockey Race/Gender Models
  196. RACE = {
  197. GALKA = 0,
  198. HUME_M = 1,
  199. HUME_F = 2,
  200. ELVAAN_M = 3,
  201. ELVAAN_F = 4,
  202. TARUTARU_M = 5,
  203. TARUTARU_F = 6,
  204. MITHRA = 7,
  205. GALKA_2 = 8,
  206. HUME_M_2 = 9,
  207. HUME_F_2 = 10,
  208. ELVAAN_M_2 = 11,
  209. ELVAAN_F_2 = 12,
  210. TARUTARU_M_2 = 13,
  211. TARUTARU_F_2 = 14,
  212. MITHRA_2 = 15
  213. },
  214.  
  215. -- Chocobo Color Visuals (Packet Mode 2, Byte 7 low nibble)
  216. -- Source: Confirmed from official implementation + real packet captures.
  217. -- Values 0-8 are the ONLY documented colors in retail.
  218. -- 0 and 1 both render Yellow; 2 and 3 both render Black; 4 and 5 both render Blue.
  219. COLOR = {
  220. YELLOW_1 = 0, -- Yellow (primary)
  221. YELLOW_2 = 1, -- Yellow (variant) — confirmed by official
  222. BLACK_1 = 2, -- Black (primary)
  223. BLACK_2 = 3, -- Black (variant) — confirmed by official
  224. BLUE_1 = 4, -- Blue (primary)
  225. BLUE_2 = 5, -- Blue (variant) — confirmed by official
  226. RED_1 = 6, -- Red (primary)
  227. RED_2 = 7, -- Red (variant)
  228. GREEN = 8, -- Green
  229. -- Values 9-15 exist in retail data but are NOT documented by the official.
  230. -- They may be rare/unique colors or map to Yellow by default on some clients.
  231. RARE_9 = 9,
  232. RARE_10 = 10,
  233. RARE_11 = 11,
  234. RARE_12 = 12,
  235. RARE_14 = 14,
  236. RARE_15 = 15,
  237. },
  238.  
  239. -- Jockey Silk/Clothing Visual IDs (Packet Mode 2, Byte 8)
  240. SILKS = {
  241. NONE = 0,
  242. ID_22 = 22,
  243. ID_36 = 36,
  244. ID_44 = 44,
  245. ID_63 = 63,
  246. ID_64 = 64,
  247. ID_96 = 96,
  248. ID_100 = 100,
  249. ID_127 = 127,
  250. ID_128 = 128,
  251. ID_129 = 129,
  252. ID_160 = 160,
  253. ID_191 = 191,
  254. ID_192 = 192,
  255. ID_224 = 224,
  256. ID_254 = 254,
  257. ID_255 = 255,
  258. },
  259.  
  260. -- Environmental Weather States (Mode 1, Byte 17)
  261. WEATHER = {
  262. FINE = 0,
  263. CLOUDY = 1,
  264. RAINY = 2,
  265. HEAT_WAVE = 3,
  266. THUNDER = 4
  267. },
  268.  
  269. -- Track Surface Conditions (Mode 1, Byte 18)
  270. CONDITION = {
  271. DRY = 0,
  272. NORMAL = 1,
  273. HEAVY = 2,
  274. SOFT = 3,
  275. UNKNOWN_183 = 183,
  276. UNKNOWN_207 = 207,
  277. UNKNOWN_255 = 255,
  278. },
  279.  
  280. -- Saddle Item IDs (Packet Mode 2, Byte 9)
  281. SADDLE = {
  282. NONE = 0,
  283. LAUAN = 5,
  284. ASH = 26,
  285. ELM = 36,
  286. BRASS = 50,
  287. SILVER = 63,
  288. MYTHRIL = 64,
  289. GRASS = 95,
  290. COTTON = 96,
  291. LINEN = 128,
  292. RABBIT_HIDE = 129,
  293. SHEEP_LEATHER = 136,
  294. BUFFALO_LEATHER = 160,
  295. },
  296.  
  297. -- Passive Equips (Increase stats for the duration of the race)
  298. EQUIP = {
  299. CHOCOBO_TAPING = 20, -- Strength+
  300. CHOCOBO_BLINKERS = 21, -- Stamina+
  301. SHADOW_ROLL = 22, -- Discernment+
  302. CHOCOBO_HOOD = 23, -- Receptivity+
  303. },
  304.  
  305. TEAM_ID = { SANDORIA = 1, BASTOK = 2, WINDURST = 3 },
  306. RACE_TYPE = { FREE_RUN = 0, OFFICIAL = 1, RE_RUN = 2 },
  307.  
  308. -- ========================================================================
  309. -- OFFICIAL RACE DATA
  310. -- Source: Verified against FFXI retail data (BG-Wiki/Chocobo Racing)
  311. -- These parameters control fees, rewards, and entry requirements.
  312. -- ========================================================================
  313. OFFICIAL_RACES = {
  314. -- Novice
  315. ["NOVICE_I"] = { fee = 0, bucks = 0, gil = 500, opponents = 4 },
  316. ["NOVICE_II"] = { fee = 0, bucks = 0, gil = 1000, opponents = 6 },
  317. ["NOVICE_III"] = { fee = 0, bucks = 3, gil = 1500, opponents = 8 },
  318. -- Challenge & International
  319. ["CHALLENGE_I"] = { fee = 3, bucks = 0, gil = 3000, opponents = 2 },
  320. ["INT_I"] = { fee = 5, bucks = 0, gil = 5000, opponents = 8 },
  321. ["INT_II"] = { fee = 10, bucks = 0, gil = 5500, opponents = 8 },
  322. ["CHALLENGE_II"] = { fee = 15, bucks = 0, gil = 6000, opponents = 3 },
  323. ["CLASSIC"] = { fee = 20, bucks = 10, gil = 6500, opponents = 8, title = "Chocorookie" },
  324. -- Pashhow Swamptrot
  325. ["DUELER"] = { fee = 25, bucks = 15, gil = 7000, opponents = 2 },
  326. ["SURVIVAL"] = { fee = 30, bucks = 15, gil = 7500, opponents = 8 },
  327. ["DEADLY"] = { fee = 35, bucks = 20, gil = 8000, opponents = 8 },
  328. ["MYSTERY"] = { fee = 40, bucks = 20, gil = 8500, opponents = 8 },
  329. ["LETHAL"] = { fee = 45, bucks = 30, gil = 9000, opponents = 8, prize = "Pullus Torque" },
  330. ["DREAM"] = { fee = 50, bucks = 30, gil = 10000, opponents = 8, title = "Chocochampion" },
  331. },
  332.  
  333. JOCKEY_ORDERS = {
  334. KEEP_PACE = 0, -- Conserve energy, consistent speed
  335. FINAL_SPURT = 1, -- Save stamina for the last 25% of race
  336. SPRINT = 2 -- Max speed from start, heavy stamina drain
  337. },
  338.  
  339. -- Zone text IDs for race announcer NPC dialogue (0x036 TalkNum packets)
  340. -- Extracted from MesNum field across all 18 retail captures.
  341. -- These are SevMess dialog table indices for zone 70 (Chocobo Circuit).
  342. TEXT = {
  343. -- Pre-race phase: NPC announcers call out race details
  344. -- Pattern from captures: fired at XX:45:19 - XX:45:49 (6 messages per race)
  345. RACE_ANNOUNCE_1 = 0xA456, -- "The [category] race is about to begin!"
  346. RACE_ANNOUNCE_2 = 0xA458, -- NPC announces first chocobo details
  347. RACE_ANNOUNCE_3 = 0xA459, -- NPC announces second chocobo details
  348. RACE_ANNOUNCE_4 = 0xA45A, -- NPC announces third chocobo details
  349. RACE_ANNOUNCE_5 = 0xA45C, -- NPC announces lane pair A
  350. RACE_ANNOUNCE_6 = 0xA45D, -- NPC announces lane pair B
  351. RACE_ANNOUNCE_FINAL = 0xA3F1, -- "All chocobos are at the starting gate!"
  352.  
  353. -- Race start: fired at XX:50:55 - XX:51:05
  354. RACE_STARTING_1 = 0xA45E, -- "And they're off!" (first NPC)
  355. RACE_STARTING_2 = 0xA45F, -- "And they're off!" (second NPC)
  356. RACE_START_BELL = 0x25AE, -- Race start bell / horn (announcer NPC 0xB6)
  357.  
  358. -- Race finish: fired at XX:51:35
  359. RACE_FINISH_BELL = 0x25AF, -- Race finish bell / horn
  360.  
  361. -- Results: fired after finish (different NPCs announce different details)
  362. RESULT_1ST = 0x25BC, -- "1st place: ..."
  363. RESULT_2ND = 0x25BD, -- "2nd place: ..."
  364. RESULT_WIN_A = 0x2604, -- Winner details (NPC 0xBF/0xC1)
  365. RESULT_WIN_B = 0x2605, -- Winner payout
  366. RESULT_DETAIL_A = 0x260E, -- Additional result detail
  367. RESULT_DETAIL_B = 0x260F, -- Additional result detail
  368. RESULT_RECAP_A = 0x25E4, -- Recap from track NPC
  369. RESULT_RECAP_B = 0x25E5, -- Recap from track NPC
  370. RESULT_RECAP_C = 0x25EE, -- Final recap
  371. RESULT_RECAP_D = 0x25EF, -- Final recap
  372.  
  373. -- Intermission: fired between races (XX:55:23, XX:10:23, etc)
  374. INTERMISSION = 0xA3F5, -- "Next race will begin shortly."
  375. },
  376. }
  377. xi.chocoboRacing.constants = constants
  378.  
  379. -- -----------------------------------------------------------------
  380. -- Chocobuck & Affiliation Helpers
  381. -- -----------------------------------------------------------------
  382.  
  383. xi.chocoboRacing.getAffiliation = function(player)
  384. return player:getCharVar("CR_TEAM_AFFILIATION")
  385. end
  386.  
  387. xi.chocoboRacing.setAffiliation = function(player, teamId)
  388. player:setCharVar("CR_TEAM_AFFILIATION", teamId)
  389. end
  390.  
  391. xi.chocoboRacing.getChocobucks = function(player, teamId)
  392. local team = teamId or xi.chocoboRacing.getAffiliation(player)
  393. if not team or team < 1 or team > 3 then
  394. return 0
  395. end
  396. -- Use persistent Character Variable instead of unsupported raw SQL
  397. return player:getCharVar(string.format("CR_BUCKS_%d", team))
  398. end
  399.  
  400. xi.chocoboRacing.addChocobucks = function(player, amount, teamId)
  401. local team = teamId or xi.chocoboRacing.getAffiliation(player)
  402. if not team or team < 1 or team > 3 then
  403. return 0
  404. end
  405. local current = xi.chocoboRacing.getChocobucks(player, team)
  406.  
  407. -- Retail Clamp: 0 to 1000
  408. local newAmount = math.max(0, math.min(1000, current + amount))
  409.  
  410. player:setCharVar(string.format("CR_BUCKS_%d", team), newAmount)
  411. return newAmount
  412. end
  413.  
  414. -- -----------------------------------------------------------------
  415. -- Shop & Items
  416. -- -----------------------------------------------------------------
  417.  
  418. local SHOP_ITEMS_TIER1 = {
  419. 5605, -- Sharug Greens
  420. 5606, -- Azouph Greens
  421. 2201, -- Tokopekko Wildgrass
  422. 2202, -- Garidav Wildgrass
  423. 5607, -- Vomp Carrot
  424. 5608, -- Zegham Carrot
  425. }
  426.  
  427. local SHOP_ITEMS_TIER2 = {
  428. 2210, -- Vegetable Paste
  429. 2208, -- Herb Paste
  430. 2206, -- Carrot Paste
  431. 2209, -- Worm Paste
  432. 2203, -- Cupid Worm
  433. 2204, -- Parasite Worm
  434. 2205, -- Gregarious Worm
  435. 2645, -- Eastern Ginger
  436. }
  437.  
  438. local SHOP_ITEMS_TIER3 = {
  439. 647, -- Lauan Lumber
  440. 641, -- Ash Lumber
  441. 642, -- Elm Lumber
  442. 624, -- Brass Ingot
  443. 625, -- Silver Ingot
  444. 626, -- Mythril Ingot
  445. }
  446.  
  447. local SHOP_ITEMS_TIER4 = {
  448. 2403, -- Chocotrain: Speed / Strength
  449. 2404, -- Chocotrain: Endurance
  450. 2405, -- Chocotrain: Discernment
  451. 2406, -- Chocotrain: Receptivity
  452. 2407, -- Chocotrain token
  453. }
  454.  
  455. local SHOP_ITEMS_TIER5 = {
  456. 2380, -- Red Chocobo Dye
  457. 2381, -- Blue Chocobo Dye
  458. 2382, -- Green Chocobo Dye
  459. 2383, -- Black Chocobo Dye
  460. 2379, -- Yellow Chocobo Dye
  461. }
  462.  
  463. local SHOP_ITEMS_TIER6 = {
  464. 11326, -- Red Race Silks
  465. 11328, -- Green Race Silks
  466. 11325, -- Blue Race Silks
  467. 11322, -- Black Race Silks
  468. 11327, -- White Race Silks
  469. 11321, -- Orange Race Silks
  470. 11324, -- Sky Blue Race Silks
  471. 11323, -- Purple Race Silks
  472. }
  473.  
  474. xi.chocoboRacing.purchaseItem = function(player, option, teamId, count)
  475. count = count or 1
  476. local itemId = 0
  477. local costPerItem = 0
  478.  
  479. if option >= 1 and option <= #SHOP_ITEMS_TIER1 then
  480. itemId = SHOP_ITEMS_TIER1[option]
  481. costPerItem = 1
  482. elseif option > #SHOP_ITEMS_TIER1 and option <= (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2) then
  483. itemId = SHOP_ITEMS_TIER2[option - #SHOP_ITEMS_TIER1]
  484. costPerItem = 3
  485. elseif option > (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2) and option <= (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3) then
  486. itemId = SHOP_ITEMS_TIER3[option - (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2)]
  487. costPerItem = 10
  488. elseif option > (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3) and option <= (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3 + #SHOP_ITEMS_TIER4) then
  489. itemId = SHOP_ITEMS_TIER4[option - (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3)]
  490. costPerItem = 20
  491. elseif option > (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3 + #SHOP_ITEMS_TIER4) and option <= (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3 + #SHOP_ITEMS_TIER4 + #SHOP_ITEMS_TIER5) then
  492. itemId = SHOP_ITEMS_TIER5[option - (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3 + #SHOP_ITEMS_TIER4)]
  493. costPerItem = 75
  494. elseif option > (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3 + #SHOP_ITEMS_TIER4 + #SHOP_ITEMS_TIER5) and option <= (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3 + #SHOP_ITEMS_TIER4 + #SHOP_ITEMS_TIER5 + #SHOP_ITEMS_TIER6) then
  495. itemId = SHOP_ITEMS_TIER6[option - (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3 + #SHOP_ITEMS_TIER4 + #SHOP_ITEMS_TIER5)]
  496. costPerItem = 150
  497. end
  498.  
  499. if itemId == 0 then return false end
  500.  
  501. local totalCost = costPerItem * count
  502. -- Retail Rule: Bonus item only for Tier 1 (1 Buck) and Tier 2 (3 Bucks)
  503. local bonus = (costPerItem == 1 or costPerItem == 3) and math.floor(count / 5) or 0
  504. local totalItems = count + bonus
  505.  
  506. local currentBucks = xi.chocoboRacing.getChocobucks(player, teamId)
  507. if currentBucks < totalCost then
  508. player:printToPlayer("You do not have enough Chocobucks.", xi.msg.channel.SYSTEM_3)
  509. return false
  510. end
  511.  
  512. if player:getFreeSlotsCount() < 1 then
  513. player:printToPlayer("Your inventory is full.", xi.msg.channel.SYSTEM_3)
  514. return false
  515. end
  516.  
  517. xi.chocoboRacing.addChocobucks(player, -totalCost, teamId)
  518. player:addItem(itemId, totalItems)
  519. player:messageSpecial(zones[xi.zone.CHOCOBO_CIRCUIT].text.ITEM_OBTAINED, itemId, totalItems)
  520. return true
  521. end
  522.  
  523. -- -----------------------------------------------------------------
  524. -- Free Run & Fee Management
  525. -- -----------------------------------------------------------------
  526.  
  527. xi.chocoboRacing.getFreeRunFee = function(player)
  528. local count = player:getCharVar("CR_FREE_RUN_COUNT")
  529. local fees = { 100, 150, 200, 300, 400, 600, 800, 1000, 1500, 2000 }
  530. local fee = fees[math.min(#fees, count + 1)] or 2000
  531.  
  532. if count >= 50 then fee = 100000 end
  533. return fee
  534. end
  535.  
  536. -- -----------------------------------------------------------------
  537. -- Equipment & Saddle Effects
  538. -- -----------------------------------------------------------------
  539.  
  540. local equipmentMod = {
  541. [constants.SADDLE.LAUAN] = { skill = 5, stamina = 5 },
  542. [constants.SADDLE.ASH] = { endurance = 10, stamina = 5 },
  543. [constants.SADDLE.ELM] = { endurance = 10, stamina = 5 },
  544. [constants.SADDLE.BRASS] = { endurance = -10, speed = 15 },
  545. [constants.SADDLE.SILVER] = { endurance = -10, speed = 15 },
  546. [constants.SADDLE.MYTHRIL] = { endurance = -10, speed = 15 },
  547. [constants.SADDLE.GRASS] = { speed = 20, endurance = 10 },
  548. [constants.SADDLE.COTTON] = { speed = 20, endurance = 10 },
  549. [constants.SADDLE.LINEN] = { speed = 20, endurance = 10 },
  550. [constants.SADDLE.RABBIT_HIDE] = { speed = 20, endurance = 10 },
  551. [constants.SADDLE.SHEEP_LEATHER] = { speed = 20, endurance = 10 },
  552. [constants.SADDLE.BUFFALO_LEATHER] = { speed = 20, endurance = 10 },
  553.  
  554. [constants.EQUIP.CHOCOBO_TAPING] = { speed = 10 },
  555. [constants.EQUIP.CHOCOBO_BLINKERS] = { stamina = 10 },
  556. [constants.EQUIP.SHADOW_ROLL] = { skill = 10 },
  557. [constants.EQUIP.CHOCOBO_HOOD] = { skill = 5, stamina = 5 },
  558. }
  559.  
  560. xi.chocoboRacing.applyEffects = function(choco, equipId)
  561. local mod = equipmentMod[equipId]
  562. if mod then
  563. if mod.speed then choco.speed = math.max(1, math.min(255, choco.speed + mod.speed)) end
  564. if mod.stamina then choco.stamina = math.max(1, math.min(255, choco.stamina + mod.stamina)) end
  565. if mod.skill then choco.skill = math.max(1, math.min(255, choco.skill + mod.skill)) end
  566. if mod.endurance then
  567. choco.stamina = math.max(1, math.min(255, choco.stamina + mod.endurance))
  568. end
  569. end
  570. end
  571.  
  572. -- -----------------------------------------------------------------
  573. -- Player Registration Helper
  574. -- -----------------------------------------------------------------
  575.  
  576. local nameLookupCache = nil
  577. local function getChocoboNameIndex(nameStr)
  578. if not nameStr or nameStr == "" then return 0 end
  579. -- Build the reverse lookup cache once
  580. if not nameLookupCache then
  581. nameLookupCache = {}
  582. for id, str in pairs(xi.chocoboNames) do
  583. nameLookupCache[str] = id
  584. end
  585. end
  586. return nameLookupCache[nameStr] or 0
  587. end
  588.  
  589. xi.chocoboRacing.getStatsForRacing = function(player)
  590. if not xi.chocoboRaising then return nil end
  591. local choco = xi.chocoboRaising.initChocoboData(player)
  592. if not choco then return nil end
  593.  
  594. -- char_chocobos stores names as strings. Packets require uint16 IDs.
  595. -- We reverse-lookup the strings against chocobo_names.lua to get the IDs.
  596. local name1Id = getChocoboNameIndex(choco.first_name or choco.name1)
  597. local name2Id = getChocoboNameIndex(choco.last_name or choco.name2)
  598.  
  599. return {
  600. name1 = name1Id,
  601. name2 = name2Id,
  602. speed = choco.strength or 32,
  603. stamina = choco.endurance or 32,
  604. skill = choco.discernment or 32,
  605. receptivity = choco.receptivity or 32,
  606. race = player:getRace(),
  607. color = choco.color or 0,
  608. silks = 0,
  609. saddle = choco.saddle or 0,
  610. item = 0,
  611. }
  612. end
  613.  
  614. xi.chocoboRacing.registerPlayer = function(player)
  615. local stats = xi.chocoboRacing.getStatsForRacing(player)
  616. if stats then
  617. stats.playerID = player:getID()
  618. -- Keep in memory for the active race burst building
  619. xi.chocoboRacing.registeredPlayers[player:getID()] = stats
  620.  
  621. -- Persist snapshot to SQL
  622. local dispName = resolveChocoboName(stats.name1, stats.name2)
  623. local query = string.format([[
  624. INSERT INTO chocobo_race_registration
  625. (charid, registered_at, speed, stamina, skill, receptivity, chocobo_name, color, silks, saddle)
  626. VALUES (%d, %d, %d, %d, %d, %d, '%s', %d, %d, %d)
  627. ON DUPLICATE KEY UPDATE
  628. registered_at = VALUES(registered_at), speed = VALUES(speed), stamina = VALUES(stamina),
  629. skill = VALUES(skill), receptivity = VALUES(receptivity), chocobo_name = VALUES(chocobo_name),
  630. color = VALUES(color), silks = VALUES(silks), saddle = VALUES(saddle)
  631. ]],
  632. player:getID(), os.time(), stats.speed, stats.stamina, stats.skill, stats.receptivity,
  633. sql:escape(dispName), stats.color, stats.silks, stats.saddle)
  634.  
  635. sql:query(query)
  636. return true
  637. end
  638.  
  639. player:printToPlayer("You must have a raised chocobo to register for official races.", xi.msg.channel.SYSTEM_3)
  640. return false
  641. end
  642. -- Schedule a race to start in the near future (e.g. for player-entered races)
  643. xi.chocoboRacing.scheduleRaceStart = function(player, category)
  644. -- This sets a global request to transition the state machine to PREPARE
  645. -- in the next available window, or manually overrides the clock.
  646. -- For now, we'll just flag the next race to be of this category.
  647. player:setLocalVar("CR_CATEGORY", category)
  648. xi.chocoboRacing.registerPlayer(player)
  649. logInfo(string.format('Player %s scheduled Category %d race.', player:getName(), category))
  650. end
  651.  
  652. -- -----------------------------------------------------------------
  653. -- Visual Generation Helpers
  654. -- -----------------------------------------------------------------
  655.  
  656. local function getRandomColor()
  657. -- Stick to the 9 colors confirmed by the official reference (0x0-0x8).
  658. -- Values 0+1=Yellow, 2+3=Black, 4+5=Blue, 6+7=Red, 8=Green.
  659. -- Skipping 0x8 variants (9-15) since they are unconfirmed and may render
  660. -- as default yellow on some clients.
  661. local colors = { 0, 1, 2, 3, 4, 5, 6, 7, 8 }
  662. return colors[math.random(#colors)]
  663. end
  664.  
  665. local function getRandomSilks()
  666. local silks = { 0, 64, 96, 128, 160, 192, 224, 255 }
  667. return silks[math.random(#silks)]
  668. end
  669.  
  670. local function getRandomSaddle()
  671. local saddles = { 0, 5, 26, 36, 50, 63, 64, 95, 96, 128, 129, 136, 160 }
  672. return saddles[math.random(#saddles)]
  673. end
  674.  
  675. local function getRandomItem()
  676. -- Use defined EQUIP constants only — raw small even numbers are undefined and cause visual glitches.
  677. local items = {
  678. 0, -- No item
  679. constants.EQUIP.CHOCOBO_TAPING, -- 20: Strength+
  680. constants.EQUIP.CHOCOBO_BLINKERS, -- 21: Stamina+
  681. constants.EQUIP.SHADOW_ROLL, -- 22: Discernment+
  682. constants.EQUIP.CHOCOBO_HOOD, -- 23: Receptivity+
  683. }
  684. return items[math.random(#items)]
  685. end
  686.  
  687. local State = {
  688. IDLE = 0,
  689. PREPARE = 1,
  690. RACING = 2,
  691. FINISHED = 3,
  692. }
  693. xi.chocoboRacing.State = State
  694.  
  695. -- Helper to construct a hex-string look_t for insertDynamicEntity
  696. local function makeMountLook(r)
  697. -- size 7 = MODEL_CHOCOBO
  698. -- color/saddle/item mapped to head/body/hands slots
  699. return string.format("07000000%04x%04x%04x00000000000000000000",
  700. r.color or 0, r.saddle or 0, r.item or 0)
  701. end
  702.  
  703. local function makeJockeyLook(r)
  704. -- size 1 = MODEL_EQUIPPED (Standard Human)
  705. -- Map race (often 0-15 in packet data) cleanly to 1-8 valid player races to prevent 'All Galka' bug
  706. local modelId = 1
  707. if r and r.race then
  708. modelId = ((r.race - 1) % 8) + 1
  709. end
  710. -- string format is 0100 -> Type 1, followed by size/model bytes.
  711. return string.format("0100%02x000000%04x000000000000000000000000",
  712. modelId, r and r.silks or 0)
  713. end
  714.  
  715. local function spawnVisualRacers(zone, raceData)
  716. if not raceData or not raceData.racers then return end
  717. for lane = 1, 8 do
  718. local r = raceData.racers[lane]
  719. if r then
  720. local x = TRACK_WAYPOINTS[1].x
  721. local y = TRACK_WAYPOINTS[1].y
  722. local z = TRACK_WAYPOINTS[1].z + (lane - 1) * 2.5
  723.  
  724. -- Spawn Mount (Chocobo)
  725. local mount = zone:insertDynamicEntity({
  726. objtype = 2, -- TYPE_NPC
  727. name = string.format("Mount_%d", lane),
  728. packetName = r.dispName or "Chocobo",
  729. look = makeMountLook(r),
  730. x = x, y = y, z = z,
  731. rotation = 64,
  732. releaseIdOnDisappear = true
  733. })
  734. if mount then
  735. r.mountId = mount:getID()
  736. r.mountRef = mount -- Cache entity reference (avoids GetNPCByID per tick)
  737. end
  738.  
  739. -- Spawn Jockey
  740. local jockey = zone:insertDynamicEntity({
  741. objtype = 2, -- TYPE_NPC
  742. name = string.format("Jockey_%d", lane),
  743. look = makeJockeyLook(r),
  744. x = x, y = y, z = z,
  745. rotation = 64,
  746. releaseIdOnDisappear = true
  747. })
  748. if jockey then
  749. r.jockeyId = jockey:getID()
  750. r.jockeyRef = jockey -- Cache entity reference
  751. end
  752. end
  753. end
  754. end
  755.  
  756. local function despawnVisualRacers(raceData)
  757. if not raceData or not raceData.racers then return end
  758. for lane = 1, 8 do
  759. local r = raceData.racers[lane]
  760. if r then
  761. local mount = GetNPCByID(r.mountId)
  762. if mount then mount:setStatus(2) end -- DISAPPEAR (triggered by m_bReleaseTargIDOnDisappear)
  763.  
  764. local jockey = GetNPCByID(r.jockeyId)
  765. if jockey then jockey:setStatus(2) end
  766. end
  767. end
  768. end
  769.  
  770. -- Dummy NPC IDs for supplementary gate/mount entities.
  771. -- The 0x069 system works WITHOUT these. If IDs don't exist in npc_list,
  772. -- GetNPCByID returns nil and setPos calls are silently skipped.
  773. -- We now overwrite these with dynamic IDs from insertDynamicEntity.
  774. local JOCKEY_BASE_ID = 0x01000010
  775. local CHOCOBO_BASE_ID = 0x0100001B
  776.  
  777. -- Track waypoints used only for supplementary NPC setPos() movement.
  778. -- The client uses Mode 3 SectionParams for its own race rendering.
  779. -- Currently using static waypoints for FFXI zone 70.
  780. --
  781. -- These 9 points trace a full oval lap. Point [9] closes the loop back
  782. -- to the finish gate (same location as [1]) so that progress=100% places
  783. -- entities exactly at the finish line rather than stranded on the left side.
  784. -- Adjust all X/Z values once real navmesh coordinates are confirmed.
  785. local TRACK_WAYPOINTS = {
  786. { x = -50.0, y = -5.0, z = -100.0 }, -- [1] START / FINISH GATE
  787. { x = 50.0, y = -5.0, z = -100.0 }, -- [2] top-right
  788. { x = 100.0, y = -5.0, z = -50.0 }, -- [3] right-upper
  789. { x = 100.0, y = -5.0, z = 50.0 }, -- [4] right-lower
  790. { x = 50.0, y = -5.0, z = 100.0 }, -- [5] bottom-right
  791. { x = -50.0, y = -5.0, z = 100.0 }, -- [6] bottom-left
  792. { x = -100.0, y = -5.0, z = 50.0 }, -- [7] left-lower
  793. { x = -100.0, y = -5.0, z = -50.0 }, -- [8] left-upper
  794. { x = -50.0, y = -5.0, z = -100.0 }, -- [9] FINISH (same as [1], closes loop)
  795. }
  796. local TRACK_LENGTH = 1000
  797.  
  798. -----------------------------------
  799. -- Module-Level State
  800. -----------------------------------
  801. local moduleState = {
  802. raceState = State.IDLE,
  803. raceTick = 0,
  804. prepareTime = 0,
  805. finishTime = 0,
  806. raceCounter = 0x28, -- Sequential race number (retail: varies per race, starts ~40)
  807. }
  808.  
  809. xi.chocoboRacing.activeBets = {}
  810. xi.chocoboRacing.registeredPlayers = {}
  811. xi.chocoboRacing.currentRaceData = nil
  812.  
  813. -- Load named rivals from DB at require() time (server startup).
  814. -- The function is defined later in this file but Lua resolves it at call time,
  815. -- so we defer via a small shim. buildRaceField also calls it lazily as a safety net.
  816. xi.chocoboRacing._initRivals = function()
  817. if type(xi.chocoboRacing.loadNpcRivals) == 'function' then
  818. xi.chocoboRacing.loadNpcRivals()
  819. end
  820. end
  821.  
  822. -- Resolve name1/name2 indices into a display name string
  823. -- using the chocobo_names table from chocobo_names.lua
  824. local function resolveChocoboName(name1, name2)
  825. local part1 = xi.chocoboNames and xi.chocoboNames[name1] or nil
  826. local part2 = xi.chocoboNames and xi.chocoboNames[name2] or nil
  827. if part1 and part2 and name2 ~= 0 then
  828. return part1 .. part2
  829. elseif part1 then
  830. return part1
  831. end
  832. return 'Chocobo'
  833. end
  834.  
  835. -----------------------------------
  836. -- Procedural Racer Generation
  837. --
  838. -- Instead of a fixed pool that repeats the same faces every race, each race
  839. -- generates 8 unique entrants on the fly. Names are drawn from the full
  840. -- xi.chocoboNames index space (700+ entries), giving thousands of unique
  841. -- combinations. Stats are randomised within per-category bands so that
  842. -- tier difficulty is preserved without identical competitors appearing again.
  843. --
  844. -- "Named rivals" are famous NPC chocobos that can make a guest appearance
  845. -- in any race of the appropriate category. Each rival has a fixed ~15% chance
  846. -- to enter a given race (checked once per rival per race). At most 3 named
  847. -- rivals will ever appear in a single race to leave room for fresh faces.
  848. -----------------------------------
  849.  
  850. -- Category stat bands: { speed_min, speed_max, stamina_min, stamina_max, skill_min, skill_max }
  851. local CATEGORY_STATS = {
  852. [constants.CATEGORY.C4_REGULAR] = { 28, 52, 24, 48, 24, 48 },
  853. [constants.CATEGORY.C3_REGULAR] = { 60, 95, 55, 85, 55, 90 },
  854. [constants.CATEGORY.C2_REGULAR] = { 140, 200, 110, 170, 130, 190 },
  855. [constants.CATEGORY.C1_CRYSTAL_STAKES] = { 190, 255, 150, 255, 180, 255 },
  856. }
  857.  
  858. -- Map the 0x80000000-style category constants to the 0-3 tier index stored in the DB.
  859. local CATEGORY_TIER = {
  860. [constants.CATEGORY.C4_REGULAR] = 0,
  861. [constants.CATEGORY.C3_REGULAR] = 1,
  862. [constants.CATEGORY.C2_REGULAR] = 2,
  863. [constants.CATEGORY.C1_CRYSTAL_STAKES] = 3,
  864. }
  865.  
  866. local NPC_POOLS = {
  867. [constants.CATEGORY.C4_REGULAR] = {
  868. { name1 = 12, name2 = 0, race = 1, color = 0, silks = 0, saddle = 0, speed = 32, stamina = 32, skill = 32, SPAWN_CHANCE = 15, min_tier = 0, max_tier = 3},
  869. { name1 = 20, name2 = 21, race = 2, color = 2, silks = 64, saddle = 5, speed = 34, stamina = 30, skill = 35, SPAWN_CHANCE = 15, min_tier = 0, max_tier = 3},
  870. { name1 = 23, name2 = 48, race = 3, color = 4, silks = 96, saddle = 26, speed = 30, stamina = 36, skill = 28, SPAWN_CHANCE = 15, min_tier = 0, max_tier = 3},
  871. { name1 = 30, name2 = 0, race = 4, color = 6, silks = 128, saddle = 36, speed = 38, stamina = 28, skill = 33, SPAWN_CHANCE = 15, min_tier = 0, max_tier = 3},
  872. { name1 = 36, name2 = 146, race = 5, color = 8, silks = 160, saddle = 0, speed = 35, stamina = 35, skill = 35, SPAWN_CHANCE = 15, min_tier = 0, max_tier = 3},
  873. { name1 = 49, name2 = 47, race = 6, color = 7, silks = 192, saddle = 50, speed = 32, stamina = 40, skill = 30, SPAWN_CHANCE = 15, min_tier = 0, max_tier = 3},
  874. { name1 = 107, name2 = 165, race = 7, color = 10, silks = 224, saddle = 63, speed = 40, stamina = 30, skill = 35, SPAWN_CHANCE = 15, min_tier = 0, max_tier = 3},
  875. { name1 = 121, name2 = 0, race = 8, color = 11, silks = 0, saddle = 0, speed = 32, stamina = 42, skill = 28, SPAWN_CHANCE = 15, min_tier = 0, max_tier = 3},
  876. },
  877. [constants.CATEGORY.C3_REGULAR] = {
  878. { name1 = 194, name2 = 247, race = 1, color = 0, silks = 64, saddle = 36, speed = 70, stamina = 60, skill = 75, SPAWN_CHANCE = 15, min_tier = 1, max_tier = 3},
  879. { name1 = 200, name2 = 213, race = 2, color = 2, silks = 128, saddle = 50, speed = 75, stamina = 65, skill = 70, SPAWN_CHANCE = 15, min_tier = 1, max_tier = 3},
  880. { name1 = 244, name2 = 321, race = 3, color = 8, silks = 96, saddle = 63, speed = 65, stamina = 80, skill = 60, SPAWN_CHANCE = 15, min_tier = 1, max_tier = 3},
  881. { name1 = 266, name2 = 241, race = 4, color = 0, silks = 160, saddle = 64, speed = 80, stamina = 60, skill = 85, SPAWN_CHANCE = 15, min_tier = 1, max_tier = 3},
  882. { name1 = 271, name2 = 155, race = 5, color = 4, silks = 192, saddle = 95, speed = 72, stamina = 70, skill = 72, SPAWN_CHANCE = 15, min_tier = 1, max_tier = 3},
  883. { name1 = 278, name2 = 319, race = 6, color = 6, silks = 224, saddle = 96, speed = 75, stamina = 75, skill = 75, SPAWN_CHANCE = 15, min_tier = 1, max_tier = 3},
  884. },
  885. [constants.CATEGORY.C1_CRYSTAL_STAKES] = {
  886. { name1 = 612, name2 = 0, dispName = "Musashi", race = 1, color = 8, silks = 0, saddle = 50, item = 50, speed = 192, stamina = 150, skill = 180, SPAWN_CHANCE = 15, min_tier = 2, max_tier = 3},
  887. { name1 = 153, name2 = 0, dispName = "StarOnion", race = 2, color = 0, silks = 64, saddle = 63, item = 63, speed = 210, stamina = 120, skill = 200, SPAWN_CHANCE = 15, min_tier = 2, max_tier = 3},
  888. { name1 = 18, name2 = 0, dispName = "Bel", race = 3, color = 0, silks = 128, saddle = 95, item = 5, speed = 255, stamina = 255, skill = 128, SPAWN_CHANCE = 15, min_tier = 2, max_tier = 3},
  889. { name1 = 258, name2 = 0, dispName = "Jolie", race = 4, color = 6, silks = 192, saddle = 96, item = 50, speed = 210, stamina = 210, skill = 210, SPAWN_CHANCE = 15, min_tier = 2, max_tier = 3},
  890. { name1 = 443, name2 = 233, dispName = "LegendFlyer", race = 5, color = 2, silks = 224, saddle = 128, item = 64, speed = 255, stamina = 180, skill = 255, SPAWN_CHANCE = 15, min_tier = 2, max_tier = 3},
  891. { name1 = 475, name2 = 323, dispName = "RaidenSturm", race = 6, color = 4, silks = 0, saddle = 129, item = 95, speed = 230, stamina = 220, skill = 240, SPAWN_CHANCE = 15, min_tier = 2, max_tier = 3},
  892. }
  893. }
  894. NPC_POOLS[constants.CATEGORY.C2_REGULAR] = NPC_POOLS[constants.CATEGORY.C1_CRYSTAL_STAKES]
  895.  
  896. local npcRivalCache = nil
  897.  
  898. -- Emulation Fallback: Reconstruct a flattened cache purely from the NPC_POOLS Lua table.
  899. -- (Removed fatal sql:query which caused lua crashes on startRace).
  900. xi.chocoboRacing.loadNpcRivals = function()
  901. npcRivalCache = {}
  902. for catId, pool in pairs(NPC_POOLS) do
  903. for _, r in ipairs(pool) do
  904. table.insert(npcRivalCache, {
  905. name1 = r.name1 or 0,
  906. name2 = r.name2 or 0,
  907. dispName = r.dispName or nil,
  908. min_tier = r.min_tier or 0,
  909. max_tier = r.max_tier or 3,
  910. SPAWN_CHANCE = r.SPAWN_CHANCE or 15,
  911. speed = r.speed or 128,
  912. stamina = r.stamina or 128,
  913. skill = r.skill or 128,
  914. receptivity = r.receptivity or 128,
  915. race = r.race or 1,
  916. color = r.color or 0,
  917. silks = r.silks or 0,
  918. saddle = r.saddle or 0,
  919. item = r.item or 0,
  920. })
  921. end
  922. end
  923. end
  924.  
  925. -- Actual max index in xi.chocoboNames (chocobo_names.lua, verified: entries 0-830
  926. -- with gaps at 38, 503, 530, 730). Gives 827 valid parts × 826 non-zero suffixes
  927. -- = 683,000+ unique two-word combinations, plus 827 single-word names.
  928. local CHOCOBO_NAME_MAX = 830
  929.  
  930. -- Valid name indices — gaps excluded so random picks never hit a nil slot.
  931. -- Stored as a flat array for O(1) random access.
  932. local CHOCOBO_NAME_INDICES = (function()
  933. local t = {}
  934. local gaps = { [38]=true, [503]=true, [530]=true, [730]=true }
  935. for i = 0, CHOCOBO_NAME_MAX do
  936. if not gaps[i] then t[#t+1] = i end
  937. end
  938. return t
  939. end)()
  940.  
  941. -- Pick a random name1/name2 pair, skipping known gaps.
  942. local function randomNamePair()
  943. local name1 = CHOCOBO_NAME_INDICES[math.random(#CHOCOBO_NAME_INDICES)]
  944. -- ~40% chance of a compound name, otherwise single-word (name2=0)
  945. local name2 = 0
  946. if math.random(100) <= 40 then
  947. name2 = CHOCOBO_NAME_INDICES[math.random(#CHOCOBO_NAME_INDICES)]
  948. if name2 == 0 then name2 = 1 end -- avoid index-0 as suffix (renders as bare 'G')
  949. end
  950. return name1, name2
  951. end
  952.  
  953. -- Generate one procedural racer for the given category stat band.
  954. local function generateRacer(category)
  955. local band = CATEGORY_STATS[category] or CATEGORY_STATS[constants.CATEGORY.C4_REGULAR]
  956. local spdMin, spdMax = band[1], band[2]
  957. local staMin, staMax = band[3], band[4]
  958. local sklMin, sklMax = band[5], band[6]
  959.  
  960. local name1, name2 = randomNamePair()
  961. return {
  962. name1 = name1,
  963. name2 = name2,
  964. speed = math.random(spdMin, spdMax),
  965. stamina = math.random(staMin, staMax),
  966. skill = math.random(sklMin, sklMax),
  967. receptivity = math.random(80, 180),
  968. race = math.random(0, 15),
  969. color = getRandomColor(),
  970. silks = getRandomSilks(),
  971. saddle = getRandomSaddle(),
  972. item = getRandomItem(),
  973. }
  974. end
  975.  
  976. -- Build the 8-racer field for a race: roll named rivals first (capped at 3),
  977. -- then fill remaining slots with freshly generated procedural racers.
  978. -- Returns a list of exactly 8 rData tables, already in randomised lane order.
  979. local function buildRaceField(category, registeredPlayers)
  980. -- Ensure rivals are loaded (no-op after first call)
  981. if npcRivalCache == nil then
  982. xi.chocoboRacing.loadNpcRivals()
  983. end
  984.  
  985. local field = {}
  986.  
  987. -- 1. Registered players always enter
  988. for _, stats in ipairs(registeredPlayers) do
  989. table.insert(field, stats)
  990. if #field >= 8 then break end
  991. end
  992.  
  993. -- 2. Roll each eligible named rival (tier must include this race's category)
  994. local tier = CATEGORY_TIER[category] or 0
  995. -- Work from a shuffled copy so the 3-rival cap doesn't always favour lower rival_ids
  996. local eligible = {}
  997. for _, rival in ipairs(npcRivalCache) do
  998. if tier >= rival.min_tier and tier <= rival.max_tier then
  999. eligible[#eligible + 1] = rival
  1000. end
  1001. end
  1002. for i = #eligible, 2, -1 do
  1003. local j = math.random(1, i)
  1004. eligible[i], eligible[j] = eligible[j], eligible[i]
  1005. end
  1006.  
  1007. local namedCount = 0
  1008. for _, rival in ipairs(eligible) do
  1009. if #field >= 8 then break end
  1010. if namedCount < 3 and math.random(100) <= rival.SPAWN_CHANCE then
  1011. table.insert(field, rival)
  1012. namedCount = namedCount + 1
  1013. end
  1014. end
  1015.  
  1016. -- 3. Fill remaining slots with procedurally generated racers
  1017. while #field < 8 do
  1018. table.insert(field, generateRacer(category))
  1019. end
  1020.  
  1021. -- 4. Shuffle the full field so named rivals don't cluster at the front
  1022. for i = #field, 2, -1 do
  1023. local j = math.random(1, i)
  1024. field[i], field[j] = field[j], field[i]
  1025. end
  1026.  
  1027. return field
  1028. end
  1029.  
  1030. -----------------------------------
  1031. -- Race Scheduling (Retail-Compliant)
  1032. --
  1033. -- SCHEDULE LOGIC:
  1034. -- Retail races start every 15 minutes Earth time (XX:00, XX:15, XX:30, XX:45).
  1035. -- A retail racing season operates on a rotating month schema based on Earth weeks.
  1036. -- Week 1 = Month 1, Week 2 = Month 2, Week 3 = Month 3, Week 4 = Month 4.
  1037. -----------------------------------
  1038. local RACE_SCHEDULE = {
  1039. -- Month 1 / Week 1: Standard / Elite Mix
  1040. {
  1041. constants.CATEGORY.C1_CRYSTAL_STAKES, -- 00:00 (Elite)
  1042. constants.CATEGORY.C4_REGULAR, -- 00:15 (Beginner)
  1043. constants.CATEGORY.C3_REGULAR, -- 00:30 (Standard)
  1044. constants.CATEGORY.C3_REGULAR, -- 00:45 (Standard)
  1045. constants.CATEGORY.C2_REGULAR, -- 01:00 (Expert)
  1046. constants.CATEGORY.C4_REGULAR, -- 01:15 (Beginner)
  1047. constants.CATEGORY.C3_REGULAR, -- 01:30 (Standard)
  1048. constants.CATEGORY.C2_REGULAR, -- 01:45 (Expert)
  1049. },
  1050. -- Month 2 / Week 2: Beginner / Standard Heavy
  1051. {
  1052. constants.CATEGORY.C3_REGULAR, -- 00:00 (Standard)
  1053. constants.CATEGORY.C4_REGULAR, -- 00:15 (Beginner)
  1054. constants.CATEGORY.C4_REGULAR, -- 00:30 (Beginner)
  1055. constants.CATEGORY.C3_REGULAR, -- 00:45 (Standard)
  1056. constants.CATEGORY.C2_REGULAR, -- 01:00 (Expert)
  1057. constants.CATEGORY.C4_REGULAR, -- 01:15 (Beginner)
  1058. constants.CATEGORY.C4_REGULAR, -- 01:30 (Beginner)
  1059. constants.CATEGORY.C2_REGULAR, -- 01:45 (Expert)
  1060. },
  1061. -- Month 3 / Week 3: Expert Heavy
  1062. {
  1063. constants.CATEGORY.C2_REGULAR, -- 00:00 (Expert)
  1064. constants.CATEGORY.C3_REGULAR, -- 00:15 (Standard)
  1065. constants.CATEGORY.C2_REGULAR, -- 00:30 (Expert)
  1066. constants.CATEGORY.C3_REGULAR, -- 00:45 (Standard)
  1067. constants.CATEGORY.C1_CRYSTAL_STAKES, -- 01:00 (Elite)
  1068. constants.CATEGORY.C3_REGULAR, -- 01:15 (Standard)
  1069. constants.CATEGORY.C2_REGULAR, -- 01:30 (Expert)
  1070. constants.CATEGORY.C1_CRYSTAL_STAKES, -- 01:45 (Elite)
  1071. },
  1072. -- Month 4 / Week 4: Championship Season (Elite / Expert)
  1073. {
  1074. constants.CATEGORY.C1_CRYSTAL_STAKES, -- 00:00 (Elite)
  1075. constants.CATEGORY.C2_REGULAR, -- 00:15 (Expert)
  1076. constants.CATEGORY.C1_CRYSTAL_STAKES, -- 00:30 (Elite)
  1077. constants.CATEGORY.C2_REGULAR, -- 00:45 (Expert)
  1078. constants.CATEGORY.C1_CRYSTAL_STAKES, -- 01:00 (Elite)
  1079. constants.CATEGORY.C2_REGULAR, -- 01:15 (Expert)
  1080. constants.CATEGORY.C1_CRYSTAL_STAKES, -- 01:30 (Elite)
  1081. constants.CATEGORY.C1_CRYSTAL_STAKES, -- 01:45 (Elite)
  1082. }
  1083. }
  1084. local SLOT_SECONDS = 900 -- 15-minute race cycle (60 * 15)
  1085. local PREPARE_HOLD_SEC = 35 -- Increased to allow the 30-sec pre-race dialog
  1086. local FINISH_HOLD_SEC = 40 -- Sufficient time for post-race results dialog
  1087.  
  1088. -----------------------------------
  1089. -- Logging System
  1090. -- All output goes to map server console via print().
  1091. -- Prefix: [ChocoboRacing] for easy grep filtering.
  1092. -- Filter with: Select-String -Path map_server.log -Pattern 'ChocoboRacing'
  1093. -----------------------------------
  1094. local LOG_LEVEL = 3 -- 0=OFF, 1=ERROR, 2=INFO, 3=DEBUG
  1095.  
  1096. local function raceLog(level, ...)
  1097. if level > LOG_LEVEL then return end
  1098.  
  1099. local tags = { [1] = 'ERROR', [2] = 'INFO ', [3] = 'DEBUG' }
  1100. local tag = tags[level] or 'INFO '
  1101.  
  1102. local args = {...}
  1103. local parts = {}
  1104. for i = 1, #args do
  1105. parts[i] = tostring(args[i])
  1106. end
  1107. local msg = table.concat(parts, ' ')
  1108.  
  1109. print('[ChocoboRacing][' .. tag .. '] ' .. msg)
  1110. end
  1111.  
  1112. -- Convenience wrappers
  1113. local function logError(...) raceLog(1, ...) end
  1114. local function logInfo(...) raceLog(2, ...) end
  1115. local function logDebug(...) raceLog(3, ...) end
  1116.  
  1117. -- Player-facing debug (prints to player chat + console)
  1118. local debug = function(player, ...)
  1119. logDebug(...)
  1120. if player and xi.settings and xi.settings.main and xi.settings.main.DEBUG_CHOCOBO_RACING then
  1121. local t = { ... }
  1122. player:printToPlayer(table.concat(t, ' '), xi.msg.channel.SYSTEM_3, '')
  1123. end
  1124. end
  1125.  
  1126. -- Dump full racer details for a race
  1127. local function logRacerTable(racers)
  1128. logDebug('=== Racer Details ===')
  1129. for lane = 1, 8 do
  1130. local r = racers[lane]
  1131. if r then
  1132. logDebug(string.format(
  1133. ' Lane %d: %-15s | SPD:%3d STA:%3d SKL:%3d | Race:%2d Color:%2d Silks:%3d Saddle:%3d Item:%3d | name1:%d name2:%d',
  1134. lane, r.dispName or '???',
  1135. r.speed or 0, r.stamina or 0, r.skill or 0,
  1136. r.race or 0, r.color or 0, r.silks or 0, r.saddle or 0, r.item or 0,
  1137. r.name1 or 0, r.name2 or 0
  1138. ))
  1139. end
  1140. end
  1141. logDebug('=====================')
  1142. end
  1143.  
  1144. -----------------------------------
  1145. -- Supplementary NPC Entity Update Timer
  1146. -----------------------------------
  1147. local setTimer = function(player, npcId)
  1148. end
  1149.  
  1150. local function build0x00E(npc)
  1151. if not npc then return nil end
  1152. local packet = { 0x0E, 0x28, 0x00, 0x00 }
  1153.  
  1154. local id = npc:getID()
  1155. packet[5] = bit.band(id, 0xFF)
  1156. packet[6] = bit.band(bit.rshift(id, 8), 0xFF)
  1157. packet[7] = bit.band(bit.rshift(id, 16), 0xFF)
  1158. packet[8] = bit.band(bit.rshift(id, 24), 0xFF)
  1159.  
  1160. local targid = npc:getTargID()
  1161. packet[9] = bit.band(targid, 0xFF)
  1162. packet[10] = bit.band(bit.rshift(targid, 8), 0xFF)
  1163.  
  1164. -- Pad remaining empty space (80 bytes total for 0x50 packet)
  1165. for i = 11, 80 do packet[i] = 0 end
  1166.  
  1167. -- Specific flags reverse-engineered from retail observation
  1168. packet[11] = 0x20
  1169. packet[49] = 0x01
  1170.  
  1171. return packet
  1172. end
  1173.  
  1174. setTimer = function(player, npcId)
  1175. player:timer(400, function(playerArg)
  1176. local npc = GetNPCByID(npcId)
  1177. if npc then
  1178. local packet = build0x00E(npc)
  1179. if packet then
  1180. playerArg:sendDebugPacket(packet)
  1181. end
  1182. end
  1183. setTimer(playerArg, npcId)
  1184. end)
  1185. end
  1186.  
  1187. -----------------------------------
  1188. -- 0x069 Packet Builders (Dynamic)
  1189. --
  1190. -- 0x069 is always 200 bytes (0x00C8).
  1191. -- Raw header: 0x69 0x64 [sync_lo] [sync_hi]
  1192. -- Byte [4]: Mode
  1193. --
  1194. -- Per XiPackets README and analysis of 18 retail captures:
  1195. -- Mode 1: Sets RaceParams (race number + category). padding/junk after.
  1196. -- Mode 2: Sets ChocoboParams (ParamIndex=0, ParamSize=0x60, 12 bytes/racer)
  1197. -- Mode 3: Sets SectionParams (two packets: ParamIndex 0x00 and 0x10)
  1198. -- Mode 4: Sets ResultParams (finish order nibbles) + optional SectionParams
  1199. -- Mode 5: Sets DownloadFlg=1 (system ready, rest of packet ignored)
  1200. --
  1201. --
  1202. -- IMPORTANT: Chocobo DISPLAY NAMES are NOT carried by 0x069.
  1203. -- They are sent separately via 0x05D (GP_SERV_COMMAND_EVENT_UPDATE_STRING)
  1204. -- as event string parameters (string1-string4, 4 names per packet,
  1205. -- two 0x05D packets per race = 8 names for 8 lanes).
  1206. -- See onEventUpdate() handler below where updateEventString() is called.
  1207. --
  1208. -- 🛑 THIS IS EMULATION 🛑
  1209. -- Do not 'fix' or 'optimize' packet bytes if they seem weird.
  1210. -- If retail sent a flat 0x08 for a byte, you send 0x08.
  1211. -----------------------------------
  1212.  
  1213. local PACKET_69_SIZE = 200
  1214.  
  1215. local function padTo200(bytes)
  1216. for i = #bytes + 1, PACKET_69_SIZE do
  1217. bytes[i] = 0
  1218. end
  1219. return bytes
  1220. end
  1221.  
  1222. -- Mode 1: RacingParams
  1223. -- Per XiPackets README: padding00[3] after Mode byte are unused by client.
  1224. -- Race number is a sequential counter (retail: varies per race, 0x28→0xC8 etc.)
  1225. local function buildMode1(raceData)
  1226. local category = raceData.category
  1227. local raceNumber = raceData.raceNum or moduleState.raceCounter
  1228. return padTo200({
  1229. 0x69, 0x64, 0x00, 0x00, -- header + sync
  1230. 0x01, 0x00, -- Mode=1, padding
  1231. 0x08, 0x01, -- padding00[3] (values vary in captures, unused)
  1232. -- RaceParams[0] = Race Number (Little Endian)
  1233. bit.band(raceNumber, 0xFF),
  1234. bit.band(bit.rshift(raceNumber, 8), 0xFF),
  1235. bit.band(bit.rshift(raceNumber, 16), 0xFF),
  1236. bit.band(bit.rshift(raceNumber, 24), 0xFF),
  1237. -- RaceParams[1] = Category (Little Endian)
  1238. bit.band(category, 0xFF),
  1239. bit.band(bit.rshift(category, 8), 0xFF),
  1240. bit.band(bit.rshift(category, 16), 0xFF),
  1241. bit.band(bit.rshift(category, 24), 0xFF),
  1242. raceData.weather or 0,
  1243. raceData.condition or 0,
  1244. 0x00, 0x00,
  1245. })
  1246. end
  1247.  
  1248. -- Mode 2: ChocoboParams (Stats & Visuals)
  1249. -- Per XiPackets README (Modes 2 & 3 share layout):
  1250. -- Byte [5] = ParamIndex, [6] = ParamSize, [7] = padding (unused by client)
  1251. -- Confirmed 12-byte block layout per racer from captures:
  1252. -- Bytes 0-3: Name indices (2x uint16 LE)
  1253. -- Bytes 4-7: Stats — retail stat byte patterns observed across captures:
  1254. -- 0xE0 0xC0 0x60 0x80 (high speed, high stamina, moderate skill)
  1255. -- 0xFF 0xFF 0x40 0x40 (max speed/stam, low skill — risky finisher)
  1256. -- 0xFF 0xFF 0x80 0x00 (max speed/stam, mid skill, no receptivity)
  1257. -- 0x80 0xC0 0xA0 0xA0 (mid speed, high stam, balanced)
  1258. -- 0xC0 0x00 0xC0 0xFF (high speed, no stamina, high skill/receptivity)
  1259. -- 0x80 0x60 0xE0 0xC0 (mid speed, low stam, very high skill)
  1260. -- Bytes 8-11: Visuals (item high, item low, saddle, race+color)
  1261. local function buildMode2(racers)
  1262. local paramSize = 0x60 -- 96 = 8 racers * 12 bytes each
  1263. local bytes = {
  1264. 0x69, 0x64, 0x00, 0x00,
  1265. 0x02, -- Mode=2
  1266. 0x00, -- ParamIndex=0
  1267. paramSize, -- ParamSize=0x60
  1268. 0x00, -- padding (unused by client)
  1269. -- 4-byte sub-header gap (bytes 8-11, 0-indexed).
  1270. -- Confirmed from retail Mode 2 capture: 0x41 0x00 0x00 0x00 appear here.
  1271. -- The client expects racer blocks to begin at byte 0x0C (12), NOT 0x08.
  1272. -- Without these 4 bytes every racer block lands 4 bytes too early,
  1273. -- placing the race+color byte at block-offset +3 instead of +7,
  1274. -- which is why all jockeys rendered as Galka (race=0) in yellow (color=0).
  1275. 0x41, 0x00, 0x00, 0x00,
  1276. -- Racer data follows here: 8 blocks x 12 bytes = 96 bytes (0x60)
  1277. -- Block offsets: 0x0C, 0x18, 0x24, 0x30, 0x3C, 0x48, 0x54, 0x60
  1278. -- (matches official packet[offset + N + 1] addressing with offset in {0x0C..0x60})
  1279. }
  1280.  
  1281. for lane = 1, 8 do
  1282. local r = racers[lane]
  1283.  
  1284. -- Field order confirmed from official implementation (packet offsets match retail capture):
  1285. -- Bytes 0-3: Stats (speed, stamina, skill, receptivity)
  1286. -- Bytes 4-5: Name index (name1, uint16 LE)
  1287. -- Byte 6: Item/equip ID (consumable held by jockey)
  1288. -- Byte 7: Visual — high nibble = jockey race, low nibble = chocobo color
  1289. -- Byte 8: Silks (jockey clothing visual ID)
  1290. -- Byte 9: Saddle visual ID
  1291. -- Bytes 10-11: Padding (0x00)
  1292. --
  1293. -- IMPORTANT: name/stat order was previously swapped here, causing garbage
  1294. -- visuals and wrong stats on the client. Fixed to match official byte layout.
  1295.  
  1296. -- 1. Stats (Bytes 0-3)
  1297. bytes[#bytes + 1] = bit.band(r and r.speed or 0x0E, 0xFF)
  1298. bytes[#bytes + 1] = bit.band(r and r.stamina or 0x0C, 0xFF)
  1299. bytes[#bytes + 1] = bit.band(r and r.skill or 0x60, 0xFF)
  1300. bytes[#bytes + 1] = bit.band(r and r.receptivity or 0x80, 0xFF)
  1301.  
  1302. -- 2. Name index (Bytes 4-5: name1 uint16 LE)
  1303. local name1 = r and r.name1 or 0x00
  1304. bytes[#bytes + 1] = bit.band(name1, 0xFF)
  1305. bytes[#bytes + 1] = bit.band(bit.rshift(name1, 8), 0xFF)
  1306.  
  1307. -- 3. Item byte (Byte 6) — hardcoded 0x08 matching all retail captures.
  1308. -- Using EQUIP constant values (20-23) here caused the client to trigger
  1309. -- repeated item usage because those IDs activated in-race items.
  1310. -- The actual passive equip stat modifiers are applied in applyEffects() on
  1311. -- the server side; the packet byte should stay 0x08 like retail.
  1312. bytes[#bytes + 1] = 0x08
  1313.  
  1314. -- 4. Visual Appearance (Byte 7: high nibble = jockey race, low nibble = chocobo color)
  1315. local jockeyRace = r and r.race or math.random(0, 7)
  1316. local chocoColor = r and r.color or math.random(0, 7)
  1317. bytes[#bytes + 1] = bit.bor(bit.lshift(bit.band(jockeyRace, 0x0F), 4), bit.band(chocoColor, 0x0F))
  1318.  
  1319. -- 5. Silks (Byte 8) — Jockey clothing visual ID
  1320. -- NOTE: silks=0 is valid (plain outfit) but 0 is falsy in Lua, so "r.silks or 0x41"
  1321. -- would incorrectly substitute 0x41 for every plain-outfit racer. Explicit nil check required.
  1322. local silksVal = (r ~= nil and r.silks ~= nil) and r.silks or 0x41
  1323. bytes[#bytes + 1] = bit.band(silksVal, 0xFF)
  1324.  
  1325. -- 6. Saddle (Byte 9) — Saddle visual ID
  1326. bytes[#bytes + 1] = bit.band(r and r.saddle or 0, 0xFF)
  1327.  
  1328. -- 7. Padding (Bytes 10-11)
  1329. bytes[#bytes + 1] = 0x00
  1330. bytes[#bytes + 1] = 0x00
  1331. end
  1332.  
  1333. return padTo200(bytes)
  1334. end
  1335.  
  1336. -- Mode 3A: SectionParams block 1 (ParamIndex=0x00)
  1337. -- Keyframe timeline array for lanes 1-4. Contains track section progression.
  1338. -- ALWAYS starts with sentinel 0x88 0x88 0x88 0x88 (confirmed all 18 captures).
  1339. --
  1340. -- Structure per segment (12 bytes):
  1341. -- [0-3]: Previous lane positions (1 byte per lane, 4 lanes)
  1342. -- [4-7]: Next lane positions (1 byte per lane, 4 lanes)
  1343. -- [8-11]: Flags — encodes speed change events between prev→next
  1344. -- Confirmed flag patterns from captures:
  1345. -- 0x00 0x00 0x00 0x00 = No change (steady pace)
  1346. -- 0x80 0x80 0x00 0x03 = Major move, lane 1+2 burst (byte = 0x80 each)
  1347. -- 0x02 0x40 0x00 0x04 = Moderate overtake, lane 1 + lane 2 adjusting
  1348. -- 0x08 0x08 0x00 0x02 = Small burst, lanes 1+2
  1349. -- 0x01 0x00 0x08 0x04 = Lane 1 slight, lane 3 boost
  1350. -- 0x20 0x00 0x08 0x04 = Lane 1 strong, lane 3 boost
  1351. -- 0x04 0x30 0xCB 0x06 = Major split with acceleration flags
  1352. -- 0x40 0x12 0xAD 0x06 = Finishing sprint zone
  1353. -- Flag nibbles appear to encode: magnitude of position delta per lane.
  1354. -- When a lane's position changes between prev→next, corresponding flag
  1355. -- byte is non-zero. Higher values = bigger move.
  1356. local function buildMode3A(raceData)
  1357. local bytes = {
  1358. 0x69, 0x64, 0x00, 0x00,
  1359. 0x03, -- Mode=3
  1360. 0x00, -- ParamIndex=0x00 (first block)
  1361. 0xC0, -- ParamSize=192
  1362. 0x00, -- padding: retail capture shows 0x00, NOT 0x01
  1363. }
  1364.  
  1365. -- Compute pool median speed so drift is relative to THIS race's competitors.
  1366. -- Using a fixed midpoint of 128 made all Beginner chocobos (speed 30-46) drift
  1367. -- at exactly -2/segment — identical motion for every lane, no visible difference.
  1368. local speedSum = 0
  1369. for i = 1, 4 do
  1370. local r = raceData.racers[i]
  1371. speedSum = speedSum + (r and r.speed or 128)
  1372. end
  1373. local poolMedian = speedSum / 4
  1374.  
  1375. -- Give each lane a speed-proportional starting position (same logic as Mode3B).
  1376. -- The original fixed {0x88, 0x88, 0x88, 0x88} sentinel gave all 4 lanes an
  1377. -- identical start, so faster-than-median lanes drifted ahead from tick 1 —
  1378. -- and since drift is deterministic per speed, lane 1 (often having the first
  1379. -- racer drawn from the pool) showed a persistent early visual lead.
  1380. -- Range kept near 0x88 so the first frame still matches retail expectations.
  1381. local lastValues = {}
  1382. for i = 1, 4 do
  1383. local r = raceData.racers[i]
  1384. local speed = (r and r.speed or 128)
  1385. local speedOffset = math.floor((speed - poolMedian) * 0.06)
  1386. lastValues[i] = math.max(0x70, math.min(0x99, 0x88 + speedOffset + math.random(-4, 4)))
  1387. end
  1388. for segment = 1, 16 do
  1389. local nextValues = {}
  1390. local flagBytes = { 0x00, 0x00, 0x00, 0x00 }
  1391.  
  1392. for i = 1, 4 do
  1393. local r = raceData.racers[i]
  1394. -- Drift relative to pool median: faster-than-median = positive drift,
  1395. -- slower-than-median = negative. Small base random walk on top.
  1396. local drift = math.random(-1, 1)
  1397. if r then
  1398. -- Scale: 0.06 gives ±3 spread for a 50-point speed difference
  1399. drift = drift + math.floor((r.speed - poolMedian) * 0.06)
  1400. -- Late-race stamina fade: low-stamina chocobos tire noticeably
  1401. if segment > 10 and r.stamina < 150 then
  1402. drift = drift - math.floor((150 - r.stamina) * 0.008 * (segment - 10))
  1403. end
  1404. end
  1405. nextValues[i] = math.max(0x50, math.min(0xB0, lastValues[i] + drift))
  1406.  
  1407. local delta = math.abs(nextValues[i] - lastValues[i])
  1408. if delta >= 8 then
  1409. if r and not r.itemUsed and r.item and r.item > 0 then
  1410. flagBytes[i] = 0x80 + math.random(0, 3)
  1411. r.itemUsed = true -- Mark globally so Mode3B won't re-fire
  1412. else
  1413. flagBytes[i] = 0x08 -- Cap to small burst to avoid spurious item anim or announcer spam
  1414. end
  1415. elseif delta >= 4 then
  1416. flagBytes[i] = bit.lshift(delta, 1)
  1417. elseif delta >= 1 then
  1418. flagBytes[i] = delta
  1419. end
  1420.  
  1421. -- Cap non-item flags so they don't break 0x40 and spam the announcer
  1422. if flagBytes[i] > 0x40 and not (flagBytes[i] >= 0x80) then
  1423. flagBytes[i] = 0x08
  1424. end
  1425. end
  1426.  
  1427. for i = 1, 4 do bytes[#bytes + 1] = lastValues[i] end
  1428. for i = 1, 4 do bytes[#bytes + 1] = nextValues[i] end
  1429. for i = 1, 4 do bytes[#bytes + 1] = flagBytes[i] end
  1430.  
  1431. lastValues = nextValues
  1432. end
  1433.  
  1434. return padTo200(bytes)
  1435. end
  1436.  
  1437. -- Mode 3B: SectionParams block 2 (ParamIndex=0x10)
  1438. -- Keyframe timeline array for lanes 5-8.
  1439. -- Confirmed second packet observed in every race cycle capture.
  1440. -- Byte [7]: 0x00 in spectate captures, 0x01 in player-entered captures.
  1441. -- Starting position bytes vary per race — derived from lane 5-8 simulation.
  1442. local function buildMode3B(raceData)
  1443. local bytes = {
  1444. 0x69, 0x64, 0x00, 0x00,
  1445. 0x03, -- Mode=3
  1446. 0x10, -- ParamIndex=0x10 (second block)
  1447. 0xC0, -- ParamSize=192
  1448. 0x00, -- padding
  1449. }
  1450.  
  1451. -- Compute pool median for lanes 5-8 (same logic as Mode 3A).
  1452. local speedSum = 0
  1453. for i = 1, 4 do
  1454. local r = raceData.racers[i + 4]
  1455. speedSum = speedSum + (r and r.speed or 128)
  1456. end
  1457. local poolMedian = speedSum / 4
  1458.  
  1459. -- Starting positions near the Mode 3A sentinel (0x88).
  1460. -- BUG FIXED: old code used 0x66 as base, putting lanes 5-8 ~34 units behind
  1461. -- lanes 1-4 from the very first frame — making the first group look permanently
  1462. -- faster. Also fixed operator-precedence bug: `r and r.speed or 128 - 128`
  1463. -- evaluated as `r.speed or 0` (not `(r.speed or 128) - 128`), silently
  1464. -- defaulting to 0 for all nil-speed racers.
  1465. local lastValues = {}
  1466. for i = 1, 4 do
  1467. local r = raceData.racers[i + 4]
  1468. local speed = (r and r.speed or 128)
  1469. -- Faster-than-median racers start slightly ahead; range kept near 0x88
  1470. local speedOffset = math.floor((speed - poolMedian) * 0.06)
  1471. lastValues[i] = math.max(0x70, math.min(0x99, 0x88 + speedOffset + math.random(-4, 4)))
  1472. end
  1473.  
  1474. for segment = 1, 16 do
  1475. local nextValues = {}
  1476. local flagBytes = { 0x00, 0x00, 0x00, 0x00 }
  1477.  
  1478. for i = 1, 4 do
  1479. local r = raceData.racers[i + 4]
  1480. local drift = math.random(-1, 1)
  1481. if r then
  1482. drift = drift + math.floor((r.speed - poolMedian) * 0.06)
  1483. if segment > 10 and r.stamina < 150 then
  1484. drift = drift - math.floor((150 - r.stamina) * 0.008 * (segment - 10))
  1485. end
  1486. end
  1487. nextValues[i] = math.max(0x50, math.min(0xB0, lastValues[i] + drift))
  1488.  
  1489. local delta = math.abs(nextValues[i] - lastValues[i])
  1490. if delta > 0 then
  1491. if delta >= 8 then
  1492. if r and not r.itemUsed and r.item and r.item > 0 then
  1493. flagBytes[i] = 0x80 + math.random(0, 3)
  1494. r.itemUsed = true
  1495. else
  1496. flagBytes[i] = 0x08
  1497. end
  1498. elseif delta >= 4 then
  1499. flagBytes[i] = bit.lshift(delta, 1)
  1500. elseif delta >= 1 then
  1501. flagBytes[i] = delta
  1502. end
  1503. if flagBytes[i] > 0x40 and not (flagBytes[i] >= 0x80) then
  1504. flagBytes[i] = 0x08
  1505. end
  1506. end
  1507. end
  1508.  
  1509. for i = 1, 4 do bytes[#bytes + 1] = lastValues[i] end
  1510. for i = 1, 4 do bytes[#bytes + 1] = nextValues[i] end
  1511. for i = 1, 4 do bytes[#bytes + 1] = flagBytes[i] end
  1512.  
  1513. lastValues = nextValues
  1514. end
  1515.  
  1516. return padTo200(bytes)
  1517. end
  1518.  
  1519. -- Mode 4: ResultParams
  1520. -- Decoded from 20+ retail captures (ParamSize always 0x04):
  1521. -- Bytes [8-9]: Winnings per quill (uint16 LE)
  1522. -- Byte [10]: (1st_place_0indexed << 4) | 2nd_place_0indexed
  1523. -- Byte [11]: (3rd_place_0indexed << 4) | 4th_place_0indexed
  1524. -- Retail examples:
  1525. -- 34 16 25 70 → winnings=0x1634=5684, 1st=lane3, 2nd=lane6, 3rd=lane8, 4th=lane1
  1526. -- 03 61 57 24 → winnings=0x6103=24835, 1st=lane6, 2nd=lane8, 3rd=lane3, 4th=lane5
  1527. -- 23 10 FF FF → Player-entered race: 0xFF = special marker (all lanes)
  1528. local function buildMode4(winnings, placements)
  1529. local bytes = {
  1530. 0x69, 0x64, 0x00, 0x00,
  1531. 0x04, 0x00, -- Mode=4
  1532. 0x04, -- ParamSize (retail: always 0x04 in all 18+ captures)
  1533. 0x01, -- Flag: 0x01 when name data present, 0x00 when absent
  1534. }
  1535.  
  1536. -- Bytes [8-9]: Winnings per quill (uint16 LE)
  1537. bytes[#bytes + 1] = bit.band(winnings, 0xFF)
  1538. bytes[#bytes + 1] = bit.band(bit.rshift(winnings, 8), 0xFF)
  1539.  
  1540. -- Byte [10]: 1st + 2nd place (0-indexed, nibble-packed)
  1541. -- placements[1] = lane of 1st place (1-indexed), convert to 0-indexed
  1542. local first = (placements[1] or 1) - 1
  1543. local second = (placements[2] or 2) - 1
  1544. bytes[#bytes + 1] = bit.bor(bit.lshift(bit.band(first, 0x0F), 4), bit.band(second, 0x0F))
  1545.  
  1546. -- Byte [11]: 3rd + 4th place (0-indexed, nibble-packed)
  1547. local third = (placements[3] or 3) - 1
  1548. local fourth = (placements[4] or 4) - 1
  1549. bytes[#bytes + 1] = bit.bor(bit.lshift(bit.band(third, 0x0F), 4), bit.band(fourth, 0x0F))
  1550.  
  1551. return padTo200(bytes)
  1552. end
  1553.  
  1554. -- Mode 5: DownloadFlg=1 (system ready)
  1555. -- Client only checks Mode byte. Rest of packet is ignored.
  1556. -- Byte [7]: 0x01 observed in all captures (both spectator and player-entered)
  1557. local function buildMode5()
  1558. return padTo200({ 0x69, 0x64, 0x00, 0x00, 0x05, 0x00, 0x00, 0x01 })
  1559. end
  1560.  
  1561. -----------------------------------
  1562. -- Race Engine
  1563. -- Simulates a full race by ticking each racer forward and recording
  1564. -- finish order. The field is built procedurally each race by buildRaceField().
  1565. --
  1566. -- 🛑 THIS IS EMULATION 🛑
  1567. -- Stat equations, weather impacts, and race timing must reflect retail.
  1568. -- Do not invent random new variance tools without retail proof.
  1569. -----------------------------------
  1570. local function calculateRaceEngine(category)
  1571. -- Seed the PRNG from the current time + a rolling counter so that:
  1572. -- (a) rapid successive calls (e.g. !exec startRace()) each get a distinct sequence
  1573. -- (b) the shuffle and stat rolls are independent across races
  1574. -- Without an explicit seed, Lua/LuaJIT uses a fixed default seed (often 0),
  1575. -- making every race produce the same shuffle and therefore the same winner.
  1576. moduleState.raceCounter = moduleState.raceCounter + 1
  1577. math.randomseed(GetSystemTime() + moduleState.raceCounter * 1000)
  1578.  
  1579. -- Randomize environmental factors
  1580. local weather = math.random(1, 100) > 85 and constants.WEATHER.RAINY or constants.WEATHER.FINE
  1581. local condition = math.random(1, 100) > 80 and constants.CONDITION.HEAVY or constants.CONDITION.NORMAL
  1582.  
  1583. local racers = {}
  1584. -- Collect registered players then clear so they don't bleed into next race
  1585. local registered = {}
  1586. for _, stats in pairs(xi.chocoboRacing.registeredPlayers) do
  1587. table.insert(registered, stats)
  1588. end
  1589. xi.chocoboRacing.registeredPlayers = {}
  1590.  
  1591. -- Build the full 8-racer field: registered players + named rival roll-ins + procedural fill
  1592. local field = buildRaceField(category, registered)
  1593.  
  1594. for lane = 1, 8 do
  1595. local rData = field[lane]
  1596.  
  1597. -- APPLY ENVIRONMENTAL MODIFIERS
  1598. if weather == constants.WEATHER.RAINY then
  1599. rData.speed = rData.speed - 5
  1600. rData.stamina = rData.stamina - 5
  1601. end
  1602. if condition == constants.CONDITION.HEAVY then
  1603. rData.speed = rData.speed - 10
  1604. rData.stamina = rData.stamina + 10 -- Heavy track requires more endurance
  1605. end
  1606.  
  1607. racers[lane] = {
  1608. lane = lane,
  1609. playerID = rData.playerID,
  1610. jockeyId = JOCKEY_BASE_ID + (lane - 1),
  1611. mountId = CHOCOBO_BASE_ID + (lane - 1),
  1612. dispName = rData.dispName or resolveChocoboName(rData.name1, rData.name2),
  1613. speed = math.max(20, math.min(255, rData.speed)),
  1614. stamina = math.max(20, math.min(255, rData.stamina)),
  1615. skill = math.max(20, math.min(255, rData.skill)),
  1616. receptivity = rData.receptivity or 128,
  1617. race = rData.race,
  1618. color = rData.color,
  1619. silks = rData.silks,
  1620. name1 = rData.name1,
  1621. name2 = rData.name2,
  1622. item = rData.item,
  1623. itemUsed = false,
  1624. saddle = rData.saddle,
  1625. progress = 0,
  1626. finished = false,
  1627. placement = 0,
  1628. replayData = {},
  1629. }
  1630. end
  1631.  
  1632. local racersFinished = 0
  1633. local currentTick = 0
  1634.  
  1635. while racersFinished < 8 do
  1636. currentTick = currentTick + 1
  1637. for lane = 1, 8 do
  1638. local r = racers[lane]
  1639. if not r.finished then
  1640. -- Speed is the primary driver, but stamina degrades over time
  1641. -- Later ticks = more fatigue, reduced by high stamina
  1642. local fatigueFactor = 1.0
  1643. if currentTick > 10 then
  1644. local staminaRatio = r.stamina / 255.0
  1645. fatigueFactor = 0.7 + (0.3 * staminaRatio)
  1646. end
  1647. -- Skill adds occasional lucky bursts
  1648. local skillBonus = 0
  1649. if math.random(1, 100) <= math.floor(r.skill * 0.15) then
  1650. skillBonus = math.random(1, 4)
  1651. end
  1652. -- Receptivity reduces variance and mitigates poor ticks
  1653. local focusBonus = (r.receptivity / 255.0) * math.random(0, 2)
  1654.  
  1655. r.progress = r.progress + (r.speed * 0.05 * fatigueFactor) + math.random(1, 3) + skillBonus + focusBonus
  1656. r.replayData[currentTick] = r.progress
  1657. if r.progress >= TRACK_LENGTH then
  1658. r.finished = true
  1659. r.finishTick = currentTick
  1660. racersFinished = racersFinished + 1
  1661. -- NOTE: do NOT assign r.placement or placements[] here.
  1662. -- The inner loop processes lanes 1→8 in order, so any tick where
  1663. -- multiple racers cross the finish simultaneously would always give
  1664. -- lane 1 the win. Placements are assigned below via post-sort.
  1665. end
  1666. end
  1667. end
  1668. end
  1669.  
  1670. -- Post-simulation placement sort.
  1671. -- Primary key: finishTick ascending (earlier tick = finished first)
  1672. -- Tiebreak key: progress descending (higher overshoot = crossed the line
  1673. -- "sooner" within the same tick step)
  1674. -- This eliminates the lane-1 always-wins bias that came from the in-loop
  1675. -- placement assignment processing lanes in fixed 1→8 order.
  1676. local finisherList = {}
  1677. for lane = 1, 8 do finisherList[#finisherList + 1] = racers[lane] end
  1678. table.sort(finisherList, function(a, b)
  1679. if a.finishTick ~= b.finishTick then
  1680. return a.finishTick < b.finishTick
  1681. end
  1682. return a.progress > b.progress -- higher overshoot = faster within the tick
  1683. end)
  1684.  
  1685. local placements = {}
  1686. for pos, r in ipairs(finisherList) do
  1687. r.placement = pos
  1688. placements[pos] = r.lane
  1689. end
  1690.  
  1691. local winningCombo = (placements[1] * 10) + placements[2]
  1692. logInfo(string.format('Race prepared. Category: 0x%08X | 1st: Lane %d (%s) | 2nd: Lane %d | Combo: %02d | Ticks: %d',
  1693. category, placements[1], racers[placements[1]].dispName, placements[2], winningCombo, currentTick))
  1694. logRacerTable(racers)
  1695.  
  1696. return {
  1697. racers = racers,
  1698. placements = placements,
  1699. maxTicks = currentTick,
  1700. winningCombo = winningCombo,
  1701. category = category,
  1702. raceNum = moduleState.raceCounter,
  1703. weather = weather,
  1704. condition = condition,
  1705. }
  1706. end
  1707.  
  1708. -----------------------------------
  1709. -- NPC Race Announcer Dialogue (0x036 TalkNum)
  1710. --
  1711. -- In retail, 5-8 zone NPCs (ActIndex 0x61-0x65, 0xB6, 0xB8, 0xBC, 0xBD,
  1712. -- 0xBF, 0xC1) deliver formatted messages from the SevMess table at specific
  1713. -- moments during the race lifecycle. These happen via messageSpecial() which
  1714. -- generates 0x036 packets.
  1715. --
  1716. -- Pattern from captures (per race cycle):
  1717. -- XX:45:19-49 Pre-race announcements (6-7 messages, NPCs 0x62-0x65)
  1718. -- XX:50:55 Race starting soon (2 messages, NPCs 0x62-0x63)
  1719. -- XX:51:05 Race bell / start (NPC 0xB6 or 0xB8)
  1720. -- XX:51:35 Race finish bell (NPC 0xB6 or 0xB8)
  1721. -- XX:55:23 Intermission (NPC 0x61)
  1722. -----------------------------------
  1723.  
  1724. -- Broadcast sequence of messages with delays (like a cutscene)
  1725. local function playAnnouncerSequence(zone, npcId, messages, intervalMs)
  1726. intervalMs = intervalMs or 3000
  1727. local npc = GetNPCByID(npcId)
  1728. if not npc or not zone then return end
  1729.  
  1730. local delay = 0
  1731. for _, msgId in ipairs(messages) do
  1732. -- Attach the timer to the anchor NPC to survive player disconnects
  1733. npc:timer(delay, function(n)
  1734. local players = zone:getPlayers()
  1735. for _, player in ipairs(players) do
  1736. player:messageSpecial(msgId, 0, 0, 0, 0)
  1737. end
  1738. end)
  1739. delay = delay + intervalMs
  1740. end
  1741. end
  1742.  
  1743. -- Broadcast pre-race NPC announcements (the 6-message sequence)
  1744. local function announcePreRace(zone)
  1745. local T = constants.TEXT
  1746. local npcId = zones[xi.zone.CHOCOBO_CIRCUIT].npc.RUNGAGA
  1747. local msgs = {
  1748. T.RACE_ANNOUNCE_1, T.RACE_ANNOUNCE_2, T.RACE_ANNOUNCE_3,
  1749. T.RACE_ANNOUNCE_4, T.RACE_ANNOUNCE_5, T.RACE_ANNOUNCE_6,
  1750. T.RACE_ANNOUNCE_FINAL
  1751. }
  1752. -- Deliver 7 messages paced out by 4 seconds each (28s duration)
  1753. playAnnouncerSequence(zone, npcId, msgs, 4000)
  1754. end
  1755.  
  1756. -- Broadcast race start bell
  1757. local function announceRaceStart(zone)
  1758. local T = constants.TEXT
  1759. local npcId = zones[xi.zone.CHOCOBO_CIRCUIT].npc.RUNGAGA
  1760. local msgs = {
  1761. T.RACE_STARTING_1, T.RACE_STARTING_2, T.RACE_START_BELL
  1762. }
  1763. -- 3 messages paced out tightly at the gate drop
  1764. playAnnouncerSequence(zone, npcId, msgs, 2000)
  1765. end
  1766.  
  1767. -- Broadcast race finish bell
  1768. local function announceRaceFinish(zone)
  1769. local T = constants.TEXT
  1770. local npcId = zones[xi.zone.CHOCOBO_CIRCUIT].npc.RUNGAGA
  1771. local msgs = {
  1772. T.RACE_FINISH_BELL, T.RESULT_1ST, T.RESULT_2ND,
  1773. T.RESULT_WIN_A, T.RESULT_WIN_B, T.RESULT_DETAIL_A,
  1774. T.RESULT_DETAIL_B, T.RESULT_RECAP_A, T.RESULT_RECAP_B,
  1775. T.RESULT_RECAP_C, T.RESULT_RECAP_D
  1776. }
  1777. -- 11-message resolution summary over 33 seconds
  1778. playAnnouncerSequence(zone, npcId, msgs, 3000)
  1779. end
  1780.  
  1781. -- Broadcast intermission
  1782. local function announceIntermission(zone)
  1783. local npcId = zones[xi.zone.CHOCOBO_CIRCUIT].npc.RUNGAGA
  1784. playAnnouncerSequence(zone, npcId, { constants.TEXT.INTERMISSION }, 3000)
  1785. end
  1786.  
  1787. -----------------------------------
  1788. -- Supplementary NPC Movement (setPos path)
  1789. -- Optional visual layer. The client renders the race from 0x069 data alone.
  1790. -- setPos() just moves the physical dummy entities to match.
  1791. -- setPos() generates 0x05B (WPOS) packets internally.
  1792. -----------------------------------
  1793. local function sendRaceBurstToPlayer(player, raceData)
  1794. local winnings = 1076 -- Base winnings per quill (Beginner default)
  1795. if raceData.category == constants.CATEGORY.C1_CRYSTAL_STAKES then
  1796. winnings = math.random(20000, 30000)
  1797. elseif raceData.category == constants.CATEGORY.C2_REGULAR then
  1798. winnings = math.random(4000, 8000)
  1799. elseif raceData.category == constants.CATEGORY.C3_REGULAR then
  1800. winnings = math.random(10000, 15000)
  1801. else -- C4_REGULAR (Beginner)
  1802. winnings = math.random(5000, 25000)
  1803. end
  1804.  
  1805. -- Persist winnings so onEventUpdate option==17 can display the correct value.
  1806. -- LocalVar won't survive across the event boundary; CharVar is persistent.
  1807. player:setCharVar('CR_WINNINGS', winnings)
  1808.  
  1809. local packets = {
  1810. buildMode1(raceData),
  1811. buildMode2(raceData.racers),
  1812. buildMode3A(raceData),
  1813. buildMode3B(raceData),
  1814. buildMode4(winnings, raceData.placements),
  1815. buildMode5(),
  1816. }
  1817.  
  1818. for _, packet in ipairs(packets) do
  1819. player:sendDebugPacket(packet)
  1820. end
  1821. end
  1822.  
  1823. local function tickNPCMovement(raceData, tick)
  1824. local numWaypoints = #TRACK_WAYPOINTS -- now 9 (loop-closed)
  1825. local numSegments = numWaypoints - 1 -- 8 segments between 9 points
  1826.  
  1827. for lane = 1, 8 do
  1828. local r = raceData.racers[lane]
  1829.  
  1830. -- Skip finished racers: leave them at whatever position setPos last
  1831. -- placed them (the finish gate at TRACK_WAYPOINTS[9]). Without this
  1832. -- guard they'd oscillate between 1.0 and lower-ratio positions because
  1833. -- replayData[tick] is nil after the racer crossed the line.
  1834. if r.finished and tick > (r.finishTick or 0) then
  1835. -- Already parked at finish; nothing to do.
  1836. else
  1837. local progress = r.replayData[tick] or 0
  1838. local progressRatio = math.min(progress / TRACK_LENGTH, 1.0)
  1839.  
  1840. -- Piecewise linear interpolation through all waypoints.
  1841. -- segmentIndex: which gap (1-indexed) in the waypoint chain.
  1842. -- t: fractional position within that gap [0, 1).
  1843. local segmentFloat = progressRatio * numSegments -- 0.0 to 8.0
  1844. local segIdx = math.min(math.floor(segmentFloat) + 1, numSegments)
  1845. local t = segmentFloat - (segIdx - 1) -- fractional part, always [0,1]
  1846. t = math.max(0.0, math.min(1.0, t)) -- clamp for floating-point edge cases
  1847.  
  1848. local wp1 = TRACK_WAYPOINTS[segIdx]
  1849. local wp2 = TRACK_WAYPOINTS[segIdx + 1]
  1850.  
  1851. local laneOff = (lane - 1) * 2.5
  1852. local x = wp1.x + (wp2.x - wp1.x) * t
  1853. local y = wp1.y + (wp2.y - wp1.y) * t
  1854. local z = (wp1.z + (wp2.z - wp1.z) * t) + laneOff
  1855.  
  1856. -- Use cached entity references (set at spawn time) to avoid
  1857. -- 16 GetNPCByID hash-map lookups per tick during RACING state.
  1858. if r.mountRef then r.mountRef:setPos(x, y, z, 0) end
  1859. if r.jockeyRef then r.jockeyRef:setPos(x, y, z, 0) end
  1860. end
  1861. end
  1862. end
  1863.  
  1864. -----------------------------------
  1865. -- Start Race Implementation
  1866. -- Generates dynamic race data, builds packets, and sends to player.
  1867. -----------------------------------
  1868. -- Send pre-calculated race data to a single player
  1869. -----------------------------------
  1870. -- keepEventAlive: Matches the official implementation's setTimer pattern.
  1871. -- The race cutscene (event 210) is driven entirely by the client interpreting
  1872. -- the 0x069 Mode 3 SectionParams packet. However, the cutscene engine
  1873. -- requires periodic entity-update "heartbeats" from the server to stay alive;
  1874. -- without them the CS stalls and gets stuck at the end of the race track.
  1875. -- We pulse sendEmptyEntityUpdateToPlayer on Rungaga (the race anchor NPC)
  1876. -- every 400ms for the duration of the race.
  1877. -----------------------------------
  1878. local keepEventAlive -- forward-declare so the recursive closure can ref itself
  1879. keepEventAlive = function(player)
  1880. local rungagaId = zones[xi.zone.CHOCOBO_CIRCUIT].npc.RUNGAGA
  1881. player:timer(400, function(playerArg)
  1882. local npc = GetNPCByID(rungagaId)
  1883. if npc then
  1884. playerArg:sendEmptyEntityUpdateToPlayer(npc)
  1885. end
  1886. -- Run unconditionally — matches official implementation which never stops the heartbeat.
  1887. -- The cutscene engine requires continuous entity-update pulses to stay alive;
  1888. -- stopping early (e.g. on CR_POS_SAVED clear) stalls the CS at the finish line.
  1889. keepEventAlive(playerArg)
  1890. end)
  1891. end
  1892.  
  1893. local startRaceImpl = function(player, raceData)
  1894. logDebug(string.format('Sending race to player: %s', player:getName()))
  1895.  
  1896. -- Build and send dynamic 0x069 packet burst
  1897. sendRaceBurstToPlayer(player, raceData)
  1898.  
  1899. -- Store numeric race state as local vars on the player for CS retrieval
  1900. player:setLocalVar('CR_CATEGORY', raceData.category)
  1901. player:setLocalVar('CR_WINNING_COMBO', raceData.winningCombo)
  1902.  
  1903. -- Save the player's current position so we can teleport them back after the race.
  1904. -- IMPORTANT: stored in CharVars (persistent) rather than LocalVars (event-scoped).
  1905. -- LocalVars are cleared when the cutscene engine takes over, so the saved coords
  1906. -- were silently lost before onEventFinish could read them.
  1907. -- Also: getPos() returns pos.rotation (not pos.rot) in server-base.
  1908. local pos = player:getPos()
  1909. if pos then
  1910. player:setCharVar('CR_START_X', math.floor(pos.x * 1000 + 0.5))
  1911. player:setCharVar('CR_START_Y', math.floor(pos.y * 1000 + 0.5))
  1912. player:setCharVar('CR_START_Z', math.floor(pos.z * 1000 + 0.5))
  1913. player:setCharVar('CR_START_ROT', pos.rotation or pos.rot or 0)
  1914. player:setCharVar('CR_POS_SAVED', 1) -- Explicit flag; avoids X=0 false-negative
  1915. logDebug(string.format('Saved pos for %s: x=%.3f y=%.3f z=%.3f rot=%d',
  1916. player:getName(), pos.x, pos.y, pos.z, pos.rotation or pos.rot or 0))
  1917. else
  1918. logError(string.format('getPos() returned nil for %s — position will not be restored after race', player:getName()))
  1919. end
  1920.  
  1921. -- Spectator mode uses Event 210 via 0x034 (EventNum, numeric params only)
  1922. -- Param 1: 3885177 (0x3B4269) — confirmed fixed value from official implementation
  1923. -- Params 3-8 confirmed matching official: -132554, -14500, 1344, -1, 610862737, 1
  1924. logDebug(string.format('startEvent(210) for %s | raceNum:%d combo:%02d',
  1925. player:getName(), raceData.raceNum, raceData.winningCombo))
  1926. player:startEvent(210, 3885177, 3885177, -132554, -14500, 1344, -1, 610862737, 1)
  1927.  
  1928. -- Start the Rungaga entity heartbeat that keeps the race cutscene alive.
  1929. -- Without this the CS engine stalls at the finish line.
  1930. keepEventAlive(player)
  1931. end
  1932.  
  1933.  
  1934. xi.chocoboRacing.startRace = function(category)
  1935. category = category or constants.CATEGORY.C4_REGULAR
  1936. logInfo(string.format('startRace called. Category: 0x%08X', category))
  1937.  
  1938. -- Calculate race ONCE for all players
  1939. local raceData = calculateRaceEngine(category)
  1940. if not raceData then
  1941. logError('Failed to generate race data')
  1942. return
  1943. end
  1944. xi.chocoboRacing.currentRaceData = raceData
  1945.  
  1946. -- Send the same race to every player in zone
  1947. local zone = GetZone(xi.zone.CHOCOBO_CIRCUIT)
  1948. if not zone then
  1949. logError('Could not get Chocobo_Circuit zone')
  1950. return
  1951. end
  1952. local players = zone:getPlayers()
  1953. logInfo(string.format('Sending race to %d player(s)', #players))
  1954. for _, player in ipairs(players) do
  1955. startRaceImpl(player, raceData)
  1956. end
  1957. end
  1958.  
  1959. -----------------------------------
  1960. -- Zone Tick Handler
  1961. --
  1962. -- Wire this up in scripts/zones/Chocobo_Circuit/Zone.lua:
  1963. -- require('scripts/globals/chocobo_racing')
  1964. -- function onZoneInit(zone)
  1965. -- xi.chocoboRacing._initRivals() -- loads entity_chocobo from DB
  1966. -- end
  1967. -- function onZoneTick(zone)
  1968. -- xi.chocoboRacing.onZoneTick(zone)
  1969. -- end
  1970. -- If onZoneInit is not available in your server base, _initRivals() will be
  1971. -- called automatically on the first race via the lazy-load in buildRaceField.
  1972. -----------------------------------
  1973. xi.chocoboRacing.onZoneTick = function(zone)
  1974. local state = moduleState.raceState
  1975.  
  1976. -- Fast path: when IDLE, only check the clock every ~2 seconds
  1977. -- to avoid GetSystemTime() + modulo every single 400ms tick.
  1978. if state == State.IDLE then
  1979. local tickCount = (moduleState.idleTickSkip or 0) + 1
  1980. moduleState.idleTickSkip = tickCount
  1981. if tickCount < 5 then return end -- Skip ~4 ticks (~1.6s)
  1982. moduleState.idleTickSkip = 0
  1983. end
  1984.  
  1985. local now = GetSystemTime()
  1986.  
  1987. if state == State.IDLE then
  1988. -- TIMING CONFIRMATION:
  1989. -- now % 900 checks if we are at the start of a 15-minute Earth time interval.
  1990. -- If we are in the first 5 seconds of the block, start the 15-min process.
  1991. if (now % SLOT_SECONDS) < 5 then
  1992. -- Determine the current "Month" based on Earth week
  1993. local week = math.floor((now / 604800) % 4) + 1
  1994. -- Map current 2-hour window (7200s) to one of the 8 schedule slots
  1995. local slot = math.floor((now % 7200) / SLOT_SECONDS) + 1
  1996. local category = RACE_SCHEDULE[week] and RACE_SCHEDULE[week][slot] or constants.CATEGORY.C4_REGULAR
  1997.  
  1998. local raceData = calculateRaceEngine(category)
  1999. if raceData then
  2000. xi.chocoboRacing.currentRaceData = raceData
  2001. moduleState.raceState = State.PREPARE
  2002. moduleState.prepareTime = now
  2003. moduleState.raceTick = 0
  2004.  
  2005. -- Announce upcoming race (0x036 TalkNum packets)
  2006. announcePreRace(zone)
  2007.  
  2008. -- Spawn dynamic jockeys and mounts on the track
  2009. spawnVisualRacers(zone, raceData)
  2010.  
  2011. -- Start race for all players currently in zone.
  2012. -- startRaceImpl sends the 0x069 burst AND fires startEvent(210, ...)
  2013. -- so that the race cutscene actually begins on each client.
  2014. -- Previously only sendRaceBurstToPlayer was called here, which sent
  2015. -- the packet data but never triggered the cutscene event on players.
  2016. local players = zone:getPlayers()
  2017. for _, player in ipairs(players) do
  2018. startRaceImpl(player, raceData)
  2019. end
  2020. end
  2021. end
  2022. return
  2023. end
  2024.  
  2025. if state == State.PREPARE then
  2026. if (now - moduleState.prepareTime) >= PREPARE_HOLD_SEC then
  2027. moduleState.raceState = State.RACING
  2028. announceRaceStart(zone) -- 0x036: Race bell
  2029. logInfo('State: PREPARE -> RACING (gates open)')
  2030. end
  2031. return
  2032. end
  2033.  
  2034. if state == State.RACING then
  2035. local raceData = xi.chocoboRacing.currentRaceData
  2036. if not raceData then
  2037. moduleState.raceState = State.IDLE
  2038. return
  2039. end
  2040. moduleState.raceTick = moduleState.raceTick + 1
  2041. tickNPCMovement(raceData, moduleState.raceTick)
  2042. if moduleState.raceTick >= raceData.maxTicks then
  2043. moduleState.raceState = State.FINISHED
  2044. moduleState.finishTime = now
  2045. announceRaceFinish(zone) -- 0x036: Finish bell
  2046. logInfo(string.format('State: RACING -> FINISHED | Combo: %02d | Ticks: %d', raceData.winningCombo, moduleState.raceTick))
  2047. xi.chocoboRacing.processPayouts(raceData.winningCombo)
  2048. xi.chocoboRacing.recordRaceResult(raceData)
  2049. end
  2050. return
  2051. end
  2052.  
  2053. if state == State.FINISHED then
  2054. if (now - moduleState.finishTime) >= FINISH_HOLD_SEC then
  2055. announceIntermission(zone) -- 0x036: Intermission
  2056. despawnVisualRacers(xi.chocoboRacing.currentRaceData)
  2057. moduleState.raceState = State.IDLE
  2058. xi.chocoboRacing.currentRaceData = nil
  2059. logInfo('State: FINISHED -> IDLE (reset)')
  2060. end
  2061. return
  2062. end
  2063. end
  2064.  
  2065. -----------------------------------
  2066. -- Event Handlers
  2067. -----------------------------------
  2068. -- Client sends 0x05B (event update request), server responds with 0x05C
  2069. -- (event work params) and 0x05D (event string params).
  2070. --
  2071. -- Chocobo display names flow through 0x05D (GP_SERV_COMMAND_EVENT_UPDATE_STRING):
  2072. -- updateEventString(name1, name2, name3, name4) → sends 4 names per 0x05D packet
  2073. -- Two calls per race = 8 names for 8 lanes.
  2074. -- This is confirmed in all 18+ retail captures: chocobo names appear ONLY in
  2075. -- 0x05D packets, never inside 0x069 packets. Any strings visible in 0x069
  2076. -- captures are junk/leftover server memory.
  2077. --
  2078. -----------------------------------
  2079. -- NPC Interaction Handlers
  2080. -----------------------------------
  2081.  
  2082. xi.chocoboRacing.onNPCInteraction = function(player, npc, eventId, nation)
  2083. -- NPC Adrian (event 1) functions as the result board interaction
  2084. if eventId == 1 then
  2085. local history = xi.chocoboRacing.getRecentWinners(3)
  2086. player:printToPlayer("--- Recent Race Results ---", xi.msg.channel.SYSTEM_3)
  2087. for _, entry in ipairs(history) do
  2088. local timeStr = os.date("%H:%M", entry.time)
  2089. player:printToPlayer(string.format("[%s] Category: %d | Combo: %02d (Winner: %s)",
  2090. timeStr, bit.band(entry.category, 0xF), entry.winningCombo, entry.winnerName),
  2091. xi.msg.channel.SYSTEM_3)
  2092. end
  2093. end
  2094.  
  2095. local hasChocobo = 1
  2096. local isRegistered = xi.chocoboRacing.registeredPlayers[player:getID()] and 1 or 0
  2097. player:startEvent(eventId, hasChocobo, nation, 1, isRegistered, 0, 0, 0, 0)
  2098. end
  2099.  
  2100. xi.chocoboRacing.onBettingInteraction = function(player, npc, eventId, ...)
  2101. player:startEvent(eventId, ...)
  2102. end
  2103.  
  2104. xi.chocoboRacing.onBettingEventFinish = function(player, csid, option, npcName)
  2105. local bettingEvents = {
  2106. [212] = true, [267] = true, [287] = true, [308] = true,
  2107. [338] = true, [339] = true, [340] = true, [343] = true,
  2108. [346] = true, [347] = true, [348] = true, [349] = true,
  2109. [350] = true, [351] = true, [352] = true, [353] = true,
  2110. [354] = true
  2111. }
  2112.  
  2113. if bettingEvents[csid] then
  2114. if option > 0 then
  2115. local combo = option
  2116. local cost = 100 -- Default bet cost
  2117. if xi.chocoboRacing.placeBet(player, combo, cost) then
  2118. player:printToPlayer("Bet placed on Combination: " .. combo .. " through Betting Clerk " .. npcName .. ".", xi.msg.channel.SYSTEM_3)
  2119. end
  2120. end
  2121. return true
  2122. end
  2123. return false
  2124. end
  2125.  
  2126. -----------------------------------
  2127. -- Event Handlers
  2128. -----------------------------------
  2129. xi.chocoboRacing.onEventUpdate = function(player, csid, option, npc)
  2130. if csid ~= 210 and csid ~= 469 then return end
  2131. debug(player, 'update', csid, option)
  2132.  
  2133. -- Read chocobo names from the global race data.
  2134. -- Fallback chain: dispName → 'Chocobo<N>' (unique per lane, mirrors official placeholder style).
  2135. -- Guard against xi.chocoboNames load failure or empty dispName strings.
  2136. local raceData = xi.chocoboRacing.currentRaceData
  2137. local names = {}
  2138. if raceData then
  2139. for lane = 1, 8 do
  2140. local r = raceData.racers[lane]
  2141. local name = r and r.dispName
  2142. -- Treat blank strings the same as nil
  2143. if not name or name == '' then
  2144. name = 'Chocobo' .. lane
  2145. end
  2146. names[lane] = name
  2147. end
  2148. else
  2149. for lane = 1, 8 do
  2150. names[lane] = 'Chocobo' .. lane
  2151. end
  2152. end
  2153.  
  2154. -- ===========================================
  2155. -- Event 210: SPECTATOR RACE (8 lanes)
  2156. -- ===========================================
  2157. if csid == 210 then
  2158. if option == 5 then
  2159. -- Confirmed from official: p4 = 389304928 (0x173B1CA0), NOT CR_CATEGORY
  2160. player:updateEvent(1, 0, -132554, -14500, 389304928, -1, 610862737, -5)
  2161. elseif option == 274 then
  2162. player:updateEventString(names[1], names[2], names[3], names[4])
  2163. player:updateEvent(70, 0, 7, 4, 389304928, -1, 610862737, -5)
  2164. elseif option == 510 or option == 530 then
  2165. player:updateEventString(names[5], names[6], names[7], names[8])
  2166. player:updateEvent(70, 0, 7, 4, 389304928, -1, 610862737, -5)
  2167. elseif option == 17 then
  2168. -- Read the real winnings that were stored when the 0x069 burst was built.
  2169. -- Previously hardcoded to 1076; now reads CR_WINNINGS CharVar set in
  2170. -- sendRaceBurstToPlayer so the cutscene shows the correct payout value.
  2171. local winnings = player:getCharVar('CR_WINNINGS')
  2172. if winnings == 0 then winnings = 1076 end -- fallback if var missing
  2173. player:updateEvent(70, 0, winnings, 1, 0, 3, 3, -5)
  2174. player:setCharVar('CR_WINNINGS', 0) -- clear after display
  2175. end
  2176. end
  2177.  
  2178. -- ===========================================
  2179. -- Event 469: PLAYER-ENTERED RACE (4 lanes)
  2180. -- ===========================================
  2181. if csid == 469 then
  2182. -- Registration & Polling
  2183. if option == 246 or option == 1 then
  2184. player:updateEvent(0x02040000, 0, 1, 0, 0, 0, 0, 0x80)
  2185. elseif option == 6 then -- Show race preview
  2186. player:updateEvent(0x02040000, 0, 1, 0, 0, 0, 0, 0x80)
  2187. elseif option == 514 or option == 8 then -- Race Progress/Start
  2188. player:updateEvent(0x42000000, 0, 1, 0, 3, 0, 0, 0x80)
  2189. elseif option == 5 then -- Intro banner
  2190. player:updateEvent(0x42000000, 0, 1, 0, -61405, 0, 0, -5)
  2191. elseif option == 265 then -- Names 1-4
  2192. player:updateEventString(names[1], names[2], names[3], names[4])
  2193. player:updateEvent(1, 0, 1, 0, -61405, 0, 0, -5)
  2194. elseif option == 521 then -- Names 5-8 (if applicable)
  2195. player:updateEventString(names[5], names[6], names[7], names[8])
  2196. player:updateEvent(0, 0, 1, 0, -61405, 0, 0, -5)
  2197. elseif option == 11 then -- Results
  2198. player:updateEvent(-61405, 0, 0, 0, 0, 0, 0, 0)
  2199. end
  2200. end
  2201. end
  2202.  
  2203. xi.chocoboRacing.onEventFinish = function(player, csid, option, npc)
  2204. if csid ~= 210 and csid ~= 469 then return end
  2205. debug(player, 'finish', csid, option)
  2206.  
  2207. -- Restore player position — reads from CharVars (persistent across event boundary).
  2208. local posSaved = player:getCharVar('CR_POS_SAVED')
  2209. if posSaved == 1 then
  2210. local startX = player:getCharVar('CR_START_X')
  2211. local startY = player:getCharVar('CR_START_Y')
  2212. local startZ = player:getCharVar('CR_START_Z')
  2213. local startRot = player:getCharVar('CR_START_ROT')
  2214. local rx = startX / 1000
  2215. local ry = startY / 1000
  2216. local rz = startZ / 1000
  2217. logDebug(string.format('Restoring pos for %s: x=%.3f y=%.3f z=%.3f rot=%d',
  2218. player:getName(), rx, ry, rz, startRot))
  2219. player:setPos(rx, ry, rz, startRot)
  2220.  
  2221. -- Clear all saved vars
  2222. player:setCharVar('CR_START_X', 0)
  2223. player:setCharVar('CR_START_Y', 0)
  2224. player:setCharVar('CR_START_Z', 0)
  2225. player:setCharVar('CR_START_ROT', 0)
  2226. player:setCharVar('CR_POS_SAVED', 0)
  2227. else
  2228. logDebug(string.format('No saved position for %s (CR_POS_SAVED=0) — skipping restore', player:getName()))
  2229. end
  2230.  
  2231. if csid == 469 then
  2232. -- Award race rewards (based on placement in currentRaceData)
  2233. local raceData = xi.chocoboRacing.currentRaceData
  2234. if raceData then
  2235. -- Find player lane and placement
  2236. for lane = 1, 8 do
  2237. local r = raceData.racers[lane]
  2238. if r and r.playerID == player:getID() then
  2239. local placement = r.placement
  2240. local category = player:getLocalVar("CR_CATEGORY")
  2241. local teamId = player:getCharVar("CR_TEAM_AFFILIATION")
  2242.  
  2243. if teamId == 0 then teamId = 1 end -- Default to San d'Oria if none
  2244.  
  2245. local rewardBucks = 0
  2246. if placement == 1 then
  2247. rewardBucks = 50
  2248. elseif placement == 2 then
  2249. rewardBucks = 30
  2250. elseif placement == 3 then
  2251. rewardBucks = 15
  2252. end
  2253.  
  2254. local suffix = "th"
  2255. if placement == 1 then suffix = "st"
  2256. elseif placement == 2 then suffix = "nd"
  2257. elseif placement == 3 then suffix = "rd" end
  2258.  
  2259. if rewardBucks > 0 then
  2260. xi.chocoboRacing.addChocobucks(player, rewardBucks, teamId)
  2261. player:printToPlayer(string.format("Race finished! You came in %d%s place and earned %d Chocobucks.",
  2262. placement, suffix, rewardBucks), xi.msg.channel.SYSTEM_3)
  2263. else
  2264. player:printToPlayer(string.format("Race finished! You came in %d%s place.",
  2265. placement, suffix), xi.msg.channel.SYSTEM_3)
  2266. end
  2267.  
  2268. player:setLocalVar("CR_CATEGORY", 0)
  2269. end
  2270. end
  2271. end
  2272. end
  2273. end
  2274.  
  2275.  
  2276.  
  2277. -----------------------------------
  2278. -- Betting & Payouts
  2279. -----------------------------------
  2280. xi.chocoboRacing.placeBet = function(player, combo, cost)
  2281. if moduleState.raceState ~= State.IDLE then
  2282. player:printToPlayer('Betting is currently closed.', xi.msg.channel.SYSTEM_3)
  2283. return false
  2284. end
  2285. if player:getGil() < cost then
  2286. player:printToPlayer('You do not have enough gil.', xi.msg.channel.SYSTEM_3)
  2287. return false
  2288. end
  2289.  
  2290. -- Deduct gil
  2291. player:delGil(cost)
  2292.  
  2293. -- Register in memory and save to char variables for crash protection
  2294. xi.chocoboRacing.activeBets[player:getID()] = { combo = combo, amt = cost }
  2295. player:setCharVar("CR_PENDING_BET_COMBO", combo)
  2296. player:setCharVar("CR_PENDING_BET_AMT", cost)
  2297.  
  2298. player:printToPlayer(
  2299. string.format('Bet placed on combination %02d for %d gil.', combo, cost),
  2300. xi.msg.channel.SYSTEM_3)
  2301. return true
  2302. end
  2303.  
  2304. xi.chocoboRacing.processPayouts = function(winningCombo)
  2305. for charid, bet in pairs(xi.chocoboRacing.activeBets) do
  2306. local player = GetPlayerByID(charid)
  2307.  
  2308. if bet.combo == winningCombo then
  2309. local amount_won = bet.amt * 15
  2310. if player then
  2311. player:addGil(amount_won)
  2312. player:messageSpecial(zones[xi.zone.CHOCOBO_CIRCUIT].text.GIL_OBTAINED, amount_won)
  2313. player:printToPlayer(
  2314. string.format('Congratulations! Combo %02d won %d gil!', winningCombo, amount_won),
  2315. xi.msg.channel.SYSTEM_3)
  2316. end
  2317. else
  2318. if player then
  2319. player:printToPlayer(
  2320. string.format('Your bet on %02d lost. Winner: %02d.', bet.combo, winningCombo),
  2321. xi.msg.channel.SYSTEM_3)
  2322. end
  2323. end
  2324.  
  2325. -- Clear the bet whether win or lose
  2326. if player then
  2327. player:setCharVar("CR_PENDING_BET_COMBO", 0)
  2328. player:setCharVar("CR_PENDING_BET_AMT", 0)
  2329. end
  2330. end
  2331. -- Reset all bets globally for next race
  2332. xi.chocoboRacing.activeBets = {}
  2333. end
  2334.  
  2335. -----------------------------------
  2336. -- Public API
  2337. -----------------------------------
  2338. xi.chocoboRacing.getRaceState = function()
  2339. return moduleState.raceState
  2340. end
  2341.  
  2342. xi.chocoboRacing.startMonitorCutscene = function(player)
  2343. if moduleState.raceState ~= State.RACING then
  2344. player:printToPlayer('There is no race currently in progress.', xi.msg.channel.SYSTEM_3)
  2345. return
  2346. end
  2347. local r = xi.chocoboRacing.currentRaceData.racers
  2348. player:startEvent(210,
  2349. r[1].placement, r[2].placement, r[3].placement, r[4].placement,
  2350. r[5].placement, r[6].placement, r[7].placement, r[8].placement)
  2351. end
  2352.  
  2353.  
  2354. -----------------------------------
  2355. -- History & Stats API
  2356. -----------------------------------
  2357.  
  2358. xi.chocoboRacing.raceHistory = {}
  2359.  
  2360. -- Record the result of a completed race
  2361. xi.chocoboRacing.recordRaceResult = function(raceData)
  2362. local winner = raceData.racers[raceData.placements[1]]
  2363. local timestamp = GetSystemTime()
  2364.  
  2365. local entry = {
  2366. time = timestamp,
  2367. category = raceData.category,
  2368. winningCombo = raceData.winningCombo,
  2369. winnerName = winner.dispName,
  2370. }
  2371. table.insert(xi.chocoboRacing.raceHistory, 1, entry)
  2372.  
  2373. -- Keep only last 10 races in memory
  2374. if #xi.chocoboRacing.raceHistory > 10 then
  2375. table.remove(xi.chocoboRacing.raceHistory)
  2376. end
  2377.  
  2378. -- Persistent SQL Storage (via C++ bindings)
  2379. -- Ensure the chocobo_race_history and chocobo_race_entries tables exist in your DB!
  2380. -- xi.chocoboRacing.RecordRaceResult(timestamp, raceData.category, raceData.winningCombo, winner.dispName) -- TODO: uncomment when C++ is bound
  2381.  
  2382. -- NOTE: C++ binding should also be expanded later to record each racer's stats to chocobo_race_entries
  2383. -- to support the web leaderboard/meta API! Example loop for when the binding is written:
  2384. -- for i = 1, 8 do
  2385. -- local r = raceData.racers[i]
  2386. -- xi.chocoboRacing.RecordRaceEntry(timestamp, r.playerID or 0, r.lane, r.placement, r.dispName, r.color, r.speed, r.stamina, r.skill, r.receptivity, r.item or 0)
  2387. -- end
  2388. end
  2389.  
  2390. -- Returns the last N race results
  2391. -- Source: Retail 'Recent Results' board/NPC interaction
  2392. xi.chocoboRacing.getRecentWinners = function(count)
  2393. count = count or 5
  2394. local results = {}
  2395. for i = 1, math.min(count, #xi.chocoboRacing.raceHistory) do
  2396. table.insert(results, xi.chocoboRacing.raceHistory[i])
  2397. end
  2398. return results
  2399. end
  2400.  
  2401. -----------------------------------
  2402. -- Stats & Leaderboard API (Future Capability)
  2403. --
  2404. -- These functions will need a backing SQL table (e.g. chocobo_race_history)
  2405. -- to persist results across server restarts. Schema sketch:
  2406. --
  2407. -- CREATE TABLE chocobo_race_history (
  2408. -- race_id INT AUTO_INCREMENT PRIMARY KEY,
  2409. -- race_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  2410. -- category INT UNSIGNED NOT NULL,
  2411. -- winning_combo TINYINT UNSIGNED NOT NULL,
  2412. -- -- per-lane columns or a child table for racer details
  2413. -- );
  2414. --
  2415. -- CREATE TABLE chocobo_race_bets (
  2416. -- bet_id INT AUTO_INCREMENT PRIMARY KEY,
  2417. -- race_id INT NOT NULL,
  2418. -- charid INT UNSIGNED NOT NULL,
  2419. -- combo_bet TINYINT UNSIGNED NOT NULL,
  2420. -- amount_bet INT UNSIGNED NOT NULL,
  2421. -- amount_won INT UNSIGNED NOT NULL DEFAULT 0,
  2422. -- FOREIGN KEY (race_id) REFERENCES chocobo_race_history(race_id)
  2423. -- );
  2424. --
  2425. -- Planned API endpoints / functions:
  2426. --
  2427. -- 1. RECENT WINNERS
  2428. -- xi.chocoboRacing.getRecentWinners(count)
  2429. -- Returns the last N race results with winning chocobo name, combo, and
  2430. -- category. Useful for NPC dialogue ("In the last race, [name] won!").
  2431. --
  2432. -- 2. BEST CHOCOBOS (period)
  2433. -- xi.chocoboRacing.getTopChocobos(period, limit)
  2434. -- period = 'day' | 'week' | 'month' | 'all'
  2435. -- Returns chocobos ranked by number of 1st place finishes in the period.
  2436. -- Display name, win count, categories won in.
  2437. --
  2438. -- 3. ALL-TIME WINNINGEST CHOCOBOS
  2439. -- xi.chocoboRacing.getAllTimeChampions(limit)
  2440. -- Returns chocobos with the most career 1st place finishes across all
  2441. -- categories and time. The "Hall of Fame".
  2442. --
  2443. -- 4. PLAYER BETTING STATS
  2444. -- xi.chocoboRacing.getPlayerBettingStats(charid)
  2445. -- Returns: total bets placed, total gil wagered, total gil won,
  2446. -- net profit/loss, win rate %, biggest single win.
  2447. --
  2448. -- 5. TOP GIL EARNERS
  2449. -- xi.chocoboRacing.getTopEarners(period, limit)
  2450. -- Players ranked by total gil won from betting in a given period.
  2451. -- Great for competitive leaderboards.
  2452. --
  2453. -- 6. RACE HISTORY (per player)
  2454. -- xi.chocoboRacing.getPlayerRaceHistory(charid, limit)
  2455. -- Returns the player's last N bets with race details, combo bet,
  2456. -- result, and payout. "Your recent bets" display.
  2457. --
  2458. -- 7. HOT STREAKS
  2459. -- xi.chocoboRacing.getPlayerStreak(charid)
  2460. -- Current win/loss streak and longest win streak ever.
  2461. -- Could trigger bonus payouts or titles at milestones.
  2462. --
  2463. -- 8. COMBO FREQUENCY STATS
  2464. -- xi.chocoboRacing.getComboStats(limit)
  2465. -- How often each winning combo (e.g. "12", "34") has hit.
  2466. -- Helps players make informed bets ("combo 23 hits 8% of the time").
  2467. --
  2468. -- 9. RACE IN PROGRESS
  2469. -- xi.chocoboRacing.getCurrentRaceInfo()
  2470. -- Returns current race state, category, racers, elapsed time.
  2471. -- For UI displays and NPC dialogue.
  2472. -----------------------------------
  2473.  
Add Comment
Please, Sign In to add comment