Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- 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.
- -----------------------------------
Add Comment
Please, Sign In to add comment