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' (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. -----------------------------------