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