Tom_Neverwinter icon

chocobo_racing 02282026

Tom_Neverwinter | PRO | 03/01/26 03:55:12 AM UTC | 0 ⭐ | 163 👁️ | Never ⏰ | []
text |

108.39 KB

|

None

|

0 👍

/

0 👎

require('scripts/globals/chocobo_names')
-----------------------------------
-- Chocobo Racing
-- https://www.bg-wiki.com/ffxi/Category:Chocobo_Racing
-- https://ffxiclopedia.fandom.com/wiki/Chocobo_Racing_Guide
--
-- ============================================================================
-- 🛑 STRICT EMULATION WARNING 🛑
-- THIS IS EMULATION. DO NOT INVENT NEW MECHANICS, PACKET STRUCTURES,
-- OR 'SMART' LOGIC. Everything must strictly align with observed retail
-- behavior and verified packet captures. If retail FFXI didn't do it,
-- DO NOT add it.
-- ============================================================================
-----------------------------------
require('scripts/globals/packet')
-----------------------------------
xi = xi or {}
xi.chocoboRacing = xi.chocoboRacing or {}
xi.chocoboRacing.registeredPlayers = xi.chocoboRacing.registeredPlayers or {}
xi.chocoboRacing.activeBets = xi.chocoboRacing.activeBets or {}
 -- FOR HEAVILY-IN-DEVELOPMENT TESTING, you can force these setting:
-- Ensure these are moved to main settings files before release.
xi.settings.main.ENABLE_CHOCOBO_RACING = true
xi.settings.main.DEBUG_CHOCOBO_RACING = true
 -- Packet Flow Reference (from analysis of 18+ retail captures):
--
-- The race lifecycle uses these packets in coordination:
--   0x069 (ChocoboRacingSys)  — Race simulation data: params, stats, sections, results
--   0x05D (EventUpdateString) — Chocobo display names (4 per packet, 2 packets = 8 lanes)
--   0x05C (PendingNum)        — Event work params (numeric updates during cutscene)
--   0x034 (EventNum)          — Starts race cutscene events (numeric params)
--   0x033 (EventStr)          — Starts events WITH STRING PARAMS (player chocobo name)
--   0x036 (TalkNum)           — NPC announcer dialogue (zone text IDs from SevMess table)
--   0x027 (TalkNumWork2)      — NPC dialogue with string+number params (race results)
--   0x052 (EventUcOff)        — Releases client from pending state (Mode=1 in all captures)
--   0x05B (WPOS)              — Entity position updates (camera/NPC movement during race)
--   0x00E (CHAR_NPC)          — NPC entity spawn/update (jockey/chocobo models)
--
-- NOTE: 0x052 Mode=1 packets are generated automatically by the engine when
-- player:updateEvent() is called. They reset PTR_RecPendingFlag to 0, allowing
-- the client to continue processing. We do NOT need to send them manually.
--
-- SPECTATOR vs PLAYER-ENTERED RACES:
--   Spectator races (Multi-Race/Race#2/Race#4 captures):
--     - 0x034 starts event 210 with UniqueNo = Rungaga NPC
--     - RACE_PARAMS_0 = 0x28 (40), category = 0x80000000+
--     - All 8 lanes populated, NPC-only
--   Player-entered races ('Entering a chocobo' capture):
--     - 0x033 starts event with player's chocobo name (String[0-3] = 'IrisAudace')
--     - RACE_PARAMS_0 = 0x24 (36), category = 0x6030 (different encoding!)
--     - Only 4 of 8 lanes may be populated
--     - Player's chocobo is always in lane 1
--     - Mode 2 has fewer populated racer entries
--
-- ============================================================================
-- FULL PACKET AUDIT (all observed packet types across all captures)
-- ============================================================================
--
-- INCOMING (Server -> Client) -- 24 unique packet types observed:
--
-- RACE-CRITICAL (must implement for races to work):
--   0x069  ChocoboRacingSys    Race sim data (params/stats/sections/results)  [HANDLED]
--   0x05D  EventUpdateString   Chocobo display names (4 per packet)          [HANDLED]
--   0x05C  PendingNum          Event work params (numeric updates in CS)     [HANDLED]
--   0x034  EventNum            Starts spectator race event (Event 210)       [HANDLED]
--   0x033  EventStr            Starts player-entered event (Event 469)       [NPC SCRIPT]
--   0x052  EventUcOff          Releases client pending state (Mode=1)        [AUTO/engine]
--
-- RACE-SUPPORT (enhances race experience):
--   0x036  TalkNum             NPC announcer dialogue (SevMess text IDs)     [HANDLED]
--   0x027  TalkNumWork2        NPC dialogue with string+number params        [NPC SCRIPT]
--   0x00E  CHAR_NPC            NPC entity spawn/update (jockeys/chocobos)    [HANDLED]
--   0x067  (Unknown)           Entity extra data (Mode 2=PC refresh,         [ENGINE]
--                               Mode 3=NPC name/metadata on state change)
--   0x05B  WPOS (incoming)     Entity position updates from server           [VIA setPos()]
--
-- ENVIRONMENTAL (zone infrastructure, not race-specific):
--   0x00D  CHAR_PC             Player character data (position/flags/model)  [ENGINE]
--   0x017  CHAT_STD            Standard chat messages                       [ENGINE]
--   0x01D  ITEM_SAME           Inventory container load state                [ENGINE]
--   0x028  BATTLE2             Battle action results                         [ENGINE N/A]
--   0x029  BATTLE_MESSAGE      Combat messages                               [ENGINE N/A]
--   0x02D  BATTLE_MESSAGE2     End-of-combat messages (EXP/chains)           [ENGINE N/A]
--   0x037  SERVERSTATUS        Entity status/flags update                    [ENGINE]
--   0x057  WEATHER             Zone weather updates                          [ENGINE]
--   0x0DF  GROUP_ATTR          Party member info updates                     [ENGINE]
--   0x0F4  TRACKING_LIST       Wide Scan entries                             [ENGINE]
--   0x0F6  TRACKING_STATE      Wide Scan list start/end                      [ENGINE]
--   0x110  (Unity)             Unity/Sparks/Deeds info                       [ENGINE]
--   0x111  (RoE Active)        Records of Eminence active quest log          [ENGINE]
--   0x112  (RoE Complete)      Records of Eminence completion flags          [ENGINE]
--
-- OUTGOING (Client -> Server) -- 5 unique packet types observed:
--   0x015  POS                 Client position update (every frame)          [ENGINE]
--   0x016  (Unknown)           Client -> server updates                      [ENGINE]
--   0x05B  EVENT_OPTION        Client event response (option selection)      [VIA updateEvent]
--   0x03A  ITEM_STACK          Inventory sort request                        [ENGINE]
--   0x0F4  TRACKING_REQ        Wide Scan request                             [ENGINE]
--
-- Legend:
--   [HANDLED]     = Implemented in this module
--   [NPC SCRIPT]  = Belongs in NPC attendant script (see event469_reference)
--   [INVESTIGATE] = Needs further analysis
--   [AUTO/engine] = Generated automatically by engine internals
--   [ENGINE]      = Handled by game engine, not our responsibility
--   [ENGINE N/A]  = Engine handles it; not relevant to racing
--   [VIA xxx]     = Indirectly handled through named API call
--
-- REMAINING TODO:
--   - Polish navmesh coordinates once FFXI server pathing is confirmed.
--
-- RESOLVED:
--   0x00E -- Custom packet builder implemented natively via sendDebugPacket.
--      Spawns visible jockey/chocobo entities dynamically. FFXI FFXI FFXI
--   0x067 -- Analyzed: Mode 2 = periodic player entity refresh (every 2-3 min),
--      Mode 3 = NPC name/metadata push when NPC state changes (race staff NPCs
--      ActIndex 0xB6-0xC1). Both generated automatically by the engine.
-- ============================================================================
 -- ============================================================================
-- SERVER-BASE COMPLIANCE CHECKLIST (verified against server-base ref code)
-- ============================================================================
-- [OK] Packet format: 0xC8 (200) byte arrays, 1-indexed Lua tables
-- [OK] sendDebugPacket: Confirmed C++ binding (lua_baseentity.cpp:1012)
-- [OK] Mode 1: Race number + category LE encoding
-- [OK] Mode 2: 12-byte blocks aligned (name1/name2/speed/stam/skill/race+color/silks/saddle/item)
-- [OK] Mode 3: Section data with ParamIndex/ParamSize
-- [OK] Mode 5: Ready signal
-- [OK] startEvent(210, ...): Spectator race event trigger with 8 numeric params
-- [OK] updateEvent(): GP_SERV_COMMAND_PENDINGNUM for numeric CS updates
-- [OK] updateEventString(): GP_SERV_COMMAND_PENDINGSTR for name delivery (4 strings)
-- [OK] onEventUpdate/onEventFinish: Zone.lua delegates to xi.chocoboRacing.*
-- [OK] DefaultActions.lua: NPC name->event mapping (no text needed, all preset)
-- [OK] NPC pools with name1/name2 indices from chocobo_names.lua
-- [OK] Category constants match: 0x80000000-0x80000003
-- [OK] Race scheduling: 15-min slots, 8-slot 2-hour cycle
-- [OK] require('scripts/globals/chocobo_names')
--
-- [DIFF] getLocalTextVar: Server-base uses it but C++ binding doesn't exist.
--        We use currentRaceData for string retrieval instead. Functionally identical.
-- [DIFF] Mode 4: Server-base uses winnings+combo+flags format.
--        We use nibble-packed placements. Both approaches are valid —
--        server-base's format is closer to retail captures.
-- [DIFF] sendPacket vs sendDebugPacket: Server-base calls sendPacket (alias).
--        Functionally identical to sendDebugPacket.
--
-- [OK] Chocobuck system (getChocobucks/addChocobucks/purchaseItem)
-- [OK] Player registration (registerPlayer/getStatsForRacing)
-- [OK] Free run fee management (getFreeRunFee)
-- [OK] Equipment/saddle stat modifications (applyEffects)
-- [OK] Betting event handler (onBettingEventFinish)
-- [OK] Full payout processing (processPayouts)
-- [OK] NPC interaction handler (onNPCInteraction)
-- [OK] Zone tick integration (onZoneTick)
-- ============================================================================
 -- Notes:
-- Since there is a timed element, packet elements, and a lot of data to fill in,
-- it makes sense to move pretty much everything apart from the CS handling down
-- into core. While developing, things can stay up here, but as this approaches
-- a stable state everything should be pushed down.
 -- To Run:
-- !exec xi.chocoboRacing.startRace()
 -- https://github.com/atom0s/XiPackets/blob/main/world/server/0x0069/README.md
 -----------------------------------
-- Constants
-----------------------------------
local constants = {
    -- Packet 0x0069 Control Modes
    MODE = {
        REGISTRATION = 0x01, -- Updates RacingParams (Weather, Track, League)
        CHOCOBO_DATA = 0x02, -- Updates ChocoboParams (Stats, Names, Jockey Silks, Saddles)
        SECTION_DATA = 0x03, -- Updates SectionParams (Movement Pathing)
        RESULT_DATA  = 0x04, -- Updates ResultParams (Winners, Winnings, Final Pathing)
        READY_SIGNAL = 0x05, -- Finalizes setup and tells client to start the race sequence
    },
     -- Race number constant (used in Mode 1 RaceParams)
    RACE_PARAMS_0 = 0x28, -- 40 decimal, confirmed from retail captures
     -- Race League Categories (Packet Mode 1 - RaceParams bytes 12-15)
    -- NOTE: High byte is always 0x80 (set in packet builder)
    CATEGORY = {
        C4_REGULAR        = 0x80000000, -- 2147483648 - Beginner League
        C1_CRYSTAL_STAKES = 0x80000001, -- 2147483649 - Elite League
        C2_REGULAR        = 0x80000002, -- 2147483650 - Expert League
        C3_REGULAR        = 0x80000003, -- 2147483651 - Standard League
    },
     -- Jockey Race/Gender Models
    RACE = {
        GALKA        = 0,
        HUME_M       = 1,
        HUME_F       = 2,
        ELVAAN_M     = 3,
        ELVAAN_F     = 4,
        TARUTARU_M   = 5,
        TARUTARU_F   = 6,
        MITHRA       = 7,
        GALKA_2      = 8,
        HUME_M_2     = 9,
        HUME_F_2     = 10,
        ELVAAN_M_2   = 11,
        ELVAAN_F_2   = 12,
        TARUTARU_M_2 = 13,
        TARUTARU_F_2 = 14,
        MITHRA_2     = 15
    },
     -- Chocobo Color Visuals (Packet Mode 2, Byte 7 low nibble)
    -- Source: Confirmed from official implementation + real packet captures.
    -- Values 0-8 are the ONLY documented colors in retail.
    -- 0 and 1 both render Yellow; 2 and 3 both render Black; 4 and 5 both render Blue.
    COLOR = {
        YELLOW_1 = 0,  -- Yellow (primary)
        YELLOW_2 = 1,  -- Yellow (variant) — confirmed by official
        BLACK_1  = 2,  -- Black  (primary)
        BLACK_2  = 3,  -- Black  (variant) — confirmed by official
        BLUE_1   = 4,  -- Blue   (primary)
        BLUE_2   = 5,  -- Blue   (variant) — confirmed by official
        RED_1    = 6,  -- Red    (primary)
        RED_2    = 7,  -- Red    (variant)
        GREEN    = 8,  -- Green
        -- Values 9-15 exist in retail data but are NOT documented by the official.
        -- They may be rare/unique colors or map to Yellow by default on some clients.
        RARE_9   = 9,
        RARE_10  = 10,
        RARE_11  = 11,
        RARE_12  = 12,
        RARE_14  = 14,
        RARE_15  = 15,
    },
     -- Jockey Silk/Clothing Visual IDs (Packet Mode 2, Byte 8)
    SILKS = {
        NONE    = 0,
        ID_22   = 22,
        ID_36   = 36,
        ID_44   = 44,
        ID_63   = 63,
        ID_64   = 64,
        ID_96   = 96,
        ID_100  = 100,
        ID_127  = 127,
        ID_128  = 128,
        ID_129  = 129,
        ID_160  = 160,
        ID_191  = 191,
        ID_192  = 192,
        ID_224  = 224,
        ID_254  = 254,
        ID_255  = 255,
    },
     -- Environmental Weather States (Mode 1, Byte 17)
    WEATHER = {
        FINE      = 0,
        CLOUDY    = 1,
        RAINY     = 2,
        HEAT_WAVE = 3,
        THUNDER   = 4
    },
     -- Track Surface Conditions (Mode 1, Byte 18)
    CONDITION = {
        DRY         = 0,
        NORMAL      = 1,
        HEAVY       = 2,
        SOFT        = 3,
        UNKNOWN_183 = 183,
        UNKNOWN_207 = 207,
        UNKNOWN_255 = 255,
    },
     -- Saddle Item IDs (Packet Mode 2, Byte 9)
    SADDLE = {
        NONE            = 0,
        LAUAN           = 5,
        ASH             = 26,
        ELM             = 36,
        BRASS           = 50,
        SILVER          = 63,
        MYTHRIL         = 64,
        GRASS           = 95,
        COTTON          = 96,
        LINEN           = 128,
        RABBIT_HIDE     = 129,
        SHEEP_LEATHER   = 136,
        BUFFALO_LEATHER = 160,
    },
     -- Passive Equips (Increase stats for the duration of the race)
    EQUIP = {
        CHOCOBO_TAPING   = 20, -- Strength+
        CHOCOBO_BLINKERS = 21, -- Stamina+
        SHADOW_ROLL      = 22, -- Discernment+
        CHOCOBO_HOOD     = 23, -- Receptivity+
    },
     TEAM_ID = { SANDORIA = 1, BASTOK = 2, WINDURST = 3 },
    RACE_TYPE = { FREE_RUN = 0, OFFICIAL = 1, RE_RUN = 2 },
     -- ========================================================================
    -- OFFICIAL RACE DATA
    -- Source: Verified against FFXI retail data (BG-Wiki/Chocobo Racing)
    -- These parameters control fees, rewards, and entry requirements.
    -- ========================================================================
    OFFICIAL_RACES = {
        -- Novice
        ["NOVICE_I"]   = { fee = 0,  bucks = 0,  gil = 500,  opponents = 4 },
        ["NOVICE_II"]  = { fee = 0,  bucks = 0,  gil = 1000, opponents = 6 },
        ["NOVICE_III"] = { fee = 0,  bucks = 3,  gil = 1500, opponents = 8 },
        -- Challenge & International
        ["CHALLENGE_I"]  = { fee = 3,  bucks = 0,  gil = 3000, opponents = 2 },
        ["INT_I"]        = { fee = 5,  bucks = 0,  gil = 5000, opponents = 8 },
        ["INT_II"]       = { fee = 10, bucks = 0,  gil = 5500, opponents = 8 },
        ["CHALLENGE_II"] = { fee = 15, bucks = 0,  gil = 6000, opponents = 3 },
        ["CLASSIC"]      = { fee = 20, bucks = 10, gil = 6500, opponents = 8, title = "Chocorookie" },
        -- Pashhow Swamptrot
        ["DUELER"]    = { fee = 25, bucks = 15, gil = 7000, opponents = 2 },
        ["SURVIVAL"]  = { fee = 30, bucks = 15, gil = 7500, opponents = 8 },
        ["DEADLY"]    = { fee = 35, bucks = 20, gil = 8000, opponents = 8 },
        ["MYSTERY"]   = { fee = 40, bucks = 20, gil = 8500, opponents = 8 },
        ["LETHAL"]    = { fee = 45, bucks = 30, gil = 9000, opponents = 8, prize = "Pullus Torque" },
        ["DREAM"]     = { fee = 50, bucks = 30, gil = 10000, opponents = 8, title = "Chocochampion" },
    },
     JOCKEY_ORDERS = {
        KEEP_PACE   = 0, -- Conserve energy, consistent speed
        FINAL_SPURT = 1, -- Save stamina for the last 25% of race
        SPRINT      = 2  -- Max speed from start, heavy stamina drain
    },
     -- Zone text IDs for race announcer NPC dialogue (0x036 TalkNum packets)
    -- Extracted from MesNum field across all 18 retail captures.
    -- These are SevMess dialog table indices for zone 70 (Chocobo Circuit).
    TEXT = {
        -- Pre-race phase: NPC announcers call out race details
        -- Pattern from captures: fired at XX:45:19 - XX:45:49 (6 messages per race)
        RACE_ANNOUNCE_1     = 0xA456, -- "The [category] race is about to begin!"
        RACE_ANNOUNCE_2     = 0xA458, -- NPC announces first chocobo details
        RACE_ANNOUNCE_3     = 0xA459, -- NPC announces second chocobo details
        RACE_ANNOUNCE_4     = 0xA45A, -- NPC announces third chocobo details
        RACE_ANNOUNCE_5     = 0xA45C, -- NPC announces lane pair A
        RACE_ANNOUNCE_6     = 0xA45D, -- NPC announces lane pair B
        RACE_ANNOUNCE_FINAL = 0xA3F1, -- "All chocobos are at the starting gate!"
         -- Race start: fired at XX:50:55 - XX:51:05
        RACE_STARTING_1     = 0xA45E, -- "And they're off!" (first NPC)
        RACE_STARTING_2     = 0xA45F, -- "And they're off!" (second NPC)
        RACE_START_BELL     = 0x25AE, -- Race start bell / horn (announcer NPC 0xB6)
         -- Race finish: fired at XX:51:35
        RACE_FINISH_BELL    = 0x25AF, -- Race finish bell / horn
         -- Results: fired after finish (different NPCs announce different details)
        RESULT_1ST          = 0x25BC, -- "1st place: ..."
        RESULT_2ND          = 0x25BD, -- "2nd place: ..."
        RESULT_WIN_A        = 0x2604, -- Winner details (NPC 0xBF/0xC1)
        RESULT_WIN_B        = 0x2605, -- Winner payout
        RESULT_DETAIL_A     = 0x260E, -- Additional result detail
        RESULT_DETAIL_B     = 0x260F, -- Additional result detail
        RESULT_RECAP_A      = 0x25E4, -- Recap from track NPC
        RESULT_RECAP_B      = 0x25E5, -- Recap from track NPC
        RESULT_RECAP_C      = 0x25EE, -- Final recap
        RESULT_RECAP_D      = 0x25EF, -- Final recap
         -- Intermission: fired between races (XX:55:23, XX:10:23, etc)
        INTERMISSION        = 0xA3F5, -- "Next race will begin shortly."
    },
}
xi.chocoboRacing.constants = constants
 -- -----------------------------------------------------------------
-- Chocobuck & Affiliation Helpers
-- -----------------------------------------------------------------
 xi.chocoboRacing.getAffiliation = function(player)
    return player:getCharVar("CR_TEAM_AFFILIATION")
end
 xi.chocoboRacing.setAffiliation = function(player, teamId)
    player:setCharVar("CR_TEAM_AFFILIATION", teamId)
end
 xi.chocoboRacing.getChocobucks = function(player, teamId)
    local team = teamId or xi.chocoboRacing.getAffiliation(player)
    if not team or team < 1 or team > 3 then
        return 0
    end
    -- Use persistent Character Variable instead of unsupported raw SQL
    return player:getCharVar(string.format("CR_BUCKS_%d", team))
end
 xi.chocoboRacing.addChocobucks = function(player, amount, teamId)
    local team = teamId or xi.chocoboRacing.getAffiliation(player)
    if not team or team < 1 or team > 3 then
        return 0
    end
    local current = xi.chocoboRacing.getChocobucks(player, team)
     -- Retail Clamp: 0 to 1000
    local newAmount = math.max(0, math.min(1000, current + amount))
     player:setCharVar(string.format("CR_BUCKS_%d", team), newAmount)
    return newAmount
end
 -- -----------------------------------------------------------------
-- Shop & Items
-- -----------------------------------------------------------------
 local SHOP_ITEMS_TIER1 = {
    5605, -- Sharug Greens
    5606, -- Azouph Greens
    2201, -- Tokopekko Wildgrass
    2202, -- Garidav Wildgrass
    5607, -- Vomp Carrot
    5608, -- Zegham Carrot
}
 local SHOP_ITEMS_TIER2 = {
    2210, -- Vegetable Paste
    2208, -- Herb Paste
    2206, -- Carrot Paste
    2209, -- Worm Paste
    2203, -- Cupid Worm
    2204, -- Parasite Worm
    2205, -- Gregarious Worm
    2645, -- Eastern Ginger
}
 local SHOP_ITEMS_TIER3 = {
    647, -- Lauan Lumber
    641, -- Ash Lumber
    642, -- Elm Lumber
    624, -- Brass Ingot
    625, -- Silver Ingot
    626, -- Mythril Ingot
}
 local SHOP_ITEMS_TIER4 = {
    2403, -- Chocotrain: Speed / Strength
    2404, -- Chocotrain: Endurance
    2405, -- Chocotrain: Discernment
    2406, -- Chocotrain: Receptivity
    2407, -- Chocotrain token
}
 local SHOP_ITEMS_TIER5 = {
    2380, -- Red Chocobo Dye
    2381, -- Blue Chocobo Dye
    2382, -- Green Chocobo Dye
    2383, -- Black Chocobo Dye
    2379, -- Yellow Chocobo Dye
}
 local SHOP_ITEMS_TIER6 = {
    11326, -- Red Race Silks
    11328, -- Green Race Silks
    11325, -- Blue Race Silks
    11322, -- Black Race Silks
    11327, -- White Race Silks
    11321, -- Orange Race Silks
    11324, -- Sky Blue Race Silks
    11323, -- Purple Race Silks
}
 xi.chocoboRacing.purchaseItem = function(player, option, teamId, count)
    count = count or 1
    local itemId = 0
    local costPerItem = 0
     if option >= 1 and option <= #SHOP_ITEMS_TIER1 then
        itemId = SHOP_ITEMS_TIER1[option]
        costPerItem = 1
    elseif option > #SHOP_ITEMS_TIER1 and option <= (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2) then
        itemId = SHOP_ITEMS_TIER2[option - #SHOP_ITEMS_TIER1]
        costPerItem = 3
    elseif option > (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2) and option <= (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3) then
        itemId = SHOP_ITEMS_TIER3[option - (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2)]
        costPerItem = 10
    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
        itemId = SHOP_ITEMS_TIER4[option - (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3)]
        costPerItem = 20
    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
        itemId = SHOP_ITEMS_TIER5[option - (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3 + #SHOP_ITEMS_TIER4)]
        costPerItem = 75
    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
        itemId = SHOP_ITEMS_TIER6[option - (#SHOP_ITEMS_TIER1 + #SHOP_ITEMS_TIER2 + #SHOP_ITEMS_TIER3 + #SHOP_ITEMS_TIER4 + #SHOP_ITEMS_TIER5)]
        costPerItem = 150
    end
     if itemId == 0 then return false end
     local totalCost = costPerItem * count
    -- Retail Rule: Bonus item only for Tier 1 (1 Buck) and Tier 2 (3 Bucks)
    local bonus = (costPerItem == 1 or costPerItem == 3) and math.floor(count / 5) or 0
    local totalItems = count + bonus
     local currentBucks = xi.chocoboRacing.getChocobucks(player, teamId)
    if currentBucks < totalCost then
        player:printToPlayer("You do not have enough Chocobucks.", xi.msg.channel.SYSTEM_3)
        return false
    end
     if player:getFreeSlotsCount() < 1 then
        player:printToPlayer("Your inventory is full.", xi.msg.channel.SYSTEM_3)
        return false
    end
     xi.chocoboRacing.addChocobucks(player, -totalCost, teamId)
    player:addItem(itemId, totalItems)
    player:messageSpecial(zones[xi.zone.CHOCOBO_CIRCUIT].text.ITEM_OBTAINED, itemId, totalItems)
    return true
end
 -- -----------------------------------------------------------------
-- Free Run & Fee Management
-- -----------------------------------------------------------------
 xi.chocoboRacing.getFreeRunFee = function(player)
    local count = player:getCharVar("CR_FREE_RUN_COUNT")
    local fees = { 100, 150, 200, 300, 400, 600, 800, 1000, 1500, 2000 }
    local fee = fees[math.min(#fees, count + 1)] or 2000
     if count >= 50 then fee = 100000 end
    return fee
end
 -- -----------------------------------------------------------------
-- Equipment & Saddle Effects
-- -----------------------------------------------------------------
 local equipmentMod = {
    [constants.SADDLE.LAUAN]           = { skill = 5, stamina = 5 },
    [constants.SADDLE.ASH]             = { endurance = 10, stamina = 5 },
    [constants.SADDLE.ELM]             = { endurance = 10, stamina = 5 },
    [constants.SADDLE.BRASS]           = { endurance = -10, speed = 15 },
    [constants.SADDLE.SILVER]          = { endurance = -10, speed = 15 },
    [constants.SADDLE.MYTHRIL]         = { endurance = -10, speed = 15 },
    [constants.SADDLE.GRASS]           = { speed = 20, endurance = 10 },
    [constants.SADDLE.COTTON]          = { speed = 20, endurance = 10 },
    [constants.SADDLE.LINEN]           = { speed = 20, endurance = 10 },
    [constants.SADDLE.RABBIT_HIDE]     = { speed = 20, endurance = 10 },
    [constants.SADDLE.SHEEP_LEATHER]   = { speed = 20, endurance = 10 },
    [constants.SADDLE.BUFFALO_LEATHER] = { speed = 20, endurance = 10 },
     [constants.EQUIP.CHOCOBO_TAPING]   = { speed = 10 },
    [constants.EQUIP.CHOCOBO_BLINKERS] = { stamina = 10 },
    [constants.EQUIP.SHADOW_ROLL]      = { skill = 10 },
    [constants.EQUIP.CHOCOBO_HOOD]     = { skill = 5, stamina = 5 },
}
 xi.chocoboRacing.applyEffects = function(choco, equipId)
    local mod = equipmentMod[equipId]
    if mod then
        if mod.speed     then choco.speed   = math.max(1, math.min(255, choco.speed + mod.speed)) end
        if mod.stamina   then choco.stamina = math.max(1, math.min(255, choco.stamina + mod.stamina)) end
        if mod.skill     then choco.skill   = math.max(1, math.min(255, choco.skill + mod.skill)) end
        if mod.endurance then
            choco.stamina = math.max(1, math.min(255, choco.stamina + mod.endurance))
        end
    end
end
 -- -----------------------------------------------------------------
-- Player Registration Helper
-- -----------------------------------------------------------------
 local nameLookupCache = nil
local function getChocoboNameIndex(nameStr)
    if not nameStr or nameStr == "" then return 0 end
    -- Build the reverse lookup cache once
    if not nameLookupCache then
        nameLookupCache = {}
        for id, str in pairs(xi.chocoboNames) do
            nameLookupCache[str] = id
        end
    end
    return nameLookupCache[nameStr] or 0
end
 xi.chocoboRacing.getStatsForRacing = function(player)
    if not xi.chocoboRaising then return nil end
    local choco = xi.chocoboRaising.initChocoboData(player)
    if not choco then return nil end
     -- char_chocobos stores names as strings. Packets require uint16 IDs.
    -- We reverse-lookup the strings against chocobo_names.lua to get the IDs.
    local name1Id = getChocoboNameIndex(choco.first_name or choco.name1)
    local name2Id = getChocoboNameIndex(choco.last_name or choco.name2)
     return {
        name1        = name1Id,
        name2        = name2Id,
        speed        = choco.strength or 32,
        stamina      = choco.endurance or 32,
        skill        = choco.discernment or 32,
        receptivity  = choco.receptivity or 32,
        race         = player:getRace(),
        color        = choco.color or 0,
        silks        = 0,
        saddle       = choco.saddle or 0,
        item         = 0,
    }
end
 xi.chocoboRacing.registerPlayer = function(player)
    local stats = xi.chocoboRacing.getStatsForRacing(player)
    if stats then
        stats.playerID = player:getID()
        -- Keep in memory for the active race burst building
        xi.chocoboRacing.registeredPlayers[player:getID()] = stats 
         -- Persist snapshot to SQL
        local dispName = resolveChocoboName(stats.name1, stats.name2)
        local query = string.format([[
            INSERT INTO chocobo_race_registration 
            (charid, registered_at, speed, stamina, skill, receptivity, chocobo_name, color, silks, saddle) 
            VALUES (%d, %d, %d, %d, %d, %d, '%s', %d, %d, %d)
            ON DUPLICATE KEY UPDATE 
            registered_at = VALUES(registered_at), speed = VALUES(speed), stamina = VALUES(stamina), 
            skill = VALUES(skill), receptivity = VALUES(receptivity), chocobo_name = VALUES(chocobo_name), 
            color = VALUES(color), silks = VALUES(silks), saddle = VALUES(saddle)
        ]], 
        player:getID(), os.time(), stats.speed, stats.stamina, stats.skill, stats.receptivity, 
        sql:escape(dispName), stats.color, stats.silks, stats.saddle)
         sql:query(query)
        return true
    end
     player:printToPlayer("You must have a raised chocobo to register for official races.", xi.msg.channel.SYSTEM_3)
    return false
end
-- Schedule a race to start in the near future (e.g. for player-entered races)
xi.chocoboRacing.scheduleRaceStart = function(player, category)
    -- This sets a global request to transition the state machine to PREPARE
    -- in the next available window, or manually overrides the clock.
    -- For now, we'll just flag the next race to be of this category.
    player:setLocalVar("CR_CATEGORY", category)
    xi.chocoboRacing.registerPlayer(player)
    logInfo(string.format('Player %s scheduled Category %d race.', player:getName(), category))
end
 -- -----------------------------------------------------------------
-- Visual Generation Helpers
-- -----------------------------------------------------------------
 local function getRandomColor()
    -- Stick to the 9 colors confirmed by the official reference (0x0-0x8).
    -- Values 0+1=Yellow, 2+3=Black, 4+5=Blue, 6+7=Red, 8=Green.
    -- Skipping 0x8 variants (9-15) since they are unconfirmed and may render
    -- as default yellow on some clients.
    local colors = { 0, 1, 2, 3, 4, 5, 6, 7, 8 }
    return colors[math.random(#colors)]
end
 local function getRandomSilks()
    local silks = { 0, 64, 96, 128, 160, 192, 224, 255 }
    return silks[math.random(#silks)]
end
 local function getRandomSaddle()
    local saddles = { 0, 5, 26, 36, 50, 63, 64, 95, 96, 128, 129, 136, 160 }
    return saddles[math.random(#saddles)]
end
 local function getRandomItem()
    -- Use defined EQUIP constants only — raw small even numbers are undefined and cause visual glitches.
    local items = {
        0,  -- No item
        constants.EQUIP.CHOCOBO_TAPING,   -- 20: Strength+
        constants.EQUIP.CHOCOBO_BLINKERS, -- 21: Stamina+
        constants.EQUIP.SHADOW_ROLL,      -- 22: Discernment+
        constants.EQUIP.CHOCOBO_HOOD,     -- 23: Receptivity+
    }
    return items[math.random(#items)]
end
 local State = {
    IDLE     = 0,
    PREPARE  = 1,
    RACING   = 2,
    FINISHED = 3,
}
xi.chocoboRacing.State = State
 -- Helper to construct a hex-string look_t for insertDynamicEntity
local function makeMountLook(r)
    -- size 7 = MODEL_CHOCOBO
    -- color/saddle/item mapped to head/body/hands slots
    return string.format("07000000%04x%04x%04x00000000000000000000",
        r.color or 0, r.saddle or 0, r.item or 0)
end
 local function makeJockeyLook(r)
    -- size 1 = MODEL_EQUIPPED (Standard Human)
    -- Map race (often 0-15 in packet data) cleanly to 1-8 valid player races to prevent 'All Galka' bug
    local modelId = 1
    if r and r.race then
        modelId = ((r.race - 1) % 8) + 1
    end
    -- string format is 0100 -> Type 1, followed by size/model bytes.
    return string.format("0100%02x000000%04x000000000000000000000000",
        modelId, r and r.silks or 0)
end
 local function spawnVisualRacers(zone, raceData)
    if not raceData or not raceData.racers then return end
    for lane = 1, 8 do
        local r = raceData.racers[lane]
        if r then
            local x = TRACK_WAYPOINTS[1].x
            local y = TRACK_WAYPOINTS[1].y
            local z = TRACK_WAYPOINTS[1].z + (lane - 1) * 2.5
             -- Spawn Mount (Chocobo)
            local mount = zone:insertDynamicEntity({
                objtype    = 2, -- TYPE_NPC
                name       = string.format("Mount_%d", lane),
                packetName = r.dispName or "Chocobo",
                look       = makeMountLook(r),
                x = x, y = y, z = z,
                rotation   = 64,
                releaseIdOnDisappear = true
            })
            if mount then
                r.mountId  = mount:getID()
                r.mountRef = mount -- Cache entity reference (avoids GetNPCByID per tick)
            end
             -- Spawn Jockey
            local jockey = zone:insertDynamicEntity({
                objtype    = 2, -- TYPE_NPC
                name       = string.format("Jockey_%d", lane),
                look       = makeJockeyLook(r),
                x = x, y = y, z = z,
                rotation   = 64,
                releaseIdOnDisappear = true
            })
            if jockey then
                r.jockeyId  = jockey:getID()
                r.jockeyRef = jockey -- Cache entity reference
            end
        end
    end
end
 local function despawnVisualRacers(raceData)
    if not raceData or not raceData.racers then return end
    for lane = 1, 8 do
        local r = raceData.racers[lane]
        if r then
            local mount = GetNPCByID(r.mountId)
            if mount then mount:setStatus(2) end -- DISAPPEAR (triggered by m_bReleaseTargIDOnDisappear)
             local jockey = GetNPCByID(r.jockeyId)
            if jockey then jockey:setStatus(2) end
        end
    end
end
 -- Dummy NPC IDs for supplementary gate/mount entities.
-- The 0x069 system works WITHOUT these. If IDs don't exist in npc_list,
-- GetNPCByID returns nil and setPos calls are silently skipped.
-- We now overwrite these with dynamic IDs from insertDynamicEntity.
local JOCKEY_BASE_ID  = 0x01000010
local CHOCOBO_BASE_ID = 0x0100001B
 -- Track waypoints used only for supplementary NPC setPos() movement.
-- The client uses Mode 3 SectionParams for its own race rendering.
-- Currently using static waypoints for FFXI zone 70.
--
-- These 9 points trace a full oval lap.  Point [9] closes the loop back
-- to the finish gate (same location as [1]) so that progress=100% places
-- entities exactly at the finish line rather than stranded on the left side.
-- Adjust all X/Z values once real navmesh coordinates are confirmed.
local TRACK_WAYPOINTS = {
    { x = -50.0,  y = -5.0, z = -100.0 },  -- [1] START / FINISH GATE
    { x =  50.0,  y = -5.0, z = -100.0 },  -- [2] top-right
    { x =  100.0, y = -5.0, z =  -50.0 },  -- [3] right-upper
    { x =  100.0, y = -5.0, z =   50.0 },  -- [4] right-lower
    { x =   50.0, y = -5.0, z =  100.0 },  -- [5] bottom-right
    { x =  -50.0, y = -5.0, z =  100.0 },  -- [6] bottom-left
    { x = -100.0, y = -5.0, z =   50.0 },  -- [7] left-lower
    { x = -100.0, y = -5.0, z =  -50.0 },  -- [8] left-upper
    { x =  -50.0, y = -5.0, z = -100.0 },  -- [9] FINISH (same as [1], closes loop)
}
local TRACK_LENGTH = 1000
 -----------------------------------
-- Module-Level State
-----------------------------------
local moduleState = {
    raceState   = State.IDLE,
    raceTick    = 0,
    prepareTime = 0,
    finishTime  = 0,
    raceCounter = 0x28, -- Sequential race number (retail: varies per race, starts ~40)
}
 xi.chocoboRacing.activeBets        = {}
xi.chocoboRacing.registeredPlayers = {}
xi.chocoboRacing.currentRaceData   = nil
 -- Load named rivals from DB at require() time (server startup).
-- The function is defined later in this file but Lua resolves it at call time,
-- so we defer via a small shim. buildRaceField also calls it lazily as a safety net.
xi.chocoboRacing._initRivals = function()
    if type(xi.chocoboRacing.loadNpcRivals) == 'function' then
        xi.chocoboRacing.loadNpcRivals()
    end
end
 -- Resolve name1/name2 indices into a display name string
-- using the chocobo_names table from chocobo_names.lua
local function resolveChocoboName(name1, name2)
    local part1 = xi.chocoboNames and xi.chocoboNames[name1] or nil
    local part2 = xi.chocoboNames and xi.chocoboNames[name2] or nil
    if part1 and part2 and name2 ~= 0 then
        return part1 .. part2
    elseif part1 then
        return part1
    end
    return 'Chocobo'
end
 -----------------------------------
-- Procedural Racer Generation
--
-- Instead of a fixed pool that repeats the same faces every race, each race
-- generates 8 unique entrants on the fly. Names are drawn from the full
-- xi.chocoboNames index space (700+ entries), giving thousands of unique
-- combinations. Stats are randomised within per-category bands so that
-- tier difficulty is preserved without identical competitors appearing again.
--
-- "Named rivals" are famous NPC chocobos that can make a guest appearance
-- in any race of the appropriate category. Each rival has a fixed ~15% chance
-- to enter a given race (checked once per rival per race). At most 3 named
-- rivals will ever appear in a single race to leave room for fresh faces.
-----------------------------------
 -- Category stat bands: { speed_min, speed_max, stamina_min, stamina_max, skill_min, skill_max }
local CATEGORY_STATS = {
    [constants.CATEGORY.C4_REGULAR]        = { 28, 52,  24, 48,  24, 48 },
    [constants.CATEGORY.C3_REGULAR]        = { 60, 95,  55, 85,  55, 90 },
    [constants.CATEGORY.C2_REGULAR]        = { 140, 200, 110, 170, 130, 190 },
    [constants.CATEGORY.C1_CRYSTAL_STAKES] = { 190, 255, 150, 255, 180, 255 },
}
 -- Map the 0x80000000-style category constants to the 0-3 tier index stored in the DB.
local CATEGORY_TIER = {
    [constants.CATEGORY.C4_REGULAR]        = 0,
    [constants.CATEGORY.C3_REGULAR]        = 1,
    [constants.CATEGORY.C2_REGULAR]        = 2,
    [constants.CATEGORY.C1_CRYSTAL_STAKES] = 3,
}
 local NPC_POOLS = {
    [constants.CATEGORY.C4_REGULAR] = {
        { 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},
        { 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},
        { 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},
        { 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},
        { 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},
        { 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},
        { 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},
        { 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},
    },
    [constants.CATEGORY.C3_REGULAR] = {
        { 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},
        { 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},
        { 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},
        { 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},
        { 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},
        { 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},
    },
    [constants.CATEGORY.C1_CRYSTAL_STAKES] = {
        { 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},
        { 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},
        { 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},
        { 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},
        { 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},
        { 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},
    }
}
NPC_POOLS[constants.CATEGORY.C2_REGULAR] = NPC_POOLS[constants.CATEGORY.C1_CRYSTAL_STAKES]
 local npcRivalCache = nil
 -- Emulation Fallback: Reconstruct a flattened cache purely from the NPC_POOLS Lua table.
-- (Removed fatal sql:query which caused lua crashes on startRace).
xi.chocoboRacing.loadNpcRivals = function()
    npcRivalCache = {}
    for catId, pool in pairs(NPC_POOLS) do
        for _, r in ipairs(pool) do
            table.insert(npcRivalCache, {
                name1        = r.name1 or 0,
                name2        = r.name2 or 0,
                dispName     = r.dispName or nil,
                min_tier     = r.min_tier or 0,
                max_tier     = r.max_tier or 3,
                SPAWN_CHANCE = r.SPAWN_CHANCE or 15,
                speed        = r.speed or 128,
                stamina      = r.stamina or 128,
                skill        = r.skill or 128,
                receptivity  = r.receptivity or 128,
                race         = r.race or 1,
                color        = r.color or 0,
                silks        = r.silks or 0,
                saddle       = r.saddle or 0,
                item         = r.item or 0,
            })
        end
    end
end
 -- Actual max index in xi.chocoboNames (chocobo_names.lua, verified: entries 0-830
-- with gaps at 38, 503, 530, 730). Gives 827 valid parts × 826 non-zero suffixes
-- = 683,000+ unique two-word combinations, plus 827 single-word names.
local CHOCOBO_NAME_MAX = 830
 -- Valid name indices — gaps excluded so random picks never hit a nil slot.
-- Stored as a flat array for O(1) random access.
local CHOCOBO_NAME_INDICES = (function()
    local t = {}
    local gaps = { [38]=true, [503]=true, [530]=true, [730]=true }
    for i = 0, CHOCOBO_NAME_MAX do
        if not gaps[i] then t[#t+1] = i end
    end
    return t
end)()
 -- Pick a random name1/name2 pair, skipping known gaps.
local function randomNamePair()
    local name1 = CHOCOBO_NAME_INDICES[math.random(#CHOCOBO_NAME_INDICES)]
    -- ~40% chance of a compound name, otherwise single-word (name2=0)
    local name2 = 0
    if math.random(100) <= 40 then
        name2 = CHOCOBO_NAME_INDICES[math.random(#CHOCOBO_NAME_INDICES)]
        if name2 == 0 then name2 = 1 end  -- avoid index-0 as suffix (renders as bare 'G')
    end
    return name1, name2
end
 -- Generate one procedural racer for the given category stat band.
local function generateRacer(category)
    local band = CATEGORY_STATS[category] or CATEGORY_STATS[constants.CATEGORY.C4_REGULAR]
    local spdMin, spdMax   = band[1], band[2]
    local staMin, staMax   = band[3], band[4]
    local sklMin, sklMax   = band[5], band[6]
     local name1, name2 = randomNamePair()
    return {
        name1       = name1,
        name2       = name2,
        speed       = math.random(spdMin, spdMax),
        stamina     = math.random(staMin, staMax),
        skill       = math.random(sklMin, sklMax),
        receptivity = math.random(80, 180),
        race        = math.random(0, 15),
        color       = getRandomColor(),
        silks       = getRandomSilks(),
        saddle      = getRandomSaddle(),
        item        = getRandomItem(),
    }
end
 -- Build the 8-racer field for a race: roll named rivals first (capped at 3),
-- then fill remaining slots with freshly generated procedural racers.
-- Returns a list of exactly 8 rData tables, already in randomised lane order.
local function buildRaceField(category, registeredPlayers)
    -- Ensure rivals are loaded (no-op after first call)
    if npcRivalCache == nil then
        xi.chocoboRacing.loadNpcRivals()
    end
     local field = {}
     -- 1. Registered players always enter
    for _, stats in ipairs(registeredPlayers) do
        table.insert(field, stats)
        if #field >= 8 then break end
    end
     -- 2. Roll each eligible named rival (tier must include this race's category)
    local tier = CATEGORY_TIER[category] or 0
    -- Work from a shuffled copy so the 3-rival cap doesn't always favour lower rival_ids
    local eligible = {}
    for _, rival in ipairs(npcRivalCache) do
        if tier >= rival.min_tier and tier <= rival.max_tier then
            eligible[#eligible + 1] = rival
        end
    end
    for i = #eligible, 2, -1 do
        local j = math.random(1, i)
        eligible[i], eligible[j] = eligible[j], eligible[i]
    end
     local namedCount = 0
    for _, rival in ipairs(eligible) do
        if #field >= 8 then break end
        if namedCount < 3 and math.random(100) <= rival.SPAWN_CHANCE then
            table.insert(field, rival)
            namedCount = namedCount + 1
        end
    end
     -- 3. Fill remaining slots with procedurally generated racers
    while #field < 8 do
        table.insert(field, generateRacer(category))
    end
     -- 4. Shuffle the full field so named rivals don't cluster at the front
    for i = #field, 2, -1 do
        local j = math.random(1, i)
        field[i], field[j] = field[j], field[i]
    end
     return field
end
 -----------------------------------
-- Race Scheduling (Retail-Compliant)
--
-- SCHEDULE LOGIC:
--   Retail races start every 15 minutes Earth time (XX:00, XX:15, XX:30, XX:45).
--   A retail racing season operates on a rotating month schema based on Earth weeks.
--   Week 1 = Month 1, Week 2 = Month 2, Week 3 = Month 3, Week 4 = Month 4.
-----------------------------------
local RACE_SCHEDULE = {
    -- Month 1 / Week 1: Standard / Elite Mix
    {
        constants.CATEGORY.C1_CRYSTAL_STAKES, -- 00:00 (Elite)
        constants.CATEGORY.C4_REGULAR,        -- 00:15 (Beginner)
        constants.CATEGORY.C3_REGULAR,        -- 00:30 (Standard)
        constants.CATEGORY.C3_REGULAR,        -- 00:45 (Standard)
        constants.CATEGORY.C2_REGULAR,        -- 01:00 (Expert)
        constants.CATEGORY.C4_REGULAR,        -- 01:15 (Beginner)
        constants.CATEGORY.C3_REGULAR,        -- 01:30 (Standard)
        constants.CATEGORY.C2_REGULAR,        -- 01:45 (Expert)
    },
    -- Month 2 / Week 2: Beginner / Standard Heavy
    {
        constants.CATEGORY.C3_REGULAR,        -- 00:00 (Standard)
        constants.CATEGORY.C4_REGULAR,        -- 00:15 (Beginner)
        constants.CATEGORY.C4_REGULAR,        -- 00:30 (Beginner)
        constants.CATEGORY.C3_REGULAR,        -- 00:45 (Standard)
        constants.CATEGORY.C2_REGULAR,        -- 01:00 (Expert)
        constants.CATEGORY.C4_REGULAR,        -- 01:15 (Beginner)
        constants.CATEGORY.C4_REGULAR,        -- 01:30 (Beginner)
        constants.CATEGORY.C2_REGULAR,        -- 01:45 (Expert)
    },
    -- Month 3 / Week 3: Expert Heavy
    {
        constants.CATEGORY.C2_REGULAR,        -- 00:00 (Expert)
        constants.CATEGORY.C3_REGULAR,        -- 00:15 (Standard)
        constants.CATEGORY.C2_REGULAR,        -- 00:30 (Expert)
        constants.CATEGORY.C3_REGULAR,        -- 00:45 (Standard)
        constants.CATEGORY.C1_CRYSTAL_STAKES, -- 01:00 (Elite)
        constants.CATEGORY.C3_REGULAR,        -- 01:15 (Standard)
        constants.CATEGORY.C2_REGULAR,        -- 01:30 (Expert)
        constants.CATEGORY.C1_CRYSTAL_STAKES, -- 01:45 (Elite)
    },
    -- Month 4 / Week 4: Championship Season (Elite / Expert)
    {
        constants.CATEGORY.C1_CRYSTAL_STAKES, -- 00:00 (Elite)
        constants.CATEGORY.C2_REGULAR,        -- 00:15 (Expert)
        constants.CATEGORY.C1_CRYSTAL_STAKES, -- 00:30 (Elite)
        constants.CATEGORY.C2_REGULAR,        -- 00:45 (Expert)
        constants.CATEGORY.C1_CRYSTAL_STAKES, -- 01:00 (Elite)
        constants.CATEGORY.C2_REGULAR,        -- 01:15 (Expert)
        constants.CATEGORY.C1_CRYSTAL_STAKES, -- 01:30 (Elite)
        constants.CATEGORY.C1_CRYSTAL_STAKES, -- 01:45 (Elite)
    }
}
local SLOT_SECONDS     = 900   -- 15-minute race cycle (60 * 15)
local PREPARE_HOLD_SEC = 35    -- Increased to allow the 30-sec pre-race dialog
local FINISH_HOLD_SEC  = 40    -- Sufficient time for post-race results dialog
 -----------------------------------
-- Logging System
-- All output goes to map server console via print().
-- Prefix: [ChocoboRacing] for easy grep filtering.
-- Filter with: Select-String -Path map_server.log -Pattern 'ChocoboRacing'
-----------------------------------
local LOG_LEVEL = 3 -- 0=OFF, 1=ERROR, 2=INFO, 3=DEBUG
 local function raceLog(level, ...)
    if level > LOG_LEVEL then return end
     local tags = { [1] = 'ERROR', [2] = 'INFO ', [3] = 'DEBUG' }
    local tag = tags[level] or 'INFO '
     local args = {...}
    local parts = {}
    for i = 1, #args do
        parts[i] = tostring(args[i])
    end
    local msg = table.concat(parts, ' ')
     print('[ChocoboRacing][' .. tag .. '] ' .. msg)
end
 -- Convenience wrappers
local function logError(...) raceLog(1, ...) end
local function logInfo(...)  raceLog(2, ...) end
local function logDebug(...) raceLog(3, ...) end
 -- Player-facing debug (prints to player chat + console)
local debug = function(player, ...)
    logDebug(...)
    if player and xi.settings and xi.settings.main and xi.settings.main.DEBUG_CHOCOBO_RACING then
        local t = { ... }
        player:printToPlayer(table.concat(t, ' '), xi.msg.channel.SYSTEM_3, '')
    end
end
 -- Dump full racer details for a race
local function logRacerTable(racers)
    logDebug('=== Racer Details ===')
    for lane = 1, 8 do
        local r = racers[lane]
        if r then
            logDebug(string.format(
                '  Lane %d: %-15s | SPD:%3d STA:%3d SKL:%3d | Race:%2d Color:%2d Silks:%3d Saddle:%3d Item:%3d | name1:%d name2:%d',
                lane, r.dispName or '???',
                r.speed or 0, r.stamina or 0, r.skill or 0,
                r.race or 0, r.color or 0, r.silks or 0, r.saddle or 0, r.item or 0,
                r.name1 or 0, r.name2 or 0
            ))
        end
    end
    logDebug('=====================')
end
 -----------------------------------
-- Supplementary NPC Entity Update Timer
-----------------------------------
local setTimer = function(player, npcId)
end
 local function build0x00E(npc)
    if not npc then return nil end
    local packet = { 0x0E, 0x28, 0x00, 0x00 }
     local id = npc:getID()
    packet[5] = bit.band(id, 0xFF)
    packet[6] = bit.band(bit.rshift(id, 8), 0xFF)
    packet[7] = bit.band(bit.rshift(id, 16), 0xFF)
    packet[8] = bit.band(bit.rshift(id, 24), 0xFF)
     local targid = npc:getTargID()
    packet[9] = bit.band(targid, 0xFF)
    packet[10] = bit.band(bit.rshift(targid, 8), 0xFF)
     -- Pad remaining empty space (80 bytes total for 0x50 packet)
    for i = 11, 80 do packet[i] = 0 end
     -- Specific flags reverse-engineered from retail observation
    packet[11] = 0x20
    packet[49] = 0x01
     return packet
end
 setTimer = function(player, npcId)
    player:timer(400, function(playerArg)
        local npc = GetNPCByID(npcId)
        if npc then
            local packet = build0x00E(npc)
            if packet then
                playerArg:sendDebugPacket(packet)
            end
        end
        setTimer(playerArg, npcId)
    end)
end
 -----------------------------------
-- 0x069 Packet Builders (Dynamic)
--
-- 0x069 is always 200 bytes (0x00C8).
-- Raw header: 0x69 0x64 [sync_lo] [sync_hi]
-- Byte [4]: Mode
--
-- Per XiPackets README and analysis of 18 retail captures:
--   Mode 1: Sets RaceParams (race number + category). padding/junk after.
--   Mode 2: Sets ChocoboParams (ParamIndex=0, ParamSize=0x60, 12 bytes/racer)
--   Mode 3: Sets SectionParams (two packets: ParamIndex 0x00 and 0x10)
--   Mode 4: Sets ResultParams (finish order nibbles) + optional SectionParams
--   Mode 5: Sets DownloadFlg=1 (system ready, rest of packet ignored)
--
--
-- IMPORTANT: Chocobo DISPLAY NAMES are NOT carried by 0x069.
-- They are sent separately via 0x05D (GP_SERV_COMMAND_EVENT_UPDATE_STRING)
-- as event string parameters (string1-string4, 4 names per packet,
-- two 0x05D packets per race = 8 names for 8 lanes).
-- See onEventUpdate() handler below where updateEventString() is called.
--
-- 🛑 THIS IS EMULATION 🛑
-- Do not 'fix' or 'optimize' packet bytes if they seem weird.
-- If retail sent a flat 0x08 for a byte, you send 0x08.
-----------------------------------
 local PACKET_69_SIZE = 200
 local function padTo200(bytes)
    for i = #bytes + 1, PACKET_69_SIZE do
        bytes[i] = 0
    end
    return bytes
end
 -- Mode 1: RacingParams
-- Per XiPackets README: padding00[3] after Mode byte are unused by client.
-- Race number is a sequential counter (retail: varies per race, 0x28→0xC8 etc.)
local function buildMode1(raceData)
    local category = raceData.category
    local raceNumber = raceData.raceNum or moduleState.raceCounter
    return padTo200({
        0x69, 0x64, 0x00, 0x00,  -- header + sync
        0x01, 0x00,               -- Mode=1, padding
        0x08, 0x01,               -- padding00[3] (values vary in captures, unused)
        -- RaceParams[0] = Race Number (Little Endian)
        bit.band(raceNumber, 0xFF),
        bit.band(bit.rshift(raceNumber,  8), 0xFF),
        bit.band(bit.rshift(raceNumber, 16), 0xFF),
        bit.band(bit.rshift(raceNumber, 24), 0xFF),
        -- RaceParams[1] = Category (Little Endian)
        bit.band(category, 0xFF),
        bit.band(bit.rshift(category,  8), 0xFF),
        bit.band(bit.rshift(category, 16), 0xFF),
        bit.band(bit.rshift(category, 24), 0xFF),
        raceData.weather or 0,
        raceData.condition or 0,
        0x00, 0x00,
    })
end
 -- Mode 2: ChocoboParams (Stats & Visuals)
-- Per XiPackets README (Modes 2 & 3 share layout):
--   Byte [5] = ParamIndex, [6] = ParamSize, [7] = padding (unused by client)
-- Confirmed 12-byte block layout per racer from captures:
--   Bytes 0-3: Name indices (2x uint16 LE)
--   Bytes 4-7: Stats — retail stat byte patterns observed across captures:
--     0xE0 0xC0 0x60 0x80  (high speed, high stamina, moderate skill)
--     0xFF 0xFF 0x40 0x40  (max speed/stam, low skill — risky finisher)
--     0xFF 0xFF 0x80 0x00  (max speed/stam, mid skill, no receptivity)
--     0x80 0xC0 0xA0 0xA0  (mid speed, high stam, balanced)
--     0xC0 0x00 0xC0 0xFF  (high speed, no stamina, high skill/receptivity)
--     0x80 0x60 0xE0 0xC0  (mid speed, low stam, very high skill)
--   Bytes 8-11: Visuals (item high, item low, saddle, race+color)
local function buildMode2(racers)
    local paramSize = 0x60 -- 96 = 8 racers * 12 bytes each
    local bytes = {
        0x69, 0x64, 0x00, 0x00,
        0x02,              -- Mode=2
        0x00,              -- ParamIndex=0
        paramSize,         -- ParamSize=0x60
        0x00,              -- padding (unused by client)
        -- 4-byte sub-header gap (bytes 8-11, 0-indexed).
        -- Confirmed from retail Mode 2 capture: 0x41 0x00 0x00 0x00 appear here.
        -- The client expects racer blocks to begin at byte 0x0C (12), NOT 0x08.
        -- Without these 4 bytes every racer block lands 4 bytes too early,
        -- placing the race+color byte at block-offset +3 instead of +7,
        -- which is why all jockeys rendered as Galka (race=0) in yellow (color=0).
        0x41, 0x00, 0x00, 0x00,
        -- Racer data follows here: 8 blocks x 12 bytes = 96 bytes (0x60)
        -- Block offsets: 0x0C, 0x18, 0x24, 0x30, 0x3C, 0x48, 0x54, 0x60
        -- (matches official packet[offset + N + 1] addressing with offset in {0x0C..0x60})
    }
     for lane = 1, 8 do
        local r = racers[lane]
         -- Field order confirmed from official implementation (packet offsets match retail capture):
        --   Bytes 0-3: Stats (speed, stamina, skill, receptivity)
        --   Bytes 4-5: Name index (name1, uint16 LE)
        --   Byte  6:   Item/equip ID (consumable held by jockey)
        --   Byte  7:   Visual — high nibble = jockey race, low nibble = chocobo color
        --   Byte  8:   Silks (jockey clothing visual ID)
        --   Byte  9:   Saddle visual ID
        --   Bytes 10-11: Padding (0x00)
        --
        -- IMPORTANT: name/stat order was previously swapped here, causing garbage
        -- visuals and wrong stats on the client. Fixed to match official byte layout.
         -- 1. Stats (Bytes 0-3)
        bytes[#bytes + 1] = bit.band(r and r.speed       or 0x0E, 0xFF)
        bytes[#bytes + 1] = bit.band(r and r.stamina     or 0x0C, 0xFF)
        bytes[#bytes + 1] = bit.band(r and r.skill       or 0x60, 0xFF)
        bytes[#bytes + 1] = bit.band(r and r.receptivity or 0x80, 0xFF)
         -- 2. Name index (Bytes 4-5: name1 uint16 LE)
        local name1 = r and r.name1 or 0x00
        bytes[#bytes + 1] = bit.band(name1, 0xFF)
        bytes[#bytes + 1] = bit.band(bit.rshift(name1, 8), 0xFF)
         -- 3. Item byte (Byte 6) — hardcoded 0x08 matching all retail captures.
        -- Using EQUIP constant values (20-23) here caused the client to trigger
        -- repeated item usage because those IDs activated in-race items.
        -- The actual passive equip stat modifiers are applied in applyEffects() on
        -- the server side; the packet byte should stay 0x08 like retail.
        bytes[#bytes + 1] = 0x08
         -- 4. Visual Appearance (Byte 7: high nibble = jockey race, low nibble = chocobo color)
        local jockeyRace = r and r.race or math.random(0, 7)
        local chocoColor = r and r.color or math.random(0, 7)
        bytes[#bytes + 1] = bit.bor(bit.lshift(bit.band(jockeyRace, 0x0F), 4), bit.band(chocoColor, 0x0F))
         -- 5. Silks (Byte 8) — Jockey clothing visual ID
        -- NOTE: silks=0 is valid (plain outfit) but 0 is falsy in Lua, so "r.silks or 0x41"
        -- would incorrectly substitute 0x41 for every plain-outfit racer. Explicit nil check required.
        local silksVal = (r ~= nil and r.silks ~= nil) and r.silks or 0x41
        bytes[#bytes + 1] = bit.band(silksVal, 0xFF)
         -- 6. Saddle (Byte 9) — Saddle visual ID
        bytes[#bytes + 1] = bit.band(r and r.saddle or 0, 0xFF)
         -- 7. Padding (Bytes 10-11)
        bytes[#bytes + 1] = 0x00
        bytes[#bytes + 1] = 0x00
    end
     return padTo200(bytes)
end
 -- Mode 3A: SectionParams block 1 (ParamIndex=0x00)
-- Keyframe timeline array for lanes 1-4. Contains track section progression.
-- ALWAYS starts with sentinel 0x88 0x88 0x88 0x88 (confirmed all 18 captures).
--
-- Structure per segment (12 bytes):
--   [0-3]: Previous lane positions (1 byte per lane, 4 lanes)
--   [4-7]: Next lane positions (1 byte per lane, 4 lanes)
--   [8-11]: Flags — encodes speed change events between prev→next
--     Confirmed flag patterns from captures:
--       0x00 0x00 0x00 0x00 = No change (steady pace)
--       0x80 0x80 0x00 0x03 = Major move, lane 1+2 burst (byte = 0x80 each)
--       0x02 0x40 0x00 0x04 = Moderate overtake, lane 1 + lane 2 adjusting
--       0x08 0x08 0x00 0x02 = Small burst, lanes 1+2
--       0x01 0x00 0x08 0x04 = Lane 1 slight, lane 3 boost
--       0x20 0x00 0x08 0x04 = Lane 1 strong, lane 3 boost
--       0x04 0x30 0xCB 0x06 = Major split with acceleration flags
--       0x40 0x12 0xAD 0x06 = Finishing sprint zone
--     Flag nibbles appear to encode: magnitude of position delta per lane.
--     When a lane's position changes between prev→next, corresponding flag
--     byte is non-zero. Higher values = bigger move.
local function buildMode3A(raceData)
    local bytes = {
        0x69, 0x64, 0x00, 0x00,
        0x03,        -- Mode=3
        0x00,        -- ParamIndex=0x00 (first block)
        0xC0,        -- ParamSize=192
        0x00,        -- padding: retail capture shows 0x00, NOT 0x01
    }
     -- Compute pool median speed so drift is relative to THIS race's competitors.
    -- Using a fixed midpoint of 128 made all Beginner chocobos (speed 30-46) drift
    -- at exactly -2/segment — identical motion for every lane, no visible difference.
    local speedSum = 0
    for i = 1, 4 do
        local r = raceData.racers[i]
        speedSum = speedSum + (r and r.speed or 128)
    end
    local poolMedian = speedSum / 4
     -- Give each lane a speed-proportional starting position (same logic as Mode3B).
    -- The original fixed {0x88, 0x88, 0x88, 0x88} sentinel gave all 4 lanes an
    -- identical start, so faster-than-median lanes drifted ahead from tick 1 —
    -- and since drift is deterministic per speed, lane 1 (often having the first
    -- racer drawn from the pool) showed a persistent early visual lead.
    -- Range kept near 0x88 so the first frame still matches retail expectations.
    local lastValues = {}
    for i = 1, 4 do
        local r = raceData.racers[i]
        local speed = (r and r.speed or 128)
        local speedOffset = math.floor((speed - poolMedian) * 0.06)
        lastValues[i] = math.max(0x70, math.min(0x99, 0x88 + speedOffset + math.random(-4, 4)))
    end
    for segment = 1, 16 do
        local nextValues = {}
        local flagBytes  = { 0x00, 0x00, 0x00, 0x00 }
         for i = 1, 4 do
            local r = raceData.racers[i]
            -- Drift relative to pool median: faster-than-median = positive drift,
            -- slower-than-median = negative.  Small base random walk on top.
            local drift = math.random(-1, 1)
            if r then
                -- Scale: 0.06 gives ±3 spread for a 50-point speed difference
                drift = drift + math.floor((r.speed - poolMedian) * 0.06)
                -- Late-race stamina fade: low-stamina chocobos tire noticeably
                if segment > 10 and r.stamina < 150 then
                    drift = drift - math.floor((150 - r.stamina) * 0.008 * (segment - 10))
                end
            end
            nextValues[i] = math.max(0x50, math.min(0xB0, lastValues[i] + drift))
                 local delta = math.abs(nextValues[i] - lastValues[i])
                if delta >= 8 then
                    if r and not r.itemUsed and r.item and r.item > 0 then
                        flagBytes[i] = 0x80 + math.random(0, 3)
                        r.itemUsed = true  -- Mark globally so Mode3B won't re-fire
                    else
                        flagBytes[i] = 0x08  -- Cap to small burst to avoid spurious item anim or announcer spam
                    end
                elseif delta >= 4 then
                    flagBytes[i] = bit.lshift(delta, 1)
                elseif delta >= 1 then
                    flagBytes[i] = delta
                end
                 -- Cap non-item flags so they don't break 0x40 and spam the announcer
                if flagBytes[i] > 0x40 and not (flagBytes[i] >= 0x80) then
                     flagBytes[i] = 0x08
                end
        end
         for i = 1, 4 do bytes[#bytes + 1] = lastValues[i] end
        for i = 1, 4 do bytes[#bytes + 1] = nextValues[i] end
        for i = 1, 4 do bytes[#bytes + 1] = flagBytes[i]  end
         lastValues = nextValues
    end
     return padTo200(bytes)
end
 -- Mode 3B: SectionParams block 2 (ParamIndex=0x10)
-- Keyframe timeline array for lanes 5-8.
-- Confirmed second packet observed in every race cycle capture.
-- Byte [7]: 0x00 in spectate captures, 0x01 in player-entered captures.
-- Starting position bytes vary per race — derived from lane 5-8 simulation.
local function buildMode3B(raceData)
    local bytes = {
        0x69, 0x64, 0x00, 0x00,
        0x03,        -- Mode=3
        0x10,        -- ParamIndex=0x10 (second block)
        0xC0,        -- ParamSize=192
        0x00,        -- padding
    }
     -- Compute pool median for lanes 5-8 (same logic as Mode 3A).
    local speedSum = 0
    for i = 1, 4 do
        local r = raceData.racers[i + 4]
        speedSum = speedSum + (r and r.speed or 128)
    end
    local poolMedian = speedSum / 4
     -- Starting positions near the Mode 3A sentinel (0x88).
    -- BUG FIXED: old code used 0x66 as base, putting lanes 5-8 ~34 units behind
    -- lanes 1-4 from the very first frame — making the first group look permanently
    -- faster. Also fixed operator-precedence bug: `r and r.speed or 128 - 128`
    -- evaluated as `r.speed or 0` (not `(r.speed or 128) - 128`), silently
    -- defaulting to 0 for all nil-speed racers.
    local lastValues = {}
    for i = 1, 4 do
        local r = raceData.racers[i + 4]
        local speed = (r and r.speed or 128)
        -- Faster-than-median racers start slightly ahead; range kept near 0x88
        local speedOffset = math.floor((speed - poolMedian) * 0.06)
        lastValues[i] = math.max(0x70, math.min(0x99, 0x88 + speedOffset + math.random(-4, 4)))
    end
     for segment = 1, 16 do
        local nextValues = {}
        local flagBytes  = { 0x00, 0x00, 0x00, 0x00 }
         for i = 1, 4 do
            local r = raceData.racers[i + 4]
            local drift = math.random(-1, 1)
            if r then
                drift = drift + math.floor((r.speed - poolMedian) * 0.06)
                if segment > 10 and r.stamina < 150 then
                    drift = drift - math.floor((150 - r.stamina) * 0.008 * (segment - 10))
                end
            end
            nextValues[i] = math.max(0x50, math.min(0xB0, lastValues[i] + drift))
             local delta = math.abs(nextValues[i] - lastValues[i])
            if delta > 0 then
                if delta >= 8 then
                    if r and not r.itemUsed and r.item and r.item > 0 then
                        flagBytes[i] = 0x80 + math.random(0, 3)
                        r.itemUsed = true
                    else
                        flagBytes[i] = 0x08
                    end
                elseif delta >= 4 then
                    flagBytes[i] = bit.lshift(delta, 1)
                elseif delta >= 1 then
                    flagBytes[i] = delta
                end
                if flagBytes[i] > 0x40 and not (flagBytes[i] >= 0x80) then
                     flagBytes[i] = 0x08
                end
            end
        end
         for i = 1, 4 do bytes[#bytes + 1] = lastValues[i] end
        for i = 1, 4 do bytes[#bytes + 1] = nextValues[i] end
        for i = 1, 4 do bytes[#bytes + 1] = flagBytes[i]  end
         lastValues = nextValues
    end
     return padTo200(bytes)
end
 -- Mode 4: ResultParams
-- Decoded from 20+ retail captures (ParamSize always 0x04):
--   Bytes [8-9]:  Winnings per quill (uint16 LE)
--   Byte  [10]:   (1st_place_0indexed << 4) | 2nd_place_0indexed
--   Byte  [11]:   (3rd_place_0indexed << 4) | 4th_place_0indexed
-- Retail examples:
--   34 16 25 70 → winnings=0x1634=5684, 1st=lane3, 2nd=lane6, 3rd=lane8, 4th=lane1
--   03 61 57 24 → winnings=0x6103=24835, 1st=lane6, 2nd=lane8, 3rd=lane3, 4th=lane5
--   23 10 FF FF → Player-entered race: 0xFF = special marker (all lanes)
local function buildMode4(winnings, placements)
    local bytes = {
        0x69, 0x64, 0x00, 0x00,
        0x04, 0x00,          -- Mode=4
        0x04,                -- ParamSize (retail: always 0x04 in all 18+ captures)
        0x01,                -- Flag: 0x01 when name data present, 0x00 when absent
    }
     -- Bytes [8-9]: Winnings per quill (uint16 LE)
    bytes[#bytes + 1] = bit.band(winnings, 0xFF)
    bytes[#bytes + 1] = bit.band(bit.rshift(winnings, 8), 0xFF)
     -- Byte [10]: 1st + 2nd place (0-indexed, nibble-packed)
    -- placements[1] = lane of 1st place (1-indexed), convert to 0-indexed
    local first  = (placements[1] or 1) - 1
    local second = (placements[2] or 2) - 1
    bytes[#bytes + 1] = bit.bor(bit.lshift(bit.band(first, 0x0F), 4), bit.band(second, 0x0F))
     -- Byte [11]: 3rd + 4th place (0-indexed, nibble-packed)
    local third  = (placements[3] or 3) - 1
    local fourth = (placements[4] or 4) - 1
    bytes[#bytes + 1] = bit.bor(bit.lshift(bit.band(third, 0x0F), 4), bit.band(fourth, 0x0F))
     return padTo200(bytes)
end
 -- Mode 5: DownloadFlg=1 (system ready)
-- Client only checks Mode byte. Rest of packet is ignored.
-- Byte [7]: 0x01 observed in all captures (both spectator and player-entered)
local function buildMode5()
    return padTo200({ 0x69, 0x64, 0x00, 0x00, 0x05, 0x00, 0x00, 0x01 })
end
 -----------------------------------
-- Race Engine
-- Simulates a full race by ticking each racer forward and recording
-- finish order. The field is built procedurally each race by buildRaceField().
--
-- 🛑 THIS IS EMULATION 🛑
-- Stat equations, weather impacts, and race timing must reflect retail.
-- Do not invent random new variance tools without retail proof.
-----------------------------------
local function calculateRaceEngine(category)
    -- Seed the PRNG from the current time + a rolling counter so that:
    -- (a) rapid successive calls (e.g. !exec startRace()) each get a distinct sequence
    -- (b) the shuffle and stat rolls are independent across races
    -- Without an explicit seed, Lua/LuaJIT uses a fixed default seed (often 0),
    -- making every race produce the same shuffle and therefore the same winner.
    moduleState.raceCounter = moduleState.raceCounter + 1
    math.randomseed(GetSystemTime() + moduleState.raceCounter * 1000)
     -- Randomize environmental factors
    local weather   = math.random(1, 100) > 85 and constants.WEATHER.RAINY or constants.WEATHER.FINE
    local condition = math.random(1, 100) > 80 and constants.CONDITION.HEAVY or constants.CONDITION.NORMAL
     local racers = {}
    -- Collect registered players then clear so they don't bleed into next race
    local registered = {}
    for _, stats in pairs(xi.chocoboRacing.registeredPlayers) do
        table.insert(registered, stats)
    end
    xi.chocoboRacing.registeredPlayers = {}
     -- Build the full 8-racer field: registered players + named rival roll-ins + procedural fill
    local field = buildRaceField(category, registered)
     for lane = 1, 8 do
        local rData = field[lane]
         -- APPLY ENVIRONMENTAL MODIFIERS
        if weather == constants.WEATHER.RAINY then
            rData.speed = rData.speed - 5
            rData.stamina = rData.stamina - 5
        end
        if condition == constants.CONDITION.HEAVY then
            rData.speed = rData.speed - 10
            rData.stamina = rData.stamina + 10 -- Heavy track requires more endurance
        end
         racers[lane] = {
            lane               = lane,
            playerID           = rData.playerID,
            jockeyId           = JOCKEY_BASE_ID  + (lane - 1),
            mountId            = CHOCOBO_BASE_ID + (lane - 1),
            dispName           = rData.dispName or resolveChocoboName(rData.name1, rData.name2),
            speed              = math.max(20, math.min(255, rData.speed)),
            stamina            = math.max(20, math.min(255, rData.stamina)),
            skill              = math.max(20, math.min(255, rData.skill)),
            receptivity        = rData.receptivity or 128,
            race               = rData.race,
            color              = rData.color,
            silks              = rData.silks,
            name1              = rData.name1,
            name2              = rData.name2,
            item               = rData.item,
            itemUsed           = false,
            saddle             = rData.saddle,
            progress           = 0,
            finished           = false,
            placement          = 0,
            replayData         = {},
        }
    end
     local racersFinished = 0
    local currentTick    = 0
     while racersFinished < 8 do
        currentTick = currentTick + 1
        for lane = 1, 8 do
            local r = racers[lane]
            if not r.finished then
                -- Speed is the primary driver, but stamina degrades over time
                -- Later ticks = more fatigue, reduced by high stamina
                local fatigueFactor = 1.0
                if currentTick > 10 then
                    local staminaRatio = r.stamina / 255.0
                    fatigueFactor = 0.7 + (0.3 * staminaRatio)
                end
                -- Skill adds occasional lucky bursts
                local skillBonus = 0
                if math.random(1, 100) <= math.floor(r.skill * 0.15) then
                    skillBonus = math.random(1, 4)
                end
                -- Receptivity reduces variance and mitigates poor ticks
                local focusBonus = (r.receptivity / 255.0) * math.random(0, 2)
                 r.progress = r.progress + (r.speed * 0.05 * fatigueFactor) + math.random(1, 3) + skillBonus + focusBonus
                r.replayData[currentTick] = r.progress
                if r.progress >= TRACK_LENGTH then
                    r.finished   = true
                    r.finishTick = currentTick
                    racersFinished = racersFinished + 1
                    -- NOTE: do NOT assign r.placement or placements[] here.
                    -- The inner loop processes lanes 1→8 in order, so any tick where
                    -- multiple racers cross the finish simultaneously would always give
                    -- lane 1 the win. Placements are assigned below via post-sort.
                end
            end
        end
    end
     -- Post-simulation placement sort.
    -- Primary key:   finishTick ascending  (earlier tick = finished first)
    -- Tiebreak key:  progress  descending  (higher overshoot = crossed the line
    --                                        "sooner" within the same tick step)
    -- This eliminates the lane-1 always-wins bias that came from the in-loop
    -- placement assignment processing lanes in fixed 1→8 order.
    local finisherList = {}
    for lane = 1, 8 do finisherList[#finisherList + 1] = racers[lane] end
    table.sort(finisherList, function(a, b)
        if a.finishTick ~= b.finishTick then
            return a.finishTick < b.finishTick
        end
        return a.progress > b.progress  -- higher overshoot = faster within the tick
    end)
     local placements = {}
    for pos, r in ipairs(finisherList) do
        r.placement    = pos
        placements[pos] = r.lane
    end
     local winningCombo = (placements[1] * 10) + placements[2]
    logInfo(string.format('Race prepared. Category: 0x%08X | 1st: Lane %d (%s) | 2nd: Lane %d | Combo: %02d | Ticks: %d',
        category, placements[1], racers[placements[1]].dispName, placements[2], winningCombo, currentTick))
    logRacerTable(racers)
     return {
        racers       = racers,
        placements   = placements,
        maxTicks     = currentTick,
        winningCombo = winningCombo,
        category     = category,
        raceNum      = moduleState.raceCounter,
        weather      = weather,
        condition    = condition,
    }
end
 -----------------------------------
-- NPC Race Announcer Dialogue (0x036 TalkNum)
--
-- In retail, 5-8 zone NPCs (ActIndex 0x61-0x65, 0xB6, 0xB8, 0xBC, 0xBD,
-- 0xBF, 0xC1) deliver formatted messages from the SevMess table at specific
-- moments during the race lifecycle. These happen via messageSpecial() which
-- generates 0x036 packets.
--
-- Pattern from captures (per race cycle):
--   XX:45:19-49  Pre-race announcements (6-7 messages, NPCs 0x62-0x65)
--   XX:50:55     Race starting soon (2 messages, NPCs 0x62-0x63)
--   XX:51:05     Race bell / start (NPC 0xB6 or 0xB8)
--   XX:51:35     Race finish bell (NPC 0xB6 or 0xB8)
--   XX:55:23     Intermission (NPC 0x61)
-----------------------------------
 -- Broadcast sequence of messages with delays (like a cutscene)
local function playAnnouncerSequence(zone, npcId, messages, intervalMs)
    intervalMs = intervalMs or 3000
    local npc = GetNPCByID(npcId)
    if not npc or not zone then return end
     local delay = 0
    for _, msgId in ipairs(messages) do
        -- Attach the timer to the anchor NPC to survive player disconnects
        npc:timer(delay, function(n)
            local players = zone:getPlayers()
            for _, player in ipairs(players) do
                player:messageSpecial(msgId, 0, 0, 0, 0)
            end
        end)
        delay = delay + intervalMs
    end
end
 -- Broadcast pre-race NPC announcements (the 6-message sequence)
local function announcePreRace(zone)
    local T = constants.TEXT
    local npcId = zones[xi.zone.CHOCOBO_CIRCUIT].npc.RUNGAGA
    local msgs = {
        T.RACE_ANNOUNCE_1, T.RACE_ANNOUNCE_2, T.RACE_ANNOUNCE_3,
        T.RACE_ANNOUNCE_4, T.RACE_ANNOUNCE_5, T.RACE_ANNOUNCE_6,
        T.RACE_ANNOUNCE_FINAL
    }
    -- Deliver 7 messages paced out by 4 seconds each (28s duration)
    playAnnouncerSequence(zone, npcId, msgs, 4000)
end
 -- Broadcast race start bell
local function announceRaceStart(zone)
    local T = constants.TEXT
    local npcId = zones[xi.zone.CHOCOBO_CIRCUIT].npc.RUNGAGA
    local msgs = {
        T.RACE_STARTING_1, T.RACE_STARTING_2, T.RACE_START_BELL
    }
    -- 3 messages paced out tightly at the gate drop
    playAnnouncerSequence(zone, npcId, msgs, 2000)
end
 -- Broadcast race finish bell
local function announceRaceFinish(zone)
    local T = constants.TEXT
    local npcId = zones[xi.zone.CHOCOBO_CIRCUIT].npc.RUNGAGA
    local msgs = {
        T.RACE_FINISH_BELL, T.RESULT_1ST, T.RESULT_2ND,
        T.RESULT_WIN_A, T.RESULT_WIN_B, T.RESULT_DETAIL_A,
        T.RESULT_DETAIL_B, T.RESULT_RECAP_A, T.RESULT_RECAP_B,
        T.RESULT_RECAP_C, T.RESULT_RECAP_D
    }
    -- 11-message resolution summary over 33 seconds
    playAnnouncerSequence(zone, npcId, msgs, 3000)
end
 -- Broadcast intermission
local function announceIntermission(zone)
    local npcId = zones[xi.zone.CHOCOBO_CIRCUIT].npc.RUNGAGA
    playAnnouncerSequence(zone, npcId, { constants.TEXT.INTERMISSION }, 3000)
end
 -----------------------------------
-- Supplementary NPC Movement (setPos path)
-- Optional visual layer. The client renders the race from 0x069 data alone.
-- setPos() just moves the physical dummy entities to match.
-- setPos() generates 0x05B (WPOS) packets internally.
-----------------------------------
local function sendRaceBurstToPlayer(player, raceData)
    local winnings = 1076 -- Base winnings per quill (Beginner default)
    if raceData.category == constants.CATEGORY.C1_CRYSTAL_STAKES then
        winnings = math.random(20000, 30000)
    elseif raceData.category == constants.CATEGORY.C2_REGULAR then
        winnings = math.random(4000, 8000)
    elseif raceData.category == constants.CATEGORY.C3_REGULAR then
        winnings = math.random(10000, 15000)
    else -- C4_REGULAR (Beginner)
        winnings = math.random(5000, 25000)
    end
     -- Persist winnings so onEventUpdate option==17 can display the correct value.
    -- LocalVar won't survive across the event boundary; CharVar is persistent.
    player:setCharVar('CR_WINNINGS', winnings)
     local packets = {
        buildMode1(raceData),
        buildMode2(raceData.racers),
        buildMode3A(raceData),
        buildMode3B(raceData),
        buildMode4(winnings, raceData.placements),
        buildMode5(),
    }
     for _, packet in ipairs(packets) do
        player:sendDebugPacket(packet)
    end
end
 local function tickNPCMovement(raceData, tick)
    local numWaypoints = #TRACK_WAYPOINTS  -- now 9 (loop-closed)
    local numSegments  = numWaypoints - 1  -- 8 segments between 9 points
     for lane = 1, 8 do
        local r = raceData.racers[lane]
         -- Skip finished racers: leave them at whatever position setPos last
        -- placed them (the finish gate at TRACK_WAYPOINTS[9]).  Without this
        -- guard they'd oscillate between 1.0 and lower-ratio positions because
        -- replayData[tick] is nil after the racer crossed the line.
        if r.finished and tick > (r.finishTick or 0) then
            -- Already parked at finish; nothing to do.
        else
            local progress      = r.replayData[tick] or 0
            local progressRatio = math.min(progress / TRACK_LENGTH, 1.0)
             -- Piecewise linear interpolation through all waypoints.
            -- segmentIndex: which gap (1-indexed) in the waypoint chain.
            -- t: fractional position within that gap [0, 1).
            local segmentFloat = progressRatio * numSegments   -- 0.0 to 8.0
            local segIdx       = math.min(math.floor(segmentFloat) + 1, numSegments)
            local t            = segmentFloat - (segIdx - 1)   -- fractional part, always [0,1]
            t = math.max(0.0, math.min(1.0, t))               -- clamp for floating-point edge cases
             local wp1 = TRACK_WAYPOINTS[segIdx]
            local wp2 = TRACK_WAYPOINTS[segIdx + 1]
             local laneOff = (lane - 1) * 2.5
            local x = wp1.x + (wp2.x - wp1.x) * t
            local y = wp1.y + (wp2.y - wp1.y) * t
            local z = (wp1.z + (wp2.z - wp1.z) * t) + laneOff
             -- Use cached entity references (set at spawn time) to avoid
            -- 16 GetNPCByID hash-map lookups per tick during RACING state.
            if r.mountRef  then r.mountRef:setPos(x, y, z, 0) end
            if r.jockeyRef then r.jockeyRef:setPos(x, y, z, 0) end
        end
    end
end
 -----------------------------------
-- Start Race Implementation
-- Generates dynamic race data, builds packets, and sends to player.
-----------------------------------
-- Send pre-calculated race data to a single player
-----------------------------------
-- keepEventAlive: Matches the official implementation's setTimer pattern.
-- The race cutscene (event 210) is driven entirely by the client interpreting
-- the 0x069 Mode 3 SectionParams packet.  However, the cutscene engine
-- requires periodic entity-update "heartbeats" from the server to stay alive;
-- without them the CS stalls and gets stuck at the end of the race track.
-- We pulse sendEmptyEntityUpdateToPlayer on Rungaga (the race anchor NPC)
-- every 400ms for the duration of the race.
-----------------------------------
local keepEventAlive  -- forward-declare so the recursive closure can ref itself
keepEventAlive = function(player)
    local rungagaId = zones[xi.zone.CHOCOBO_CIRCUIT].npc.RUNGAGA
    player:timer(400, function(playerArg)
        local npc = GetNPCByID(rungagaId)
        if npc then
            playerArg:sendEmptyEntityUpdateToPlayer(npc)
        end
        -- Run unconditionally — matches official implementation which never stops the heartbeat.
        -- The cutscene engine requires continuous entity-update pulses to stay alive;
        -- stopping early (e.g. on CR_POS_SAVED clear) stalls the CS at the finish line.
        keepEventAlive(playerArg)
    end)
end
 local startRaceImpl = function(player, raceData)
    logDebug(string.format('Sending race to player: %s', player:getName()))
     -- Build and send dynamic 0x069 packet burst
    sendRaceBurstToPlayer(player, raceData)
     -- Store numeric race state as local vars on the player for CS retrieval
    player:setLocalVar('CR_CATEGORY', raceData.category)
    player:setLocalVar('CR_WINNING_COMBO', raceData.winningCombo)
     -- Save the player's current position so we can teleport them back after the race.
    -- IMPORTANT: stored in CharVars (persistent) rather than LocalVars (event-scoped).
    -- LocalVars are cleared when the cutscene engine takes over, so the saved coords
    -- were silently lost before onEventFinish could read them.
    -- Also: getPos() returns pos.rotation (not pos.rot) in server-base.
    local pos = player:getPos()
    if pos then
        player:setCharVar('CR_START_X',   math.floor(pos.x        * 1000 + 0.5))
        player:setCharVar('CR_START_Y',   math.floor(pos.y        * 1000 + 0.5))
        player:setCharVar('CR_START_Z',   math.floor(pos.z        * 1000 + 0.5))
        player:setCharVar('CR_START_ROT', pos.rotation or pos.rot or 0)
        player:setCharVar('CR_POS_SAVED', 1)  -- Explicit flag; avoids X=0 false-negative
        logDebug(string.format('Saved pos for %s: x=%.3f y=%.3f z=%.3f rot=%d',
            player:getName(), pos.x, pos.y, pos.z, pos.rotation or pos.rot or 0))
    else
        logError(string.format('getPos() returned nil for %s — position will not be restored after race', player:getName()))
    end
     -- Spectator mode uses Event 210 via 0x034 (EventNum, numeric params only)
    -- Param 1: 3885177 (0x3B4269) — confirmed fixed value from official implementation
    -- Params 3-8 confirmed matching official: -132554, -14500, 1344, -1, 610862737, 1
    logDebug(string.format('startEvent(210) for %s | raceNum:%d combo:%02d',
        player:getName(), raceData.raceNum, raceData.winningCombo))
    player:startEvent(210, 3885177, 3885177, -132554, -14500, 1344, -1, 610862737, 1)
     -- Start the Rungaga entity heartbeat that keeps the race cutscene alive.
    -- Without this the CS engine stalls at the finish line.
    keepEventAlive(player)
end
  xi.chocoboRacing.startRace = function(category)
    category = category or constants.CATEGORY.C4_REGULAR
    logInfo(string.format('startRace called. Category: 0x%08X', category))
     -- Calculate race ONCE for all players
    local raceData = calculateRaceEngine(category)
    if not raceData then
        logError('Failed to generate race data')
        return
    end
    xi.chocoboRacing.currentRaceData = raceData
     -- Send the same race to every player in zone
    local zone = GetZone(xi.zone.CHOCOBO_CIRCUIT)
    if not zone then
        logError('Could not get Chocobo_Circuit zone')
        return
    end
    local players = zone:getPlayers()
    logInfo(string.format('Sending race to %d player(s)', #players))
    for _, player in ipairs(players) do
        startRaceImpl(player, raceData)
    end
end
 -----------------------------------
-- Zone Tick Handler
--
-- Wire this up in scripts/zones/Chocobo_Circuit/Zone.lua:
--   require('scripts/globals/chocobo_racing')
--   function onZoneInit(zone)
--       xi.chocoboRacing._initRivals()   -- loads entity_chocobo from DB
--   end
--   function onZoneTick(zone)
--       xi.chocoboRacing.onZoneTick(zone)
--   end
-- If onZoneInit is not available in your server base, _initRivals() will be
-- called automatically on the first race via the lazy-load in buildRaceField.
-----------------------------------
xi.chocoboRacing.onZoneTick = function(zone)
    local state = moduleState.raceState
     -- Fast path: when IDLE, only check the clock every ~2 seconds
    -- to avoid GetSystemTime() + modulo every single 400ms tick.
    if state == State.IDLE then
        local tickCount = (moduleState.idleTickSkip or 0) + 1
        moduleState.idleTickSkip = tickCount
        if tickCount < 5 then return end -- Skip ~4 ticks (~1.6s)
        moduleState.idleTickSkip = 0
    end
     local now = GetSystemTime()
     if state == State.IDLE then
        -- TIMING CONFIRMATION:
        -- now % 900 checks if we are at the start of a 15-minute Earth time interval.
        -- If we are in the first 5 seconds of the block, start the 15-min process.
        if (now % SLOT_SECONDS) < 5 then
            -- Determine the current "Month" based on Earth week
            local week     = math.floor((now / 604800) % 4) + 1
            -- Map current 2-hour window (7200s) to one of the 8 schedule slots
            local slot     = math.floor((now % 7200) / SLOT_SECONDS) + 1
            local category = RACE_SCHEDULE[week] and RACE_SCHEDULE[week][slot] or constants.CATEGORY.C4_REGULAR
             local raceData = calculateRaceEngine(category)
            if raceData then
                xi.chocoboRacing.currentRaceData = raceData
                moduleState.raceState   = State.PREPARE
                moduleState.prepareTime = now
                moduleState.raceTick    = 0
                 -- Announce upcoming race (0x036 TalkNum packets)
                announcePreRace(zone)
                 -- Spawn dynamic jockeys and mounts on the track
                spawnVisualRacers(zone, raceData)
                 -- Start race for all players currently in zone.
                -- startRaceImpl sends the 0x069 burst AND fires startEvent(210, ...)
                -- so that the race cutscene actually begins on each client.
                -- Previously only sendRaceBurstToPlayer was called here, which sent
                -- the packet data but never triggered the cutscene event on players.
                local players = zone:getPlayers()
                for _, player in ipairs(players) do
                    startRaceImpl(player, raceData)
                end
            end
        end
        return
    end
     if state == State.PREPARE then
        if (now - moduleState.prepareTime) >= PREPARE_HOLD_SEC then
            moduleState.raceState = State.RACING
            announceRaceStart(zone) -- 0x036: Race bell
            logInfo('State: PREPARE -> RACING (gates open)')
        end
        return
    end
     if state == State.RACING then
        local raceData = xi.chocoboRacing.currentRaceData
        if not raceData then
            moduleState.raceState = State.IDLE
            return
        end
        moduleState.raceTick = moduleState.raceTick + 1
        tickNPCMovement(raceData, moduleState.raceTick)
        if moduleState.raceTick >= raceData.maxTicks then
            moduleState.raceState = State.FINISHED
            moduleState.finishTime = now
            announceRaceFinish(zone) -- 0x036: Finish bell
            logInfo(string.format('State: RACING -> FINISHED | Combo: %02d | Ticks: %d', raceData.winningCombo, moduleState.raceTick))
            xi.chocoboRacing.processPayouts(raceData.winningCombo)
            xi.chocoboRacing.recordRaceResult(raceData)
        end
        return
    end
     if state == State.FINISHED then
        if (now - moduleState.finishTime) >= FINISH_HOLD_SEC then
            announceIntermission(zone) -- 0x036: Intermission
            despawnVisualRacers(xi.chocoboRacing.currentRaceData)
            moduleState.raceState            = State.IDLE
            xi.chocoboRacing.currentRaceData = nil
            logInfo('State: FINISHED -> IDLE (reset)')
        end
        return
    end
end
 -----------------------------------
-- Event Handlers
-----------------------------------
-- Client sends 0x05B (event update request), server responds with 0x05C
-- (event work params) and 0x05D (event string params).
--
-- Chocobo display names flow through 0x05D (GP_SERV_COMMAND_EVENT_UPDATE_STRING):
--   updateEventString(name1, name2, name3, name4) → sends 4 names per 0x05D packet
--   Two calls per race = 8 names for 8 lanes.
-- This is confirmed in all 18+ retail captures: chocobo names appear ONLY in
-- 0x05D packets, never inside 0x069 packets. Any strings visible in 0x069
-- captures are junk/leftover server memory.
--
-----------------------------------
-- NPC Interaction Handlers
-----------------------------------
 xi.chocoboRacing.onNPCInteraction = function(player, npc, eventId, nation)
    -- NPC Adrian (event 1) functions as the result board interaction
    if eventId == 1 then
        local history = xi.chocoboRacing.getRecentWinners(3)
        player:printToPlayer("--- Recent Race Results ---", xi.msg.channel.SYSTEM_3)
        for _, entry in ipairs(history) do
            local timeStr = os.date("%H:%M", entry.time)
            player:printToPlayer(string.format("[%s] Category: %d | Combo: %02d (Winner: %s)",
                timeStr, bit.band(entry.category, 0xF), entry.winningCombo, entry.winnerName),
                xi.msg.channel.SYSTEM_3)
        end
    end
     local hasChocobo   = 1
    local isRegistered = xi.chocoboRacing.registeredPlayers[player:getID()] and 1 or 0
    player:startEvent(eventId, hasChocobo, nation, 1, isRegistered, 0, 0, 0, 0)
end
 xi.chocoboRacing.onBettingInteraction = function(player, npc, eventId, ...)
    player:startEvent(eventId, ...)
end
 xi.chocoboRacing.onBettingEventFinish = function(player, csid, option, npcName)
    local bettingEvents = {
        [212] = true, [267] = true, [287] = true, [308] = true,
        [338] = true, [339] = true, [340] = true, [343] = true,
        [346] = true, [347] = true, [348] = true, [349] = true,
        [350] = true, [351] = true, [352] = true, [353] = true,
        [354] = true
    }
     if bettingEvents[csid] then
        if option > 0 then
            local combo = option
            local cost = 100 -- Default bet cost
            if xi.chocoboRacing.placeBet(player, combo, cost) then
                player:printToPlayer("Bet placed on Combination: " .. combo .. " through Betting Clerk " .. npcName .. ".", xi.msg.channel.SYSTEM_3)
            end
        end
        return true
    end
    return false
end
 -----------------------------------
-- Event Handlers
-----------------------------------
xi.chocoboRacing.onEventUpdate = function(player, csid, option, npc)
    if csid ~= 210 and csid ~= 469 then return end
    debug(player, 'update', csid, option)
     -- Read chocobo names from the global race data.
    -- Fallback chain: dispName → 'Chocobo<N>' (unique per lane, mirrors official placeholder style).
    -- Guard against xi.chocoboNames load failure or empty dispName strings.
    local raceData = xi.chocoboRacing.currentRaceData
    local names = {}
    if raceData then
        for lane = 1, 8 do
            local r = raceData.racers[lane]
            local name = r and r.dispName
            -- Treat blank strings the same as nil
            if not name or name == '' then
                name = 'Chocobo' .. lane
            end
            names[lane] = name
        end
    else
        for lane = 1, 8 do
            names[lane] = 'Chocobo' .. lane
        end
    end
     -- ===========================================
    -- Event 210: SPECTATOR RACE (8 lanes)
    -- ===========================================
    if csid == 210 then
        if option == 5 then
            -- Confirmed from official: p4 = 389304928 (0x173B1CA0), NOT CR_CATEGORY
            player:updateEvent(1, 0, -132554, -14500, 389304928, -1, 610862737, -5)
        elseif option == 274 then
            player:updateEventString(names[1], names[2], names[3], names[4])
            player:updateEvent(70, 0, 7, 4, 389304928, -1, 610862737, -5)
        elseif option == 510 or option == 530 then
            player:updateEventString(names[5], names[6], names[7], names[8])
            player:updateEvent(70, 0, 7, 4, 389304928, -1, 610862737, -5)
        elseif option == 17 then
            -- Read the real winnings that were stored when the 0x069 burst was built.
            -- Previously hardcoded to 1076; now reads CR_WINNINGS CharVar set in
            -- sendRaceBurstToPlayer so the cutscene shows the correct payout value.
            local winnings = player:getCharVar('CR_WINNINGS')
            if winnings == 0 then winnings = 1076 end  -- fallback if var missing
            player:updateEvent(70, 0, winnings, 1, 0, 3, 3, -5)
            player:setCharVar('CR_WINNINGS', 0)  -- clear after display
        end
    end
     -- ===========================================
    -- Event 469: PLAYER-ENTERED RACE (4 lanes)
    -- ===========================================
    if csid == 469 then
        -- Registration & Polling
        if option == 246 or option == 1 then
            player:updateEvent(0x02040000, 0, 1, 0, 0, 0, 0, 0x80)
        elseif option == 6 then -- Show race preview
            player:updateEvent(0x02040000, 0, 1, 0, 0, 0, 0, 0x80)
        elseif option == 514 or option == 8 then -- Race Progress/Start
            player:updateEvent(0x42000000, 0, 1, 0, 3, 0, 0, 0x80)
        elseif option == 5 then -- Intro banner
            player:updateEvent(0x42000000, 0, 1, 0, -61405, 0, 0, -5)
        elseif option == 265 then -- Names 1-4
            player:updateEventString(names[1], names[2], names[3], names[4])
            player:updateEvent(1, 0, 1, 0, -61405, 0, 0, -5)
        elseif option == 521 then -- Names 5-8 (if applicable)
            player:updateEventString(names[5], names[6], names[7], names[8])
            player:updateEvent(0, 0, 1, 0, -61405, 0, 0, -5)
        elseif option == 11 then -- Results
            player:updateEvent(-61405, 0, 0, 0, 0, 0, 0, 0)
        end
    end
end
 xi.chocoboRacing.onEventFinish = function(player, csid, option, npc)
    if csid ~= 210 and csid ~= 469 then return end
    debug(player, 'finish', csid, option)
     -- Restore player position — reads from CharVars (persistent across event boundary).
    local posSaved = player:getCharVar('CR_POS_SAVED')
    if posSaved == 1 then
        local startX   = player:getCharVar('CR_START_X')
        local startY   = player:getCharVar('CR_START_Y')
        local startZ   = player:getCharVar('CR_START_Z')
        local startRot = player:getCharVar('CR_START_ROT')
        local rx = startX / 1000
        local ry = startY / 1000
        local rz = startZ / 1000
        logDebug(string.format('Restoring pos for %s: x=%.3f y=%.3f z=%.3f rot=%d',
            player:getName(), rx, ry, rz, startRot))
        player:setPos(rx, ry, rz, startRot)
         -- Clear all saved vars
        player:setCharVar('CR_START_X',   0)
        player:setCharVar('CR_START_Y',   0)
        player:setCharVar('CR_START_Z',   0)
        player:setCharVar('CR_START_ROT', 0)
        player:setCharVar('CR_POS_SAVED', 0)
    else
        logDebug(string.format('No saved position for %s (CR_POS_SAVED=0) — skipping restore', player:getName()))
    end
     if csid == 469 then
        -- Award race rewards (based on placement in currentRaceData)
        local raceData = xi.chocoboRacing.currentRaceData
        if raceData then
            -- Find player lane and placement
            for lane = 1, 8 do
                local r = raceData.racers[lane]
                if r and r.playerID == player:getID() then
                    local placement = r.placement
                    local category = player:getLocalVar("CR_CATEGORY")
                    local teamId = player:getCharVar("CR_TEAM_AFFILIATION")
                     if teamId == 0 then teamId = 1 end -- Default to San d'Oria if none
                     local rewardBucks = 0
                    if placement == 1 then
                        rewardBucks = 50
                    elseif placement == 2 then
                        rewardBucks = 30
                    elseif placement == 3 then
                        rewardBucks = 15
                    end
                     local suffix = "th"
                    if placement == 1 then suffix = "st"
                    elseif placement == 2 then suffix = "nd"
                    elseif placement == 3 then suffix = "rd" end
                     if rewardBucks > 0 then
                        xi.chocoboRacing.addChocobucks(player, rewardBucks, teamId)
                        player:printToPlayer(string.format("Race finished! You came in %d%s place and earned %d Chocobucks.", 
                            placement, suffix, rewardBucks), xi.msg.channel.SYSTEM_3)
                    else
                        player:printToPlayer(string.format("Race finished! You came in %d%s place.", 
                            placement, suffix), xi.msg.channel.SYSTEM_3)
                    end
                     player:setLocalVar("CR_CATEGORY", 0)
                end
            end
        end
    end
end
   -----------------------------------
-- Betting & Payouts
-----------------------------------
xi.chocoboRacing.placeBet = function(player, combo, cost)
    if moduleState.raceState ~= State.IDLE then
        player:printToPlayer('Betting is currently closed.', xi.msg.channel.SYSTEM_3)
        return false
    end
    if player:getGil() < cost then
        player:printToPlayer('You do not have enough gil.', xi.msg.channel.SYSTEM_3)
        return false
    end
     -- Deduct gil
    player:delGil(cost)
     -- Register in memory and save to char variables for crash protection
    xi.chocoboRacing.activeBets[player:getID()] = { combo = combo, amt = cost }
    player:setCharVar("CR_PENDING_BET_COMBO", combo)
    player:setCharVar("CR_PENDING_BET_AMT", cost)
     player:printToPlayer(
        string.format('Bet placed on combination %02d for %d gil.', combo, cost),
        xi.msg.channel.SYSTEM_3)
    return true
end
 xi.chocoboRacing.processPayouts = function(winningCombo)
    for charid, bet in pairs(xi.chocoboRacing.activeBets) do
        local player = GetPlayerByID(charid)
         if bet.combo == winningCombo then
            local amount_won = bet.amt * 15
            if player then
                player:addGil(amount_won)
                player:messageSpecial(zones[xi.zone.CHOCOBO_CIRCUIT].text.GIL_OBTAINED, amount_won)
                player:printToPlayer(
                    string.format('Congratulations! Combo %02d won %d gil!', winningCombo, amount_won),
                    xi.msg.channel.SYSTEM_3)
            end
        else
            if player then
                player:printToPlayer(
                    string.format('Your bet on %02d lost. Winner: %02d.', bet.combo, winningCombo),
                    xi.msg.channel.SYSTEM_3)
            end
        end
         -- Clear the bet whether win or lose
        if player then
            player:setCharVar("CR_PENDING_BET_COMBO", 0)
            player:setCharVar("CR_PENDING_BET_AMT", 0)
        end
    end
    -- Reset all bets globally for next race
    xi.chocoboRacing.activeBets = {}
end
 -----------------------------------
-- Public API
-----------------------------------
xi.chocoboRacing.getRaceState = function()
    return moduleState.raceState
end
 xi.chocoboRacing.startMonitorCutscene = function(player)
    if moduleState.raceState ~= State.RACING then
        player:printToPlayer('There is no race currently in progress.', xi.msg.channel.SYSTEM_3)
        return
    end
    local r = xi.chocoboRacing.currentRaceData.racers
    player:startEvent(210,
        r[1].placement, r[2].placement, r[3].placement, r[4].placement,
        r[5].placement, r[6].placement, r[7].placement, r[8].placement)
end
  -----------------------------------
-- History & Stats API
-----------------------------------
 xi.chocoboRacing.raceHistory = {}
 -- Record the result of a completed race
xi.chocoboRacing.recordRaceResult = function(raceData)
    local winner = raceData.racers[raceData.placements[1]]
    local timestamp = GetSystemTime()
     local entry = {
        time         = timestamp,
        category     = raceData.category,
        winningCombo = raceData.winningCombo,
        winnerName   = winner.dispName,
    }
    table.insert(xi.chocoboRacing.raceHistory, 1, entry)
     -- Keep only last 10 races in memory
    if #xi.chocoboRacing.raceHistory > 10 then
        table.remove(xi.chocoboRacing.raceHistory)
    end
     -- Persistent SQL Storage (via C++ bindings)
    -- Ensure the chocobo_race_history and chocobo_race_entries tables exist in your DB!
    -- xi.chocoboRacing.RecordRaceResult(timestamp, raceData.category, raceData.winningCombo, winner.dispName) -- TODO: uncomment when C++ is bound
     -- NOTE: C++ binding should also be expanded later to record each racer's stats to chocobo_race_entries 
    -- to support the web leaderboard/meta API! Example loop for when the binding is written:
    -- for i = 1, 8 do
    --     local r = raceData.racers[i]
    --     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)
    -- end
end
 -- Returns the last N race results
-- Source: Retail 'Recent Results' board/NPC interaction
xi.chocoboRacing.getRecentWinners = function(count)
    count = count or 5
    local results = {}
    for i = 1, math.min(count, #xi.chocoboRacing.raceHistory) do
        table.insert(results, xi.chocoboRacing.raceHistory[i])
    end
    return results
end
 -----------------------------------
-- Stats & Leaderboard API (Future Capability)
--
-- These functions will need a backing SQL table (e.g. chocobo_race_history)
-- to persist results across server restarts. Schema sketch:
--
--   CREATE TABLE chocobo_race_history (
--       race_id        INT AUTO_INCREMENT PRIMARY KEY,
--       race_time      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
--       category       INT UNSIGNED NOT NULL,
--       winning_combo  TINYINT UNSIGNED NOT NULL,
--       -- per-lane columns or a child table for racer details
--   );
--
--   CREATE TABLE chocobo_race_bets (
--       bet_id         INT AUTO_INCREMENT PRIMARY KEY,
--       race_id        INT NOT NULL,
--       charid         INT UNSIGNED NOT NULL,
--       combo_bet      TINYINT UNSIGNED NOT NULL,
--       amount_bet     INT UNSIGNED NOT NULL,
--       amount_won     INT UNSIGNED NOT NULL DEFAULT 0,
--       FOREIGN KEY (race_id) REFERENCES chocobo_race_history(race_id)
--   );
--
-- Planned API endpoints / functions:
--
-- 1. RECENT WINNERS
--    xi.chocoboRacing.getRecentWinners(count)
--    Returns the last N race results with winning chocobo name, combo, and
--    category. Useful for NPC dialogue ("In the last race, [name] won!").
--
-- 2. BEST CHOCOBOS (period)
--    xi.chocoboRacing.getTopChocobos(period, limit)
--    period = 'day' | 'week' | 'month' | 'all'
--    Returns chocobos ranked by number of 1st place finishes in the period.
--    Display name, win count, categories won in.
--
-- 3. ALL-TIME WINNINGEST CHOCOBOS
--    xi.chocoboRacing.getAllTimeChampions(limit)
--    Returns chocobos with the most career 1st place finishes across all
--    categories and time. The "Hall of Fame".
--
-- 4. PLAYER BETTING STATS
--    xi.chocoboRacing.getPlayerBettingStats(charid)
--    Returns: total bets placed, total gil wagered, total gil won,
--    net profit/loss, win rate %, biggest single win.
--
-- 5. TOP GIL EARNERS
--    xi.chocoboRacing.getTopEarners(period, limit)
--    Players ranked by total gil won from betting in a given period.
--    Great for competitive leaderboards.
--
-- 6. RACE HISTORY (per player)
--    xi.chocoboRacing.getPlayerRaceHistory(charid, limit)
--    Returns the player's last N bets with race details, combo bet,
--    result, and payout. "Your recent bets" display.
--
-- 7. HOT STREAKS
--    xi.chocoboRacing.getPlayerStreak(charid)
--    Current win/loss streak and longest win streak ever.
--    Could trigger bonus payouts or titles at milestones.
--
-- 8. COMBO FREQUENCY STATS
--    xi.chocoboRacing.getComboStats(limit)
--    How often each winning combo (e.g. "12", "34") has hit.
--    Helps players make informed bets ("combo 23 hits 8% of the time").
--
-- 9. RACE IN PROGRESS
--    xi.chocoboRacing.getCurrentRaceInfo()
--    Returns current race state, category, racers, elapsed time.
--    For UI displays and NPC dialogue.
-----------------------------------
 

Comments