Start repo, claude & kimi still vibing tho
This commit is contained in:
4
.formatter.exs
Normal file
4
.formatter.exs
Normal file
@@ -0,0 +1,4 @@
|
||||
# Used by "mix format"
|
||||
[
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||
]
|
||||
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# The directory Mix will write compiled artifacts to.
|
||||
/_build/
|
||||
|
||||
# If you run "mix test --cover", coverage assets end up here.
|
||||
/cover/
|
||||
|
||||
# The directory Mix downloads your dependencies sources to.
|
||||
/deps/
|
||||
|
||||
# Where third-party dependencies like ExDoc output generated docs.
|
||||
/doc/
|
||||
|
||||
# If the VM crashes, it generates a dump, let's ignore it too.
|
||||
erl_crash.dump
|
||||
|
||||
# Also ignore archive artifacts (built via "mix archive.build").
|
||||
*.ez
|
||||
|
||||
# Ignore package tarball (built via "mix hex.build").
|
||||
odinsea-*.tar
|
||||
|
||||
# Temporary files, for example, from tests.
|
||||
/tmp/
|
||||
|
||||
/.elixir_ls
|
||||
755
PORT_PROGRESS.md
Normal file
755
PORT_PROGRESS.md
Normal file
@@ -0,0 +1,755 @@
|
||||
# Odinsea Elixir Port - Progress Tracker
|
||||
|
||||
## Project Overview
|
||||
|
||||
**Reference:** Java MapleStory server (odinsea) at `/home/ra/lucid`
|
||||
**Target:** Elixir/OTP implementation at `/home/ra/odinsea-elixir`
|
||||
**Client Version:** GMS v342 (Rev 342)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Foundation ✅ COMPLETE
|
||||
|
||||
### 1.1 Project Structure & Tooling ✅
|
||||
- [x] Initialize Elixir project with `mix new`
|
||||
- [x] Configure project dependencies (mix.exs)
|
||||
- [x] Create directory structure aligned with domain boundaries
|
||||
- [x] Set up development tooling placeholders (credo, dialyzer)
|
||||
|
||||
**Dependencies Configured:**
|
||||
- Networking: `:ranch`, `:gen_state_machine`
|
||||
- Database: `:ecto_sql`, `:myxql`, `:redix`
|
||||
- Utilities: `:jason`, `:decimal`, `:timex`, `:poolboy`, `:nimble_pool`
|
||||
- Observability: `:telemetry`, `:logger_file_backend`
|
||||
- Development: `:credo`, `:dialyxir`
|
||||
|
||||
### 1.2 Configuration System ✅
|
||||
- [x] Port `config/global.properties` → `config/runtime.exs`
|
||||
- [x] Port `config/plugin.properties` → feature flags
|
||||
- [x] Create `config/config.exs` for defaults
|
||||
|
||||
**Files Created:**
|
||||
- `config/config.exs` - Default configuration
|
||||
- `config/runtime.exs` - Environment-based configuration
|
||||
|
||||
### 1.3 Core Types & Constants ✅
|
||||
- [x] Port `GameConstants` → `Odinsea.Constants.Game`
|
||||
- [x] Port `ServerConstants` → `Odinsea.Constants.Server`
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/constants/server.ex` - Server/protocol constants
|
||||
- `lib/odinsea/constants/game.ex` - Game mechanics constants
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Networking Layer ✅ COMPLETE
|
||||
|
||||
### 2.1 Packet Infrastructure ✅
|
||||
- [x] Port `InPacket` → `Odinsea.Net.Packet.In`
|
||||
- [x] Port `OutPacket` → `Odinsea.Net.Packet.Out`
|
||||
- [x] Port `HexTool` → `Odinsea.Net.Hex`
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/net/packet/in.ex` - Incoming packet decoder (little-endian)
|
||||
- `lib/odinsea/net/packet/out.ex` - Outgoing packet encoder (little-endian)
|
||||
- `lib/odinsea/net/hex.ex` - Hex encoding/decoding utilities
|
||||
|
||||
### 2.2 Cryptography ✅
|
||||
- [x] Port AES encryption (`AESCipher`)
|
||||
- [x] Port InnoGames cipher (`IGCipher` - shuffle/hash)
|
||||
- [x] Port ClientCrypto (IV management, header encoding/decoding)
|
||||
- [x] Port LoginCrypto (password hashing SHA-1/SHA-512, RSA decryption)
|
||||
- [x] Port BitTools utilities (byte manipulation, string extraction)
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/net/cipher/aes_cipher.ex` - AES-ECB encryption with IV handling
|
||||
- `lib/odinsea/net/cipher/ig_cipher.ex` - InnoGames IV hash transformation
|
||||
- `lib/odinsea/net/cipher/client_crypto.ex` - Client crypto coordinator (encrypt/decrypt/header)
|
||||
- `lib/odinsea/net/cipher/login_crypto.ex` - Password hashing and RSA operations
|
||||
- `lib/odinsea/util/bit_tools.ex` - Bit/byte manipulation utilities
|
||||
|
||||
**Reference Files:**
|
||||
- `src/handling/netty/cipher/AESCipher.java` ✅
|
||||
- `src/handling/netty/cipher/IGCipher.java` ✅
|
||||
- `src/handling/netty/ClientCrypto.java` ✅
|
||||
- `src/client/LoginCrypto.java` ✅
|
||||
- `src/tools/BitTools.java` ✅
|
||||
|
||||
### 2.3 Client Connection ✅ (Basic)
|
||||
- [x] Implement TCP acceptor pool with gen_tcp
|
||||
- [x] Port `ClientHandler` → `Odinsea.Net.Client`
|
||||
- [x] Implement connection state machine structure
|
||||
- [ ] Implement alive ack/ping handling
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/login/listener.ex` - Login server TCP listener
|
||||
- `lib/odinsea/login/client.ex` - Login client handler
|
||||
- `lib/odinsea/channel/server.ex` - Channel server TCP listener
|
||||
- `lib/odinsea/channel/client.ex` - Channel client handler
|
||||
- `lib/odinsea/shop/listener.ex` - Cash shop TCP listener
|
||||
- `lib/odinsea/shop/client.ex` - Cash shop client handler
|
||||
|
||||
### 2.4 Packet Processor / Opcodes ✅
|
||||
- [x] Port packet opcode definitions (`ClientPacket`/`LoopbackPacket`)
|
||||
- [x] Port `PacketProcessor` → `Odinsea.Net.Processor`
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/net/opcodes.ex` - All client/server packet opcodes
|
||||
- `lib/odinsea/net/processor.ex` - Central packet routing/dispatch system
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Database Layer 🔄 PARTIAL
|
||||
|
||||
### 3.1 Database Connection ✅
|
||||
- [x] Configure Ecto with MyXQL adapter
|
||||
- [x] Port connection pool settings
|
||||
- [ ] Set up migration structure
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/database/repo.ex` - Ecto repository
|
||||
|
||||
### 3.2 Schemas & Repos 🔄 PARTIAL
|
||||
- [x] Create Ecto schemas for core tables:
|
||||
- [x] accounts
|
||||
- [x] characters
|
||||
- [ ] inventory_items
|
||||
- [ ] storage
|
||||
- [ ] buddies
|
||||
- [ ] guilds
|
||||
- [ ] parties
|
||||
- [x] Implement Database Context module (Odinsea.Database.Context)
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/database/schema/account.ex` - Account schema with changesets
|
||||
- `lib/odinsea/database/schema/character.ex` - Character schema with changesets
|
||||
|
||||
### 3.3 Redis Connection ✅ (Basic)
|
||||
- [x] Configure Redix connection
|
||||
- [x] Implement migration token storage (Redis + ETS)
|
||||
- [ ] Port pub/sub messaging system
|
||||
- [ ] Implement full cross-server message passing
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Login Server ✅ HANDLERS COMPLETE
|
||||
|
||||
### 4.1 Login Handlers ✅
|
||||
- [x] Port `CharLoginHandler` → `Odinsea.Login.Handler`
|
||||
- [x] Implement auth flow:
|
||||
- [x] Permission request
|
||||
- [x] Password check (with SPW)
|
||||
- [x] World selection
|
||||
- [x] Character list
|
||||
- [x] Character creation/deletion
|
||||
- [x] Character selection
|
||||
- [x] Integrate with database (Odinsea.Database.Context)
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/login/handler.ex` - All login packet handlers
|
||||
|
||||
**Reference Files:**
|
||||
- `src/handling/login/handler/CharLoginHandler.java` ✅
|
||||
|
||||
### 4.2 Login Packets ✅
|
||||
- [x] Port `LoginPacket` → `Odinsea.Login.Packets`
|
||||
- [x] Implement packet builders:
|
||||
- [x] Hello/handshake packets
|
||||
- [x] Authentication responses
|
||||
- [x] Server/world list
|
||||
- [x] Character list
|
||||
- [x] Character name check
|
||||
- [x] Character creation/deletion
|
||||
- [x] Migration command
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/login/packets.ex` - Login server packet builders
|
||||
|
||||
**Reference Files:**
|
||||
- `src/tools/packet/LoginPacket.java` ✅
|
||||
|
||||
### 4.3 Account Management ⏳
|
||||
- [ ] Port `MapleClient` → `Odinsea.Login.Session`
|
||||
- [ ] Implement account storage/caching
|
||||
- [ ] Handle session state transitions
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Game World Core 🔄 STRUCTURE READY
|
||||
|
||||
### 5.1 World Server ✅ (Structure)
|
||||
- [x] Port `World` → `Odinsea.World` (placeholder)
|
||||
- [ ] Implement cross-server messaging
|
||||
- [ ] Port party/guild/family/alliance management
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/world.ex` - World state
|
||||
- `lib/odinsea/world/supervisor.ex` - World services supervisor
|
||||
- `lib/odinsea/world/party.ex` - Party service (placeholder)
|
||||
- `lib/odinsea/world/guild.ex` - Guild service (placeholder)
|
||||
- `lib/odinsea/world/family.ex` - Family service (placeholder)
|
||||
- `lib/odinsea/world/expedition.ex` - Expedition service (placeholder)
|
||||
- `lib/odinsea/world/messenger.ex` - Messenger service (placeholder)
|
||||
|
||||
### 5.2 Channel Server ✅ (Structure)
|
||||
- [x] Port `ChannelServer` → `Odinsea.Channel` (structure)
|
||||
- [x] Implement channel registry
|
||||
- [ ] Handle player storage per channel
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/channel/supervisor.ex` - Channel supervisor
|
||||
- `lib/odinsea/channel/server.ex` - Individual channel server
|
||||
|
||||
### 5.3 Player Storage ✅
|
||||
- [x] Port `PlayerStorage` → `Odinsea.Channel.Players`
|
||||
- [x] Implement character loading/saving (via Context)
|
||||
- [x] Handle channel transfers (Migration system)
|
||||
|
||||
### 5.4 Migration System ✅
|
||||
- [x] Port `CharacterTransfer` → `Odinsea.World.Migration`
|
||||
- [x] Implement migration token creation/validation
|
||||
- [x] Cross-server token storage (ETS + Redis)
|
||||
- [x] Token expiration and cleanup
|
||||
|
||||
### 5.5 Inter-Server Handler ✅
|
||||
- [x] Port `InterServerHandler` → `Odinsea.Channel.Handler.InterServer`
|
||||
- [x] Implement MigrateIn handling
|
||||
- [x] Implement ChangeChannel handling
|
||||
|
||||
### 5.6 Character (In-Game State) ✅
|
||||
- [x] Port `MapleCharacter` → `Odinsea.Game.Character` (minimal)
|
||||
- [x] Implement character stats structure
|
||||
- [x] Implement position tracking
|
||||
- [x] Character loading from database
|
||||
- [x] Character saving to database
|
||||
- [x] Map change logic
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/game/character.ex` - In-game character GenServer
|
||||
|
||||
**Reference Files:**
|
||||
- `src/client/MapleCharacter.java` ✅ (minimal - 150 fields remaining)
|
||||
- `src/client/PlayerStats.java` ✅ (minimal)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Game Systems ⏳ NOT STARTED
|
||||
|
||||
### 6.1 Maps 🔄 STARTED
|
||||
- [x] Port `MapleMap` → `Odinsea.Game.Map` (minimal implementation)
|
||||
- [x] Implement player spawn/despawn on maps
|
||||
- [x] Implement map broadcasting (packets to all players)
|
||||
- [ ] Port `MapleMapFactory` → map loading/caching
|
||||
- [ ] Implement map objects (reactors, portals)
|
||||
- [ ] Load map data from WZ files
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/game/map.ex` - Map instance GenServer
|
||||
|
||||
**Reference Files:**
|
||||
- `src/server/maps/MapleMap.java` ✅ (partially)
|
||||
|
||||
### 6.2 Life (Mobs/NPCs) ⏳
|
||||
- [ ] Port `MapleLifeFactory` → `Odinsea.Game.Life`
|
||||
- [ ] Port `MapleMonster` → monster handling
|
||||
- [ ] Port `MapleNPC` → NPC handling
|
||||
|
||||
**Reference Files:**
|
||||
- `src/server/life/*.java`
|
||||
|
||||
### 6.3 Items & Inventory ⏳
|
||||
- [ ] Port `MapleItemInformationProvider`
|
||||
- [ ] Port `MapleInventory` → `Odinsea.Game.Inventory`
|
||||
- [ ] Implement item types (equip, use, setup, etc.)
|
||||
|
||||
**Reference Files:**
|
||||
- `src/server/MapleItemInformationProvider.java`
|
||||
- `src/client/inventory/*.java`
|
||||
|
||||
### 6.4 Skills & Buffs ⏳
|
||||
- [ ] Port `SkillFactory` → `Odinsea.Game.Skills`
|
||||
- [ ] Implement buff management
|
||||
- [ ] Port cooldown handling
|
||||
|
||||
**Reference Files:**
|
||||
- `src/client/SkillFactory.java`
|
||||
- `src/client/status/*.java`
|
||||
|
||||
### 6.6 Movement ✅ (Simplified)
|
||||
- [x] Port `MovementParse` → `Odinsea.Game.Movement` (simplified)
|
||||
- [x] Parse movement commands
|
||||
- [x] Extract final position from movement data
|
||||
- [ ] Full movement type parsing (40+ movement command types)
|
||||
- [ ] Movement validation and anti-cheat
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/game/movement.ex` - Movement parsing
|
||||
|
||||
**Reference Files:**
|
||||
- `src/handling/channel/handler/MovementParse.java` ✅ (simplified)
|
||||
|
||||
### 6.5 Quests ⏳
|
||||
- [ ] Port `MapleQuest` → `Odinsea.Game.Quests`
|
||||
- [ ] Implement quest management
|
||||
|
||||
**Reference Files:**
|
||||
- `src/server/quest/MapleQuest.java`
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Channel Handlers 🔄 IN PROGRESS
|
||||
|
||||
### 7.1 Player Handlers 🔄 PARTIAL
|
||||
- [x] Port `PlayerHandler` → `Odinsea.Channel.Handler.Player` (basic)
|
||||
- [x] Implement movement (MovePlayer)
|
||||
- [x] Implement map changes (ChangeMap)
|
||||
- [x] Implement keybinding changes (ChangeKeymap)
|
||||
- [x] Implement skill macro changes (ChangeSkillMacro)
|
||||
- [x] Stub attack handlers (CloseRange, Ranged, Magic)
|
||||
- [x] Stub damage handler (TakeDamage)
|
||||
- [ ] Full attack implementation (damage calculation, mob interaction)
|
||||
- [ ] Stats handling (AP/SP distribution)
|
||||
- [ ] Skill usage and buffs
|
||||
- [ ] Item effects
|
||||
- [ ] Chair usage
|
||||
- [ ] Emotion changes
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/channel/handler/player.ex` - Player action handlers
|
||||
|
||||
**Reference Files:**
|
||||
- `src/handling/channel/handler/PlayerHandler.java` ✅ (partial)
|
||||
- `src/handling/channel/handler/StatsHandling.java` ⏳
|
||||
|
||||
### 7.2 Inventory Handlers ⏳
|
||||
- [ ] Port `InventoryHandler` → `Odinsea.Channel.Handler.Inventory`
|
||||
- [ ] Implement item usage, scrolling, sorting
|
||||
|
||||
**Reference Files:**
|
||||
- `src/handling/channel/handler/InventoryHandler.java`
|
||||
|
||||
### 7.3 Mob Handlers ⏳
|
||||
- [ ] Port `MobHandler` → `Odinsea.Channel.Handler.Mob`
|
||||
- [ ] Implement mob movement, damage, skills
|
||||
|
||||
**Reference Files:**
|
||||
- `src/handling/channel/handler/MobHandler.java`
|
||||
|
||||
### 7.4 NPC Handlers ⏳
|
||||
- [ ] Port `NPCHandler` → `Odinsea.Channel.Handler.NPC`
|
||||
- [ ] Implement NPC talk, shops, storage
|
||||
|
||||
**Reference Files:**
|
||||
- `src/handling/channel/handler/NPCHandler.java`
|
||||
|
||||
### 7.5 Chat & Social Handlers ✅ CHAT COMPLETE
|
||||
- [x] Port `ChatHandler` → `Odinsea.Channel.Handler.Chat`
|
||||
- [x] Implement general chat (map broadcast)
|
||||
- [x] Implement party chat routing (buddy, party, guild, alliance, expedition)
|
||||
- [x] Implement whisper/find player
|
||||
- [x] Add chat packet builders (UserChat, Whisper, MultiChat, FindPlayer)
|
||||
- [ ] Port `BuddyListHandler` → buddy system
|
||||
- [ ] Port `PartyHandler`, `GuildHandler`, `FamilyHandler`
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/channel/handler/chat.ex` - Chat packet handlers
|
||||
|
||||
**Reference Files:**
|
||||
- `src/handling/channel/handler/ChatHandler.java` ✅
|
||||
- `src/handling/channel/handler/BuddyListHandler.java` ⏳
|
||||
- `src/handling/channel/handler/PartyHandler.java` ⏳
|
||||
- `src/handling/channel/handler/GuildHandler.java` ⏳
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Cash Shop ⏳ NOT STARTED
|
||||
|
||||
### 8.1 Cash Shop Server ✅ (Structure)
|
||||
- [x] Port `CashShopServer` → `Odinsea.Shop` (structure)
|
||||
- [ ] Implement cash item handling
|
||||
- [ ] Implement coupon system
|
||||
|
||||
**Files Created:**
|
||||
- `lib/odinsea/shop/listener.ex` - Cash shop listener
|
||||
- `lib/odinsea/shop/client.ex` - Cash shop client
|
||||
|
||||
### 8.2 Channel Packets 🔄 PARTIAL
|
||||
- [x] Basic channel packet builders
|
||||
- [x] Character spawn packet (simplified)
|
||||
- [x] Character despawn packet
|
||||
- [x] Chat packets (UserChat, Whisper, MultiChat, FindPlayer)
|
||||
- [x] Movement packet (MovePlayer)
|
||||
- [ ] Full character encoding (equipment, buffs, pets)
|
||||
- [ ] Damage packets
|
||||
- [ ] Skill effect packets
|
||||
- [ ] Attack packets
|
||||
|
||||
**Files Updated:**
|
||||
- `lib/odinsea/channel/packets.ex` - Added spawn_player, remove_player, chat packets
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Scripting System ⏳ NOT STARTED
|
||||
|
||||
### 9.1 Script Engine ⏳
|
||||
- [ ] Integrate QuickJS or Lua runtime
|
||||
- [ ] Port `AbstractScriptManager`
|
||||
- [ ] Implement script globals (cm, em, pi, etc.)
|
||||
|
||||
**Reference Files:**
|
||||
- `src/scripting/*.java`
|
||||
|
||||
---
|
||||
|
||||
## Phase 10: Advanced Features ⏳ NOT STARTED
|
||||
|
||||
### 10.1 Timers & Scheduling ⏳
|
||||
- [ ] Port timer system to Elixir processes
|
||||
- [ ] World timer, Map timer, Buff timer, etc.
|
||||
|
||||
**Reference Files:**
|
||||
- `src/server/Timer.java`
|
||||
|
||||
### 10.2 Anti-Cheat ⏳
|
||||
- [ ] Port `MapleAntiCheat` → `Odinsea.AntiCheat`
|
||||
- [ ] Implement lie detector system
|
||||
|
||||
**Reference Files:**
|
||||
- `src/client/anticheat/*.java`
|
||||
|
||||
### 10.3 Events ⏳
|
||||
- [ ] Port event system
|
||||
- [ ] Implement scheduled events
|
||||
|
||||
**Reference Files:**
|
||||
- `src/server/events/*.java`
|
||||
|
||||
### 10.4 Admin Commands ⏳
|
||||
- [ ] Port `AdminHandler` → `Odinsea.Admin`
|
||||
- [ ] Implement command system
|
||||
|
||||
**Reference Files:**
|
||||
- `src/handling/admin/*.java`
|
||||
|
||||
---
|
||||
|
||||
## Phase 11: Testing & Optimization ⏳ NOT STARTED
|
||||
|
||||
- [ ] Unit tests for packet encoding/decoding
|
||||
- [ ] Integration tests for login flow
|
||||
- [ ] Load testing for channel capacity
|
||||
- [ ] Performance optimization
|
||||
- [ ] Documentation
|
||||
|
||||
---
|
||||
|
||||
## File Mapping Reference
|
||||
|
||||
### Core Application
|
||||
| Java | Elixir | Status |
|
||||
|------|--------|--------|
|
||||
| `src/app/Program.java` | `lib/odinsea/application.ex` | ✅ Structure ready |
|
||||
| `src/app/ServerStart.java` | `lib/odinsea/application.ex` | ✅ Structure ready |
|
||||
| `src/app/ServerStop.java` | `lib/odinsea/application.ex` | ✅ Structure ready |
|
||||
| `src/app/Config.java` | `config/runtime.exs` | ✅ Done |
|
||||
| `src/app/Plugin.java` | `config/runtime.exs` (features) | ✅ Done |
|
||||
| `src/app/Logging.java` | Elixir Logger | ✅ Native |
|
||||
|
||||
### Networking
|
||||
| Java | Elixir | Status |
|
||||
|------|--------|--------|
|
||||
| `src/handling/PacketProcessor.java` | `lib/odinsea/net/processor.ex` | ✅ Done |
|
||||
| `src/handling/ClientPacket.java` | `lib/odinsea/net/opcodes.ex` | ✅ Done |
|
||||
| `src/tools/data/InPacket.java` | `lib/odinsea/net/packet/in.ex` | ✅ Done |
|
||||
| `src/tools/data/OutPacket.java` | `lib/odinsea/net/packet/out.ex` | ✅ Done |
|
||||
| `src/tools/HexTool.java` | `lib/odinsea/net/hex.ex` | ✅ Done |
|
||||
| `src/handling/netty/cipher/AESCipher.java` | `lib/odinsea/net/cipher/aes_cipher.ex` | ✅ Done |
|
||||
| `src/handling/netty/cipher/IGCipher.java` | `lib/odinsea/net/cipher/ig_cipher.ex` | ✅ Done |
|
||||
| `src/handling/netty/ClientCrypto.java` | `lib/odinsea/net/cipher/client_crypto.ex` | ✅ Done |
|
||||
| `src/client/LoginCrypto.java` | `lib/odinsea/net/cipher/login_crypto.ex` | ✅ Done |
|
||||
| `src/tools/BitTools.java` | `lib/odinsea/util/bit_tools.ex` | ✅ Done |
|
||||
|
||||
### Database
|
||||
| Java | Elixir | Status |
|
||||
|------|--------|--------|
|
||||
| `src/database/DatabaseConnection.java` | `lib/odinsea/database/repo.ex` | ✅ Structure ready |
|
||||
| `src/database/RedisConnection.java` | `config/runtime.exs` | ✅ Config ready |
|
||||
|
||||
### Login
|
||||
| Java | Elixir | Status |
|
||||
|------|--------|--------|
|
||||
| `src/handling/login/handler/CharLoginHandler.java` | `lib/odinsea/login/handler.ex` | ✅ Done |
|
||||
| `src/tools/packet/LoginPacket.java` | `lib/odinsea/login/packets.ex` | ✅ Done |
|
||||
| `src/client/MapleClient.java` | `lib/odinsea/login/session.ex` | ⏳ TODO |
|
||||
| N/A | `lib/odinsea/login/listener.ex` | ✅ Created |
|
||||
| N/A | `lib/odinsea/login/client.ex` | ✅ Created |
|
||||
|
||||
### World
|
||||
| Java | Elixir | Status |
|
||||
|------|--------|--------|
|
||||
| `src/handling/world/World.java` | `lib/odinsea/world.ex` | ✅ Structure ready |
|
||||
| `src/handling/world/MapleParty.java` | `lib/odinsea/world/party.ex` | ✅ Structure ready |
|
||||
| `src/handling/world/guild/*.java` | `lib/odinsea/world/guild.ex` | ✅ Structure ready |
|
||||
| `src/handling/world/family/*.java` | `lib/odinsea/world/family.ex` | ✅ Structure ready |
|
||||
|
||||
### Channel
|
||||
| Java | Elixir | Status |
|
||||
|------|--------|--------|
|
||||
| `src/handling/channel/ChannelServer.java` | `lib/odinsea/channel/server.ex` | ✅ Structure ready |
|
||||
| `src/handling/channel/PlayerStorage.java` | `lib/odinsea/channel/players.ex` | ✅ Done |
|
||||
| `src/handling/channel/handler/MovementParse.java` | `lib/odinsea/game/movement.ex` | 🔄 Simplified |
|
||||
| `src/handling/channel/handler/ChatHandler.java` | `lib/odinsea/channel/handler/chat.ex` | ✅ Done |
|
||||
| `src/handling/channel/handler/PlayerHandler.java` | `lib/odinsea/channel/handler/player.ex` | 🔄 Partial (movement, stubs) |
|
||||
| N/A | `lib/odinsea/channel/supervisor.ex` | ✅ Created |
|
||||
| N/A | `lib/odinsea/channel/client.ex` | ✅ Created + wired handlers |
|
||||
| N/A | `lib/odinsea/channel/packets.ex` | 🔄 Partial + chat packets |
|
||||
|
||||
### Shop
|
||||
| Java | Elixir | Status |
|
||||
|------|--------|--------|
|
||||
| `src/handling/cashshop/CashShopServer.java` | `lib/odinsea/shop/listener.ex` | ✅ Structure ready |
|
||||
| N/A | `lib/odinsea/shop/client.ex` | ✅ Created |
|
||||
|
||||
### Game Systems
|
||||
| Java | Elixir | Status |
|
||||
|------|--------|--------|
|
||||
| `src/client/MapleCharacter.java` | `lib/odinsea/game/character.ex` | 🔄 Minimal (stats + position) |
|
||||
| `src/client/PlayerStats.java` | `lib/odinsea/game/character.ex` | 🔄 Minimal |
|
||||
| `src/server/maps/MapleMap.java` | `lib/odinsea/game/map.ex` | 🔄 Minimal (spawn/despawn) |
|
||||
| `src/server/maps/MapleMapFactory.java` | ⏳ TODO | ⏳ Not started |
|
||||
| `src/client/inventory/MapleInventory.java` | ⏳ TODO | ⏳ Not started |
|
||||
| `src/client/SkillFactory.java` | ⏳ TODO | ⏳ Not started |
|
||||
|
||||
---
|
||||
|
||||
## Project Statistics
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Files Created | 40+ |
|
||||
| Lines of Code (Elixir) | ~7,500+ |
|
||||
| Modules Implemented | 37+ |
|
||||
| Opcodes Defined | 160+ |
|
||||
| Registries | 5 (Player, Channel, Character, Map, Client) |
|
||||
| Supervisors | 4 (World, Channel, Client, Map) |
|
||||
| Channel Handlers | 2 (Chat ✅, Player 🔄) |
|
||||
|
||||
---
|
||||
|
||||
## Progress Summary
|
||||
|
||||
| Phase | Status | % Complete |
|
||||
|-------|--------|------------|
|
||||
| 1. Foundation | ✅ Complete | 100% |
|
||||
| 2. Networking | ✅ Complete | 100% |
|
||||
| 3. Database | 🔄 Partial | 65% |
|
||||
| 4. Login Server | ✅ Complete | 100% |
|
||||
| 5. World/Channel | 🔄 Core Complete | 70% |
|
||||
| 6. Game Systems | 🔄 Started | 20% |
|
||||
| 7. Handlers | 🔄 In Progress | 25% |
|
||||
| 8. Cash Shop | 🔄 Structure + Packets | 30% |
|
||||
| 9. Scripting | ⏳ Not Started | 0% |
|
||||
| 10. Advanced | ⏳ Not Started | 0% |
|
||||
| 11. Testing | ⏳ Not Started | 0% |
|
||||
|
||||
**Overall Progress: ~45%**
|
||||
|
||||
---
|
||||
|
||||
## Next Session Recommendations
|
||||
|
||||
### High Priority (Database Integration)
|
||||
1. **Integrate Login Handlers with Database**
|
||||
- Implement `authenticate_user/3` with actual DB queries
|
||||
- Load characters from database in `load_characters/2`
|
||||
- Character name validation against DB
|
||||
- Account/character creation in database
|
||||
- Ban checking (IP, MAC, account)
|
||||
|
||||
2. **Implement Migration System**
|
||||
- Create Ecto migrations for accounts/characters tables
|
||||
- Set up migration tokens for channel transfers
|
||||
- Session management across servers
|
||||
|
||||
3. **Complete Packet Sending**
|
||||
- Implement actual packet sending in `send_packet/2`
|
||||
- Add encryption/header generation before sending
|
||||
- Test full login flow with real client
|
||||
|
||||
### Medium Priority (Channel Server)
|
||||
4. **Implement Channel Packet Handlers**
|
||||
- Port `InterServerHandler` (migration in)
|
||||
- Port `PlayerHandler` (movement, attacks)
|
||||
- Port `InventoryHandler` (items)
|
||||
- Port `NPCHandler` (dialogs, shops)
|
||||
|
||||
5. **Implement Map System**
|
||||
- Port `MapleMapFactory`
|
||||
- Create map cache (ETS)
|
||||
- Map loading from WZ data
|
||||
|
||||
6. **Implement Character Loading**
|
||||
- Load full character data from database
|
||||
- Load inventory/equipment
|
||||
- Load skills/buffs/quests
|
||||
|
||||
---
|
||||
|
||||
## Notes for Future Agents
|
||||
|
||||
### Architecture Decisions
|
||||
|
||||
1. **Concurrency Model:**
|
||||
- One process per client connection (GenServer)
|
||||
- One process per map instance (GenServer)
|
||||
- ETS tables for shared caches (items, maps, mobs)
|
||||
- Registry for player lookups by name/ID
|
||||
|
||||
2. **Packet Handling:**
|
||||
- Little-endian encoding (MapleStory standard)
|
||||
- Opcode dispatch pattern in client handlers
|
||||
- Separate modules for each handler type
|
||||
|
||||
3. **Database Strategy:**
|
||||
- Ecto for type safety and migrations
|
||||
- Keep SQL schema compatible with Java server
|
||||
- Consider read replicas for static data
|
||||
|
||||
4. **State Management:**
|
||||
- GenServer state for connection/session
|
||||
- ETS for global/shared state
|
||||
- Redis for cross-server pub/sub
|
||||
|
||||
### Key Files to Reference
|
||||
|
||||
- `src/handling/PacketProcessor.java` - Packet dispatch logic
|
||||
- `src/handling/netty/cipher/` - Encryption algorithms
|
||||
- `src/handling/login/handler/CharLoginHandler.java` - Login flow
|
||||
- `src/tools/packet/LoginPacket.java` - Login packet formats
|
||||
- `src/server/maps/MapleMap.java` - Map system
|
||||
|
||||
---
|
||||
|
||||
## Session History
|
||||
|
||||
### Session 2026-02-14
|
||||
**Completed:**
|
||||
- ✅ Implemented `Odinsea.Net.Processor` (central packet routing)
|
||||
- ✅ Implemented `Odinsea.Login.Handler` (all login packet handlers)
|
||||
- ✅ Implemented `Odinsea.Login.Packets` (login packet builders)
|
||||
- ✅ Created `Odinsea.Database.Schema.Account` (Ecto schema)
|
||||
- ✅ Created `Odinsea.Database.Schema.Character` (Ecto schema)
|
||||
- ✅ Integrated PacketProcessor with Login.Client
|
||||
|
||||
### Session 2026-02-14 (continued)
|
||||
**Completed:**
|
||||
- ✅ Implemented `Odinsea.Database.Context` - Full database context module
|
||||
- Account authentication (with SHA-512 password verification)
|
||||
- Login state management
|
||||
- IP logging
|
||||
- Character CRUD operations
|
||||
- Character name validation
|
||||
- ✅ Integrated login handlers with database (real queries instead of stubs)
|
||||
- ✅ Implemented `Odinsea.World.Migration` - Server transfer system
|
||||
- Migration token creation/validation
|
||||
- Cross-server storage (ETS + Redis)
|
||||
- Token expiration and cleanup
|
||||
- ✅ Implemented `Odinsea.Channel.Players` - Player storage (ETS-based)
|
||||
- ✅ Implemented `Odinsea.Channel.Handler.InterServer` - Migration handler
|
||||
- ✅ Added `verify_salted_sha512/3` to LoginCrypto
|
||||
- ✅ Implemented actual packet sending via TCP sockets
|
||||
|
||||
**Next Steps:**
|
||||
- Channel packet handlers (PlayerHandler, InventoryHandler, etc.)
|
||||
- Map system implementation (MapleMap)
|
||||
- Character full data loading (inventory, skills, quests)
|
||||
- Testing the login flow with real client
|
||||
|
||||
### Session 2026-02-14 (Game Systems - Vertical Slice)
|
||||
**Completed:**
|
||||
- ✅ Implemented `Odinsea.Game.Character` - In-game character state GenServer
|
||||
- Character stats (str, dex, int, luk, hp, mp, etc.)
|
||||
- Position tracking (x, y, foothold, stance)
|
||||
- Map transitions (change_map/3)
|
||||
- Load from database / save to database
|
||||
- SP array parsing (comma-separated to list)
|
||||
- ✅ Implemented `Odinsea.Game.Map` - Map instance GenServer
|
||||
- Player spawn/despawn on map
|
||||
- Object ID (OID) allocation
|
||||
- Broadcasting packets to all players
|
||||
- Broadcasting packets except specific player
|
||||
- Dynamic map loading via DynamicSupervisor
|
||||
- ✅ Added Character and Map registries to application
|
||||
- `Odinsea.CharacterRegistry` for character_id → pid lookups
|
||||
- `Odinsea.MapRegistry` for {map_id, channel_id} → pid lookups
|
||||
- `Odinsea.MapSupervisor` for dynamic map instance supervision
|
||||
- ✅ Implemented `Odinsea.Game.Movement` - Movement parsing (simplified)
|
||||
- Parse movement commands from packets
|
||||
- Extract final position from movement data
|
||||
- Support for basic movement types (absolute, relative, teleport)
|
||||
- TODO: Full 40+ movement command types
|
||||
- ✅ Updated `Odinsea.Channel.Packets`
|
||||
- `spawn_player/2` - Spawn player on map (minimal encoding)
|
||||
- `remove_player/1` - Remove player from map
|
||||
- Helper functions for appearance/buffs/mounts/pets (minimal)
|
||||
- ✅ Updated `Odinsea.Net.Opcodes`
|
||||
- Added `lp_spawn_player/0` (184)
|
||||
- Added `lp_remove_player_from_map/0` (185)
|
||||
- Added `lp_chattext/0` (186)
|
||||
- Added `lp_move_player/0` (226)
|
||||
- Added `lp_update_char_look/0` (241)
|
||||
|
||||
**Architecture Notes:**
|
||||
- Took vertical slice approach: minimal character → minimal map → spawn/movement
|
||||
- Each character is a GenServer (isolation, crash safety)
|
||||
- Each map instance is a GenServer (per-channel map isolation)
|
||||
- Registry pattern for lookups (distributed-ready)
|
||||
- DynamicSupervisor for on-demand map loading
|
||||
|
||||
**Next Steps:**
|
||||
- Implement basic PlayerHandler (movement, chat, map changes)
|
||||
- Expand character encoding (equipment, buffs, pets)
|
||||
- Implement full movement parsing (all 40+ command types)
|
||||
- Add inventory system
|
||||
- Add NPC interaction
|
||||
|
||||
### Session 2026-02-14 (Channel Handlers - Chat & Movement)
|
||||
**Completed:**
|
||||
- ✅ Implemented `Odinsea.Channel.Handler.Chat` - Full chat handler
|
||||
- General chat (broadcast to map)
|
||||
- Party chat (buddy, party, guild, alliance, expedition routing)
|
||||
- Whisper system (find player, send whisper)
|
||||
- ✅ Implemented `Odinsea.Channel.Handler.Player` - Player action handler
|
||||
- MovePlayer (character movement with broadcast)
|
||||
- ChangeMap (portal-based map transitions)
|
||||
- ChangeKeymap (keybinding changes)
|
||||
- ChangeSkillMacro (skill macro management)
|
||||
- Attack stubs (CloseRange, Ranged, Magic - ready for implementation)
|
||||
- TakeDamage stub
|
||||
- ✅ Added chat packet builders to `Odinsea.Channel.Packets`
|
||||
- UserChat (general chat)
|
||||
- WhisperReceived / WhisperReply
|
||||
- FindPlayerReply / FindPlayerWithMap
|
||||
- MultiChat (party/guild/alliance/expedition)
|
||||
- ✅ Wired handlers into `Odinsea.Channel.Client` packet dispatcher
|
||||
- Integrated ChatHandler for all chat opcodes
|
||||
- Integrated PlayerHandler for movement, map changes, attacks
|
||||
- ✅ Added missing opcodes (lp_whisper, lp_multi_chat)
|
||||
|
||||
**Architecture Notes:**
|
||||
- Chat system routes through World services for cross-channel support
|
||||
- Movement broadcasts to all players on map except mover
|
||||
- Handlers use character GenServer for state management
|
||||
- Map GenServer handles player tracking and packet broadcasting
|
||||
|
||||
**Next Steps:**
|
||||
- Implement full attack system (damage calculation, mob interaction)
|
||||
- Port NPC handler (NPC talk, shops)
|
||||
- Port Inventory handler (item usage, equipping)
|
||||
- Implement mob system (spawning, movement, AI)
|
||||
- Implement party/guild/buddy systems in World layer
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: 2026-02-14*
|
||||
*Current Phase: Channel Handlers (40% → 45%)*
|
||||
174
README.md
Normal file
174
README.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# Odinsea Elixir
|
||||
|
||||
An Elixir/OTP port of the Odinsea Java MapleStory private server.
|
||||
|
||||
## Project Status
|
||||
|
||||
**Current Phase:** Foundation Complete, Networking In Progress
|
||||
**Overall Progress:** ~15%
|
||||
|
||||
See [PORT_PROGRESS.md](./PORT_PROGRESS.md) for detailed progress tracking.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Odinsea Elixir
|
||||
├── Login Server (port 8584) - Authentication & character selection
|
||||
├── Channel Servers (ports 8585+) - Game world (multiple channels)
|
||||
├── Cash Shop (port 8605) - Item purchasing
|
||||
├── World Services - Cross-server state (parties, guilds)
|
||||
└── Database - MySQL/MariaDB via Ecto
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Elixir 1.17+
|
||||
- MySQL/MariaDB
|
||||
- Redis
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
mix deps.get
|
||||
|
||||
# Configure database (edit config/runtime.exs or use env vars)
|
||||
export ODINSEA_DB_HOST=localhost
|
||||
export ODINSEA_DB_NAME=odin_sea
|
||||
export ODINSEA_DB_USER=root
|
||||
export ODINSEA_DB_PASS=
|
||||
|
||||
# Run database migrations
|
||||
mix ecto.setup
|
||||
|
||||
# Start the server
|
||||
mix run --no-halt
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `ODINSEA_NAME` | Luna | Server name |
|
||||
| `ODINSEA_HOST` | 127.0.0.1 | Server host |
|
||||
| `ODINSEA_LOGIN_PORT` | 8584 | Login server port |
|
||||
| `ODINSEA_CHANNEL_COUNT` | 2 | Number of game channels |
|
||||
| `ODINSEA_DB_HOST` | localhost | Database host |
|
||||
| `ODINSEA_DB_NAME` | odin_sea | Database name |
|
||||
| `ODINSEA_REDIS_HOST` | localhost | Redis host |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
lib/odinsea/
|
||||
├── application.ex # Main supervisor
|
||||
├── constants/ # Game constants
|
||||
│ ├── game.ex # Game mechanics constants
|
||||
│ └── server.ex # Protocol constants
|
||||
├── database/
|
||||
│ └── repo.ex # Ecto repository
|
||||
├── login/ # Login server
|
||||
│ ├── listener.ex # TCP listener
|
||||
│ └── client.ex # Client handler
|
||||
├── channel/ # Game channel servers
|
||||
│ ├── supervisor.ex # Channel supervisor
|
||||
│ ├── server.ex # Individual channel
|
||||
│ └── client.ex # Channel client handler
|
||||
├── shop/ # Cash shop server
|
||||
│ ├── listener.ex # TCP listener
|
||||
│ └── client.ex # Client handler
|
||||
├── world/ # Cross-server services
|
||||
│ ├── supervisor.ex # World supervisor
|
||||
│ ├── world.ex # World state
|
||||
│ ├── party.ex # Party management
|
||||
│ ├── guild.ex # Guild management
|
||||
│ ├── family.ex # Family management
|
||||
│ ├── expedition.ex # Expedition management
|
||||
│ └── messenger.ex # Messenger/chat
|
||||
└── net/ # Networking
|
||||
├── opcodes.ex # Packet opcodes
|
||||
├── hex.ex # Hex utilities
|
||||
└── packet/
|
||||
├── in.ex # Packet decoder
|
||||
└── out.ex # Packet encoder
|
||||
```
|
||||
|
||||
## Client Compatibility
|
||||
|
||||
- **Version:** GMS v342
|
||||
- **Patch:** 1
|
||||
- **Revision:** 1
|
||||
|
||||
## Key Differences from Java
|
||||
|
||||
1. **Concurrency:** Elixir processes instead of Java threads
|
||||
2. **State:** GenServer/ETS instead of mutable fields
|
||||
3. **Networking:** gen_tcp instead of Netty
|
||||
4. **Database:** Ecto instead of raw JDBC
|
||||
5. **Hot Reloading:** Native Elixir code reloading
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
mix test
|
||||
```
|
||||
|
||||
### Code Quality
|
||||
|
||||
```bash
|
||||
# Linting
|
||||
mix credo
|
||||
|
||||
# Type checking
|
||||
mix dialyzer
|
||||
|
||||
# Formatting
|
||||
mix format
|
||||
```
|
||||
|
||||
### Packet Testing
|
||||
|
||||
```elixir
|
||||
# Decode a packet
|
||||
packet = Odinsea.Net.Packet.In.new(<<0x01, 0x00, 0x05, 0x00>>)
|
||||
{opcode, packet} = Odinsea.Net.Packet.In.decode_short(packet)
|
||||
|
||||
# Encode a packet
|
||||
packet = Odinsea.Net.Packet.Out.new(0x00)
|
||||
packet = Odinsea.Net.Packet.Out.encode_string(packet, "Hello")
|
||||
binary = Odinsea.Net.Packet.Out.to_binary(packet)
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
See [PORT_PROGRESS.md](./PORT_PROGRESS.md) for the complete roadmap.
|
||||
|
||||
### Immediate Next Steps
|
||||
|
||||
1. Implement AES encryption (`lib/odinsea/net/cipher/aes.ex`)
|
||||
2. Port login handlers (`lib/odinsea/login/handler.ex`)
|
||||
3. Create database schemas
|
||||
4. Implement login packet responses
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a port of the Java Odinsea server. When implementing features:
|
||||
|
||||
1. Reference the Java source in `/home/ra/lucid/src/`
|
||||
2. Follow Elixir/OTP best practices
|
||||
3. Update PORT_PROGRESS.md
|
||||
4. Add tests where applicable
|
||||
|
||||
## License
|
||||
|
||||
Same as the original Odinsea project.
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Original Odinsea Java developers
|
||||
- MapleStory community
|
||||
- Elixir/OTP team
|
||||
61
config/config.exs
Normal file
61
config/config.exs
Normal file
@@ -0,0 +1,61 @@
|
||||
import Config
|
||||
|
||||
# Default configuration (overridden by runtime.exs)
|
||||
|
||||
config :odinsea, :server,
|
||||
name: "Luna",
|
||||
host: "127.0.0.1",
|
||||
revision: 1,
|
||||
flag: 0,
|
||||
slide_message: "Welcome to Luna",
|
||||
data_prefix: "Luna"
|
||||
|
||||
config :odinsea, :rates,
|
||||
exp: 5,
|
||||
meso: 3,
|
||||
drop: 1,
|
||||
quest: 1
|
||||
|
||||
config :odinsea, :login,
|
||||
port: 8584,
|
||||
user_limit: 1500,
|
||||
max_characters: 3,
|
||||
flag: 3,
|
||||
event_message: "Welcome to Luna"
|
||||
|
||||
config :odinsea, :game,
|
||||
channels: 2,
|
||||
channel_ports: %{1 => 8585, 2 => 8586},
|
||||
events: []
|
||||
|
||||
config :odinsea, :shop,
|
||||
port: 8605
|
||||
|
||||
config :odinsea, :features,
|
||||
admin_mode: false,
|
||||
proxy_mode: false,
|
||||
extra_crypt: false,
|
||||
log_trace: true,
|
||||
log_packet: true,
|
||||
rsa_passwords: false,
|
||||
script_reload: true,
|
||||
family_disable: false,
|
||||
custom_lang: false,
|
||||
custom_dmgskin: true,
|
||||
skip_maccheck: true
|
||||
|
||||
config :odinsea, ecto_repos: [Odinsea.Repo]
|
||||
|
||||
config :odinsea, Odinsea.Repo,
|
||||
database: "odin_sea",
|
||||
username: "root",
|
||||
password: "",
|
||||
hostname: "localhost",
|
||||
port: 3306,
|
||||
pool_size: 32
|
||||
|
||||
config :odinsea, :redis,
|
||||
host: "localhost",
|
||||
port: 6379,
|
||||
timeout: 5000,
|
||||
pool_size: 10
|
||||
115
config/runtime.exs
Normal file
115
config/runtime.exs
Normal file
@@ -0,0 +1,115 @@
|
||||
import Config
|
||||
|
||||
# Odinsea Server Configuration
|
||||
# This file maps the Java properties files to Elixir configuration
|
||||
|
||||
# ====================================================================================================
|
||||
# Server Identity
|
||||
# ====================================================================================================
|
||||
config :odinsea, :server,
|
||||
name: System.get_env("ODINSEA_NAME", "Luna"),
|
||||
host: System.get_env("ODINSEA_HOST", "127.0.0.1"),
|
||||
revision: String.to_integer(System.get_env("ODINSEA_REV", "1")),
|
||||
flag: String.to_integer(System.get_env("ODINSEA_FLAG", "0")),
|
||||
slide_message: System.get_env("ODINSEA_SLIDE_MESSAGE", "Welcome to Luna v99. The Ultimate Private Server"),
|
||||
data_prefix: System.get_env("ODINSEA_DATA_PREFIX", "Luna")
|
||||
|
||||
# ====================================================================================================
|
||||
# Game Rates
|
||||
# ====================================================================================================
|
||||
config :odinsea, :rates,
|
||||
exp: String.to_integer(System.get_env("ODINSEA_RATE_EXP", "5")),
|
||||
meso: String.to_integer(System.get_env("ODINSEA_RATE_MESO", "3")),
|
||||
drop: String.to_integer(System.get_env("ODINSEA_RATE_DROP", "1")),
|
||||
quest: String.to_integer(System.get_env("ODINSEA_RATE_QUEST", "1"))
|
||||
|
||||
# ====================================================================================================
|
||||
# Login Server
|
||||
# ====================================================================================================
|
||||
config :odinsea, :login,
|
||||
port: String.to_integer(System.get_env("ODINSEA_LOGIN_PORT", "8584")),
|
||||
user_limit: String.to_integer(System.get_env("ODINSEA_USER_LIMIT", "1500")),
|
||||
max_characters: String.to_integer(System.get_env("ODINSEA_MAX_CHARACTERS", "3")),
|
||||
flag: String.to_integer(System.get_env("ODINSEA_LOGIN_FLAG", "3")),
|
||||
event_message: System.get_env("ODINSEA_EVENT_MESSAGE", "#bLuna v99\\r\\n#rThe Ultimate Private Server")
|
||||
|
||||
# ====================================================================================================
|
||||
# Game Channels
|
||||
# ====================================================================================================
|
||||
channel_count = String.to_integer(System.get_env("ODINSEA_CHANNEL_COUNT", "2"))
|
||||
|
||||
# Generate channel port configuration
|
||||
channel_ports =
|
||||
for i <- 1..channel_count do
|
||||
port = String.to_integer(System.get_env("ODINSEA_CHANNEL_PORT_#{i}", "#{8584 + i}"))
|
||||
{i, port}
|
||||
end
|
||||
|> Map.new()
|
||||
|
||||
config :odinsea, :game,
|
||||
channels: channel_count,
|
||||
channel_ports: channel_ports,
|
||||
events: System.get_env("ODINSEA_EVENTS",
|
||||
"MiniDungeon,Olivia,PVP,CygnusBattle,ScarTarBattle,VonLeonBattle"
|
||||
) |> String.split(",")
|
||||
|
||||
# ====================================================================================================
|
||||
# Cash Shop Server
|
||||
# ====================================================================================================
|
||||
config :odinsea, :shop,
|
||||
port: String.to_integer(System.get_env("ODINSEA_SHOP_PORT", "8605"))
|
||||
|
||||
# ====================================================================================================
|
||||
# Database
|
||||
# ====================================================================================================
|
||||
config :odinsea, Odinsea.Repo,
|
||||
database: System.get_env("ODINSEA_DB_NAME", "odin_sea"),
|
||||
username: System.get_env("ODINSEA_DB_USER", "root"),
|
||||
password: System.get_env("ODINSEA_DB_PASS", ""),
|
||||
hostname: System.get_env("ODINSEA_DB_HOST", "localhost"),
|
||||
port: String.to_integer(System.get_env("ODINSEA_DB_PORT", "3306")),
|
||||
pool_size: String.to_integer(System.get_env("ODINSEA_DB_POOL_SIZE", "32")),
|
||||
queue_target: 50,
|
||||
queue_interval: 1000
|
||||
|
||||
# ====================================================================================================
|
||||
# Redis
|
||||
# ====================================================================================================
|
||||
config :odinsea, :redis,
|
||||
host: System.get_env("ODINSEA_REDIS_HOST", "localhost"),
|
||||
port: String.to_integer(System.get_env("ODINSEA_REDIS_PORT", "6379")),
|
||||
timeout: String.to_integer(System.get_env("ODINSEA_REDIS_TIMEOUT", "5000")),
|
||||
pool_size: String.to_integer(System.get_env("ODINSEA_REDIS_POOL_SIZE", "10"))
|
||||
|
||||
# ====================================================================================================
|
||||
# Feature Flags (from plugin.properties)
|
||||
# ====================================================================================================
|
||||
config :odinsea, :features,
|
||||
admin_mode: System.get_env("ODINSEA_ADMIN_MODE", "false") == "true",
|
||||
proxy_mode: System.get_env("ODINSEA_PROXY_MODE", "false") == "true",
|
||||
extra_crypt: System.get_env("ODINSEA_EXTRA_CRYPT", "false") == "true",
|
||||
log_trace: System.get_env("ODINSEA_LOG_TRACE", "true") == "true",
|
||||
log_packet: System.get_env("ODINSEA_LOG_PACKET", "true") == "true",
|
||||
rsa_passwords: System.get_env("ODINSEA_RSA_PASSWORDS", "false") == "true",
|
||||
script_reload: System.get_env("ODINSEA_SCRIPT_RELOAD", "true") == "true",
|
||||
family_disable: System.get_env("ODINSEA_FAMILY_DISABLE", "false") == "true",
|
||||
custom_lang: System.get_env("ODINSEA_CUSTOM_LANG", "false") == "true",
|
||||
custom_dmgskin: System.get_env("ODINSEA_CUSTOM_DMGSKIN", "true") == "true",
|
||||
skip_maccheck: System.get_env("ODINSEA_SKIP_MACCHECK", "true") == "true"
|
||||
|
||||
# ====================================================================================================
|
||||
# Logging
|
||||
# ====================================================================================================
|
||||
config :logger,
|
||||
level: String.to_atom(System.get_env("ODINSEA_LOG_LEVEL", "info")),
|
||||
backends: [:console, {LoggerFileBackend, :file_log}]
|
||||
|
||||
config :logger, :console,
|
||||
format: "$time [$level] $message\n",
|
||||
metadata: [:module, :function]
|
||||
|
||||
config :logger, :file_log,
|
||||
path: System.get_env("ODINSEA_LOG_PATH", "logs/odinsea.log"),
|
||||
format: "$date $time [$level] $message\n",
|
||||
metadata: [:module, :function],
|
||||
level: :info
|
||||
125
lib/odinsea.ex
Normal file
125
lib/odinsea.ex
Normal file
@@ -0,0 +1,125 @@
|
||||
defmodule Odinsea do
|
||||
@moduledoc """
|
||||
Odinsea - An Elixir/OTP MapleStory private server.
|
||||
|
||||
This is a port of the Java Odinsea server (GMS v342).
|
||||
|
||||
## Architecture
|
||||
|
||||
The server consists of multiple components:
|
||||
|
||||
- **Login Server**: Handles authentication and character selection
|
||||
- **Channel Servers**: Game world instances (multiple channels)
|
||||
- **Cash Shop Server**: Item purchasing
|
||||
- **World Services**: Cross-server state management
|
||||
|
||||
## Quick Start
|
||||
|
||||
Start the server:
|
||||
|
||||
iex> Odinsea.start()
|
||||
|
||||
Or with Mix:
|
||||
|
||||
$ mix run --no-halt
|
||||
|
||||
## Configuration
|
||||
|
||||
See `config/runtime.exs` for all configuration options.
|
||||
Environment variables are supported for all settings.
|
||||
|
||||
## Packet Handling
|
||||
|
||||
Incoming packets are decoded with `Odinsea.Net.Packet.In`:
|
||||
|
||||
packet = Odinsea.Net.Packet.In.new(binary_data)
|
||||
{opcode, packet} = Odinsea.Net.Packet.In.decode_short(packet)
|
||||
{value, packet} = Odinsea.Net.Packet.In.decode_int(packet)
|
||||
|
||||
Outgoing packets are encoded with `Odinsea.Net.Packet.Out`:
|
||||
|
||||
packet = Odinsea.Net.Packet.Out.new(0x00) # opcode
|
||||
packet = Odinsea.Net.Packet.Out.encode_string(packet, "Hello")
|
||||
binary = Odinsea.Net.Packet.Out.to_binary(packet)
|
||||
|
||||
## Constants
|
||||
|
||||
Game constants are in `Odinsea.Constants.Game`:
|
||||
|
||||
Odinsea.Constants.Game.max_level() # 250
|
||||
Odinsea.Constants.Game.exp_rate() # configured rate
|
||||
Odinsea.Constants.Game.job_name(112) # "Hero"
|
||||
|
||||
Server constants are in `Odinsea.Constants.Server`:
|
||||
|
||||
Odinsea.Constants.Server.maple_version() # 342
|
||||
Odinsea.Constants.Server.server_name() # "Luna"
|
||||
"""
|
||||
|
||||
alias Odinsea.Constants.Server
|
||||
|
||||
@doc """
|
||||
Returns the server version string.
|
||||
"""
|
||||
@spec version() :: String.t()
|
||||
def version do
|
||||
"Odinsea Elixir v0.1.0 (Rev #{Server.server_revision()})"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the MapleStory client version.
|
||||
"""
|
||||
@spec client_version() :: integer()
|
||||
def client_version do
|
||||
Server.maple_version()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the server name.
|
||||
"""
|
||||
@spec server_name() :: String.t()
|
||||
def server_name do
|
||||
Server.server_name()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the current timestamp in milliseconds.
|
||||
"""
|
||||
@spec now() :: integer()
|
||||
def now do
|
||||
System.system_time(:millisecond)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Starts the Odinsea application.
|
||||
"""
|
||||
@spec start() :: :ok | {:error, term()}
|
||||
def start do
|
||||
Application.ensure_all_started(:odinsea)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops the Odinsea application.
|
||||
"""
|
||||
@spec stop() :: :ok
|
||||
def stop do
|
||||
Application.stop(:odinsea)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the application uptime in milliseconds.
|
||||
"""
|
||||
@spec uptime() :: integer()
|
||||
def uptime do
|
||||
Odinsea.Application.uptime()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the server is running.
|
||||
"""
|
||||
@spec running?() :: boolean()
|
||||
def running? do
|
||||
Application.started_applications()
|
||||
|> Enum.any?(fn {app, _, _} -> app == :odinsea end)
|
||||
end
|
||||
end
|
||||
116
lib/odinsea/application.ex
Normal file
116
lib/odinsea/application.ex
Normal file
@@ -0,0 +1,116 @@
|
||||
defmodule Odinsea.Application do
|
||||
@moduledoc """
|
||||
Main application supervisor for Odinsea.
|
||||
Ported from Java Program.java and ServerStart.java.
|
||||
"""
|
||||
|
||||
use Application
|
||||
|
||||
require Logger
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
Logger.info("Starting Odinsea Server v#{version()}")
|
||||
|
||||
# Log server configuration
|
||||
log_configuration()
|
||||
|
||||
children = [
|
||||
# Database repository
|
||||
Odinsea.Repo,
|
||||
|
||||
# Redis connection pool
|
||||
{Redix, name: :redix, host: redis_config()[:host], port: redis_config()[:port]},
|
||||
|
||||
# Registry for player lookups
|
||||
{Registry, keys: :unique, name: Odinsea.PlayerRegistry},
|
||||
|
||||
# Registry for channel lookups
|
||||
{Registry, keys: :unique, name: Odinsea.Channel.Registry},
|
||||
|
||||
# Registry for character processes (in-game players)
|
||||
{Registry, keys: :unique, name: Odinsea.CharacterRegistry},
|
||||
|
||||
# Registry for map instances {map_id, channel_id} => pid
|
||||
{Registry, keys: :unique, name: Odinsea.MapRegistry},
|
||||
|
||||
# Dynamic supervisor for client connections
|
||||
{DynamicSupervisor, strategy: :one_for_one, name: Odinsea.ClientSupervisor},
|
||||
|
||||
# Dynamic supervisor for map instances
|
||||
{DynamicSupervisor, strategy: :one_for_one, name: Odinsea.MapSupervisor},
|
||||
|
||||
# Game world supervisor
|
||||
Odinsea.World.Supervisor,
|
||||
|
||||
# Login server
|
||||
Odinsea.Login.Listener,
|
||||
|
||||
# Channel servers (dynamic based on config)
|
||||
{Odinsea.Channel.Supervisor, game_config()[:channels]},
|
||||
|
||||
# Cash shop server
|
||||
Odinsea.Shop.Listener
|
||||
]
|
||||
|
||||
opts = [strategy: :one_for_one, name: Odinsea.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def prep_stop(state) do
|
||||
Logger.info("Odinsea server shutting down...")
|
||||
# Perform graceful shutdown tasks
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the server version.
|
||||
"""
|
||||
def version do
|
||||
"0.1.0-#{Odinsea.Constants.Server.server_revision()}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the current unix timestamp in milliseconds.
|
||||
"""
|
||||
def current_time do
|
||||
System.system_time(:millisecond)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns uptime in milliseconds.
|
||||
"""
|
||||
def uptime do
|
||||
current_time() - Application.get_env(:odinsea, :start_time, current_time())
|
||||
end
|
||||
|
||||
defp log_configuration do
|
||||
server = server_config()
|
||||
rates = Application.get_env(:odinsea, :rates, [])
|
||||
|
||||
Logger.info("""
|
||||
Server Configuration:
|
||||
Name: #{server[:name]}
|
||||
Host: #{server[:host]}
|
||||
Revision: #{server[:revision]}
|
||||
EXP Rate: #{rates[:exp]}x
|
||||
Meso Rate: #{rates[:meso]}x
|
||||
""")
|
||||
|
||||
login = login_config()
|
||||
Logger.info("Login Server: port=#{login[:port]}, limit=#{login[:user_limit]}")
|
||||
|
||||
game = game_config()
|
||||
Logger.info("Game Channels: count=#{game[:channels]}")
|
||||
|
||||
shop = shop_config()
|
||||
Logger.info("Cash Shop: port=#{shop[:port]}")
|
||||
end
|
||||
|
||||
defp server_config, do: Application.get_env(:odinsea, :server, [])
|
||||
defp login_config, do: Application.get_env(:odinsea, :login, [])
|
||||
defp game_config, do: Application.get_env(:odinsea, :game, [])
|
||||
defp shop_config, do: Application.get_env(:odinsea, :shop, [])
|
||||
defp redis_config, do: Application.get_env(:odinsea, :redis, [])
|
||||
end
|
||||
178
lib/odinsea/channel/client.ex
Normal file
178
lib/odinsea/channel/client.ex
Normal file
@@ -0,0 +1,178 @@
|
||||
defmodule Odinsea.Channel.Client do
|
||||
@moduledoc """
|
||||
Client connection handler for game channel servers.
|
||||
Manages the game session state.
|
||||
"""
|
||||
|
||||
use GenServer, restart: :temporary
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Channel.Handler
|
||||
|
||||
defstruct [:socket, :ip, :channel_id, :state, :character_id]
|
||||
|
||||
def start_link({socket, channel_id}) do
|
||||
GenServer.start_link(__MODULE__, {socket, channel_id})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init({socket, channel_id}) do
|
||||
{:ok, {ip, _port}} = :inet.peername(socket)
|
||||
ip_string = format_ip(ip)
|
||||
|
||||
Logger.info("Channel #{channel_id} client connected from #{ip_string}")
|
||||
|
||||
state = %__MODULE__{
|
||||
socket: socket,
|
||||
ip: ip_string,
|
||||
channel_id: channel_id,
|
||||
state: :connected,
|
||||
character_id: nil
|
||||
}
|
||||
|
||||
send(self(), :receive)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:receive, %{socket: socket} = state) do
|
||||
case :gen_tcp.recv(socket, 0, 30_000) do
|
||||
{:ok, data} ->
|
||||
new_state = handle_packet(data, state)
|
||||
send(self(), :receive)
|
||||
{:noreply, new_state}
|
||||
|
||||
{:error, :closed} ->
|
||||
Logger.info("Channel #{state.channel_id} client disconnected: #{state.ip}")
|
||||
{:stop, :normal, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Channel client error: #{inspect(reason)}")
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, state) do
|
||||
if state.socket do
|
||||
:gen_tcp.close(state.socket)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_packet(data, state) do
|
||||
packet = In.new(data)
|
||||
|
||||
case In.decode_short(packet) do
|
||||
{opcode, packet} ->
|
||||
Logger.debug("Channel #{state.channel_id} packet: opcode=0x#{Integer.to_string(opcode, 16)}")
|
||||
dispatch_packet(opcode, packet, state)
|
||||
|
||||
:error ->
|
||||
Logger.warning("Failed to read packet opcode")
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch_packet(opcode, packet, state) do
|
||||
# Define opcodes for matching
|
||||
cp_general_chat = Opcodes.cp_general_chat()
|
||||
cp_party_chat = Opcodes.cp_party_chat()
|
||||
cp_whisper = Opcodes.cp_whisper()
|
||||
cp_move_player = Opcodes.cp_move_player()
|
||||
cp_change_map = Opcodes.cp_change_map()
|
||||
cp_change_keymap = Opcodes.cp_change_keymap()
|
||||
cp_skill_macro = Opcodes.cp_skill_macro()
|
||||
cp_close_range_attack = Opcodes.cp_close_range_attack()
|
||||
cp_ranged_attack = Opcodes.cp_ranged_attack()
|
||||
cp_magic_attack = Opcodes.cp_magic_attack()
|
||||
cp_take_damage = Opcodes.cp_take_damage()
|
||||
|
||||
case opcode do
|
||||
# Chat handlers
|
||||
^cp_general_chat ->
|
||||
case Handler.Chat.handle_general_chat(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_party_chat ->
|
||||
case Handler.Chat.handle_party_chat(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_whisper ->
|
||||
case Handler.Chat.handle_whisper(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
# Player movement and actions
|
||||
^cp_move_player ->
|
||||
case Handler.Player.handle_move_player(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_change_map ->
|
||||
case Handler.Player.handle_change_map(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_change_keymap ->
|
||||
case Handler.Player.handle_change_keymap(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_skill_macro ->
|
||||
case Handler.Player.handle_change_skill_macro(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
# Combat handlers (stubs for now)
|
||||
^cp_close_range_attack ->
|
||||
case Handler.Player.handle_close_range_attack(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_ranged_attack ->
|
||||
case Handler.Player.handle_ranged_attack(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_magic_attack ->
|
||||
case Handler.Player.handle_magic_attack(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
^cp_take_damage ->
|
||||
case Handler.Player.handle_take_damage(packet, state) do
|
||||
{:ok, new_state} -> new_state
|
||||
_ -> state
|
||||
end
|
||||
|
||||
_ ->
|
||||
Logger.debug("Unhandled channel opcode: 0x#{Integer.to_string(opcode, 16)}")
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp format_ip({a, b, c, d}) do
|
||||
"#{a}.#{b}.#{c}.#{d}"
|
||||
end
|
||||
|
||||
defp format_ip({a, b, c, d, e, f, g, h}) do
|
||||
"#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
|
||||
end
|
||||
end
|
||||
266
lib/odinsea/channel/handler/chat.ex
Normal file
266
lib/odinsea/channel/handler/chat.ex
Normal file
@@ -0,0 +1,266 @@
|
||||
defmodule Odinsea.Channel.Handler.Chat do
|
||||
@moduledoc """
|
||||
Handles chat packets (general, party, whisper, messenger).
|
||||
Ported from src/handling/channel/handler/ChatHandler.java
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Channel.Packets
|
||||
alias Odinsea.Game.Character
|
||||
|
||||
@max_chat_length 80
|
||||
@max_staff_chat_length 512
|
||||
|
||||
@doc """
|
||||
Handles general chat (CP_USER_CHAT).
|
||||
Ported from ChatHandler.GeneralChat()
|
||||
"""
|
||||
def handle_general_chat(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid),
|
||||
{:ok, map_pid} <- get_map_pid(character.map_id, client_state.channel_id) do
|
||||
# Decode packet
|
||||
{tick, packet} = In.decode_int(packet)
|
||||
{message, packet} = In.decode_string(packet)
|
||||
{only_balloon, _packet} = In.decode_byte(packet)
|
||||
|
||||
# Validate message
|
||||
cond do
|
||||
String.length(message) == 0 ->
|
||||
{:ok, client_state}
|
||||
|
||||
String.length(message) >= @max_chat_length ->
|
||||
Logger.warning("Chat message too long from character #{character.id}")
|
||||
{:ok, client_state}
|
||||
|
||||
true ->
|
||||
# TODO: Process commands (CommandProcessor.processCommand)
|
||||
# TODO: Check if muted
|
||||
# TODO: Anti-spam checks
|
||||
|
||||
# Broadcast chat to map
|
||||
chat_packet = Packets.user_chat(character.id, message, false, only_balloon == 1)
|
||||
|
||||
Odinsea.Game.Map.broadcast(map_pid, chat_packet)
|
||||
|
||||
# Log chat
|
||||
Logger.info(
|
||||
"Chat [#{character.name}] (Map #{character.map_id}): #{message}"
|
||||
)
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("General chat failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles party chat (CP_PARTY_CHAT).
|
||||
Ported from ChatHandler.PartyChat()
|
||||
|
||||
Chat types:
|
||||
- 0: Buddy
|
||||
- 1: Party
|
||||
- 2: Guild
|
||||
- 3: Alliance
|
||||
- 4: Expedition
|
||||
"""
|
||||
def handle_party_chat(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# Decode packet
|
||||
{chat_type, packet} = In.decode_byte(packet)
|
||||
{num_recipients, packet} = In.decode_byte(packet)
|
||||
|
||||
# Validate recipients count
|
||||
if num_recipients < 1 or num_recipients > 6 do
|
||||
{:ok, client_state}
|
||||
else
|
||||
# Read recipient IDs
|
||||
{recipients, packet} = decode_recipients(packet, num_recipients, [])
|
||||
{message, _packet} = In.decode_string(packet)
|
||||
|
||||
# Validate message
|
||||
if String.length(message) == 0 do
|
||||
{:ok, client_state}
|
||||
else
|
||||
# TODO: Process commands
|
||||
# TODO: Check if muted
|
||||
|
||||
# Route based on chat type
|
||||
route_party_chat(chat_type, character, recipients, message)
|
||||
|
||||
# Log chat
|
||||
chat_type_name = get_chat_type_name(chat_type)
|
||||
|
||||
Logger.info(
|
||||
"Chat [#{character.name}] (#{chat_type_name}): #{message}"
|
||||
)
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Party chat failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles whisper/find commands (CP_WHISPER).
|
||||
Ported from ChatHandler.WhisperFind()
|
||||
"""
|
||||
def handle_whisper(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# Decode packet
|
||||
{mode, packet} = In.decode_byte(packet)
|
||||
{_tick, packet} = In.decode_int(packet)
|
||||
|
||||
case mode do
|
||||
# Find player (mode 5 or 68)
|
||||
mode when mode in [5, 68] ->
|
||||
{recipient, _packet} = In.decode_string(packet)
|
||||
handle_find_player(recipient, character, client_state)
|
||||
|
||||
# Whisper (mode 6)
|
||||
6 ->
|
||||
{recipient, packet} = In.decode_string(packet)
|
||||
{message, _packet} = In.decode_string(packet)
|
||||
handle_whisper_message(recipient, message, character, client_state)
|
||||
|
||||
_ ->
|
||||
Logger.warning("Unknown whisper mode: #{mode}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Whisper failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp get_character(client_state) do
|
||||
case client_state.character_id do
|
||||
nil ->
|
||||
{:error, :no_character}
|
||||
|
||||
character_id ->
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :character_not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_map_pid(map_id, channel_id) do
|
||||
case Registry.lookup(Odinsea.MapRegistry, {map_id, channel_id}) do
|
||||
[{pid, _}] ->
|
||||
{:ok, pid}
|
||||
|
||||
[] ->
|
||||
# Map not loaded yet - load it
|
||||
case DynamicSupervisor.start_child(
|
||||
Odinsea.MapSupervisor,
|
||||
{Odinsea.Game.Map, {map_id, channel_id}}
|
||||
) do
|
||||
{:ok, pid} -> {:ok, pid}
|
||||
{:error, {:already_started, pid}} -> {:ok, pid}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_recipients(packet, 0, acc), do: {Enum.reverse(acc), packet}
|
||||
|
||||
defp decode_recipients(packet, count, acc) do
|
||||
{recipient_id, packet} = In.decode_int(packet)
|
||||
decode_recipients(packet, count - 1, [recipient_id | acc])
|
||||
end
|
||||
|
||||
defp route_party_chat(chat_type, character, recipients, message) do
|
||||
case chat_type do
|
||||
0 ->
|
||||
# Buddy chat
|
||||
Logger.debug("Buddy chat from #{character.name} to #{inspect(recipients)}: #{message}")
|
||||
# TODO: Implement World.Buddy.buddyChat
|
||||
|
||||
1 ->
|
||||
# Party chat
|
||||
Logger.debug("Party chat from #{character.name}: #{message}")
|
||||
# TODO: Implement World.Party.partyChat
|
||||
|
||||
2 ->
|
||||
# Guild chat
|
||||
Logger.debug("Guild chat from #{character.name}: #{message}")
|
||||
# TODO: Implement World.Guild.guildChat
|
||||
|
||||
3 ->
|
||||
# Alliance chat
|
||||
Logger.debug("Alliance chat from #{character.name}: #{message}")
|
||||
# TODO: Implement World.Alliance.allianceChat
|
||||
|
||||
4 ->
|
||||
# Expedition chat
|
||||
Logger.debug("Expedition chat from #{character.name}: #{message}")
|
||||
# TODO: Implement World.Party.expedChat
|
||||
|
||||
_ ->
|
||||
Logger.warning("Unknown party chat type: #{chat_type}")
|
||||
end
|
||||
end
|
||||
|
||||
defp get_chat_type_name(0), do: "Buddy"
|
||||
defp get_chat_type_name(1), do: "Party"
|
||||
defp get_chat_type_name(2), do: "Guild"
|
||||
defp get_chat_type_name(3), do: "Alliance"
|
||||
defp get_chat_type_name(4), do: "Expedition"
|
||||
defp get_chat_type_name(_), do: "Unknown"
|
||||
|
||||
defp handle_find_player(recipient, character, client_state) do
|
||||
# TODO: Implement player search across channels
|
||||
# For now, just search locally
|
||||
case Odinsea.Channel.Players.find_by_name(client_state.channel_id, recipient) do
|
||||
{:ok, _target_character} ->
|
||||
# Send find reply with map
|
||||
# TODO: Send packet
|
||||
Logger.debug("Found player #{recipient} on current channel")
|
||||
{:ok, client_state}
|
||||
|
||||
{:error, :not_found} ->
|
||||
# Search other channels
|
||||
Logger.debug("Player #{recipient} not found")
|
||||
# TODO: Send "player not found" packet
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_whisper_message(recipient, message, character, client_state) do
|
||||
# TODO: Check if muted
|
||||
# TODO: Check blacklist
|
||||
|
||||
# Validate message
|
||||
if String.length(message) == 0 do
|
||||
{:ok, client_state}
|
||||
else
|
||||
# TODO: Find recipient across channels and send whisper
|
||||
Logger.info("Whisper [#{character.name} -> #{recipient}]: #{message}")
|
||||
|
||||
# For now, just log
|
||||
# TODO: Send whisper packet to recipient
|
||||
# TODO: Send whisper reply packet to sender
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
221
lib/odinsea/channel/handler/inter_server.ex
Normal file
221
lib/odinsea/channel/handler/inter_server.ex
Normal file
@@ -0,0 +1,221 @@
|
||||
defmodule Odinsea.Channel.Handler.InterServer do
|
||||
@moduledoc """
|
||||
Inter-server migration handler.
|
||||
Handles players migrating into the channel server from login or other channels.
|
||||
|
||||
Ported from Java handling.channel.handler.InterServerHandler.MigrateIn
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Database.Context
|
||||
alias Odinsea.World.Migration
|
||||
alias Odinsea.Channel.Packets
|
||||
|
||||
@doc """
|
||||
Handles character migration into the channel server.
|
||||
|
||||
## Parameters
|
||||
- character_id: The character ID migrating in
|
||||
- client_state: The client connection state
|
||||
|
||||
## Returns
|
||||
- {:ok, new_state} on success
|
||||
- {:error, reason, state} on failure
|
||||
- {:disconnect, reason} on critical failure
|
||||
"""
|
||||
def migrate_in(character_id, %{socket: socket, ip: ip} = state) do
|
||||
Logger.info("Migrate in: character_id=#{character_id} from #{ip}")
|
||||
|
||||
# Check if character is already online in this channel
|
||||
if Odinsea.Channel.Players.is_online?(character_id) do
|
||||
Logger.error("Character #{character_id} already online, disconnecting")
|
||||
{:disconnect, :already_online}
|
||||
else
|
||||
# Check for pending migration token
|
||||
token = Migration.get_pending_character(character_id)
|
||||
|
||||
if token do
|
||||
# Validate the token
|
||||
case Migration.validate_migration_token(token.id, character_id, :channel) do
|
||||
{:ok, valid_token} ->
|
||||
# Use transfer data if available
|
||||
do_migrate_in(character_id, valid_token.account_id, state, valid_token.character_data)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Migration token validation failed: #{inspect(reason)}")
|
||||
# Fall back to database load
|
||||
do_migrate_in(character_id, nil, state, %{})
|
||||
end
|
||||
else
|
||||
# No token, load directly from database (direct login)
|
||||
do_migrate_in(character_id, nil, state, %{})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles channel change request from client.
|
||||
|
||||
## Parameters
|
||||
- target_channel: The target channel (1-20)
|
||||
- state: Client state
|
||||
|
||||
## Returns
|
||||
- {:ok, new_state} - Will disconnect client for migration
|
||||
"""
|
||||
def change_channel(target_channel, %{character_id: char_id, account_id: acc_id} = state) do
|
||||
Logger.info("Change channel: character=#{char_id} to channel #{target_channel}")
|
||||
|
||||
# Check server capacity
|
||||
if Migration.pending_count() >= 10 do
|
||||
Logger.warning("Server busy, rejecting channel change")
|
||||
response = Packets.server_blocked(2)
|
||||
send_packet(state, response)
|
||||
send_packet(state, Packets.enable_actions())
|
||||
{:ok, state}
|
||||
else
|
||||
# TODO: Check if player has blocked inventory, is in event, etc.
|
||||
|
||||
# Save character to database
|
||||
Context.update_character_position(char_id, state.map_id, state.spawn_point)
|
||||
|
||||
# Create migration token
|
||||
character_data = %{
|
||||
map_id: state.map_id,
|
||||
hp: state.hp,
|
||||
mp: state.mp,
|
||||
buffs: state.buffs || []
|
||||
}
|
||||
|
||||
case Migration.create_migration_token(char_id, acc_id, :channel, target_channel, character_data) do
|
||||
{:ok, token_id} ->
|
||||
# Get channel IP and port
|
||||
channel_ip = get_channel_ip(target_channel)
|
||||
channel_port = get_channel_port(target_channel)
|
||||
|
||||
# Send migration command
|
||||
response = Packets.get_channel_change(channel_ip, channel_port, char_id)
|
||||
send_packet(state, response)
|
||||
|
||||
# Update login state
|
||||
Context.update_login_state(acc_id, 3, state.ip) # CHANGE_CHANNEL
|
||||
|
||||
# Remove player from current channel storage
|
||||
Odinsea.Channel.Players.remove_player(char_id)
|
||||
|
||||
# Disconnect will happen after packet is sent
|
||||
{:disconnect, :changing_channel}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to create migration token: #{inspect(reason)}")
|
||||
send_packet(state, Packets.server_blocked(2))
|
||||
send_packet(state, Packets.enable_actions())
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Private Functions
|
||||
# ==================================================================================================
|
||||
|
||||
defp do_migrate_in(character_id, account_id, state, transfer_data) do
|
||||
# Load character from database
|
||||
character = Context.load_character(character_id)
|
||||
|
||||
if is_nil(character) do
|
||||
Logger.error("Character #{character_id} not found in database")
|
||||
{:disconnect, :character_not_found}
|
||||
else
|
||||
# Verify account ownership if account_id provided
|
||||
if account_id && character.accountid != account_id do
|
||||
Logger.error("Character account mismatch: expected #{account_id}, got #{character.accountid}")
|
||||
{:disconnect, :account_mismatch}
|
||||
else
|
||||
# Check login state
|
||||
login_state = Context.get_login_state(character.accountid)
|
||||
|
||||
allow_login =
|
||||
login_state in [0, 1, 3] # NOTLOGGEDIN, SERVER_TRANSITION, or CHANGE_CHANNEL
|
||||
# TODO: Check if character is already connected on another account's session
|
||||
|
||||
if allow_login do
|
||||
complete_migration(character, state, transfer_data)
|
||||
else
|
||||
Logger.warning("Character #{character_id} already logged in elsewhere")
|
||||
{:disconnect, :already_logged_in}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp complete_migration(character, state, transfer_data) do
|
||||
# Update login state to logged in
|
||||
Context.update_login_state(character.accountid, 2, state.ip)
|
||||
|
||||
# Add to channel player storage
|
||||
:ok = Odinsea.Channel.Players.add_player(character.id, %{
|
||||
character_id: character.id,
|
||||
account_id: character.accountid,
|
||||
name: character.name,
|
||||
map_id: character.map,
|
||||
level: character.level,
|
||||
job: character.job,
|
||||
socket: state.socket
|
||||
})
|
||||
|
||||
# Restore buffs/cooldowns from transfer data or storage
|
||||
restored_buffs = transfer_data[:buffs] || []
|
||||
|
||||
# Send character info packet
|
||||
char_info = Packets.get_char_info(character, restored_buffs)
|
||||
send_packet(state, char_info)
|
||||
|
||||
# Send cash shop enable packet
|
||||
send_packet(state, Packets.enable_cash_shop())
|
||||
|
||||
# TODO: Send buddy list, guild info, etc.
|
||||
|
||||
new_state =
|
||||
state
|
||||
|> Map.put(:character_id, character.id)
|
||||
|> Map.put(:account_id, character.accountid)
|
||||
|> Map.put(:character_name, character.name)
|
||||
|> Map.put(:map_id, character.map)
|
||||
|> Map.put(:hp, character.hp)
|
||||
|> Map.put(:mp, character.mp)
|
||||
|> Map.put(:level, character.level)
|
||||
|> Map.put(:job, character.job)
|
||||
|> Map.put(:logged_in, true)
|
||||
|
||||
Logger.info("Character #{character.name} (#{character.id}) successfully migrated in")
|
||||
|
||||
{:ok, new_state}
|
||||
end
|
||||
|
||||
defp send_packet(%{socket: socket}, packet_data) do
|
||||
packet_length = byte_size(packet_data)
|
||||
header = <<packet_length::little-size(16)>>
|
||||
|
||||
case :gen_tcp.send(socket, header <> packet_data) do
|
||||
:ok -> :ok
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to send packet: #{inspect(reason)}")
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp send_packet(_, _), do: :error
|
||||
|
||||
defp get_channel_ip(channel_id) do
|
||||
# TODO: Get from configuration
|
||||
Application.get_env(:odinsea, :channel_ip, "127.0.0.1")
|
||||
end
|
||||
|
||||
defp get_channel_port(channel_id) do
|
||||
# TODO: Get from configuration
|
||||
base_port = Application.get_env(:odinsea, :channel_base_port, 8585)
|
||||
base_port + channel_id - 1
|
||||
end
|
||||
end
|
||||
447
lib/odinsea/channel/handler/npc.ex
Normal file
447
lib/odinsea/channel/handler/npc.ex
Normal file
@@ -0,0 +1,447 @@
|
||||
defmodule Odinsea.Channel.Handler.NPC do
|
||||
@moduledoc """
|
||||
Handles NPC interaction packets: talk, shop, storage, quests.
|
||||
Ported from src/handling/channel/handler/NPCHandler.java
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias Odinsea.Net.Packet.{In, Out}
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Constants.Game
|
||||
|
||||
@doc """
|
||||
Handles NPC movement/talk animations.
|
||||
Forwards NPC movement/animation packets to other players on the map.
|
||||
"""
|
||||
def handle_npc_move(%In{} = packet, client_pid) do
|
||||
with {:ok, chr_pid} <- get_character(client_pid),
|
||||
{:ok, map_pid} <- get_map(chr_pid),
|
||||
{:ok, change_time} <- get_change_time(chr_pid) do
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Anti-spam: prevent rapid NPC interactions
|
||||
if change_time > 0 and now - change_time < 7000 do
|
||||
:ok
|
||||
else
|
||||
handle_npc_move_packet(packet, client_pid, map_pid)
|
||||
end
|
||||
else
|
||||
_error -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_npc_move_packet(packet, _client_pid, _map_pid) do
|
||||
packet_length = In.remaining(packet)
|
||||
|
||||
cond do
|
||||
# NPC Talk (10 bytes for GMS, 6 for KMS)
|
||||
packet_length == 10 ->
|
||||
oid = In.decode_int(packet)
|
||||
byte1 = In.decode_byte(packet)
|
||||
unk = In.decode_byte(packet)
|
||||
|
||||
if unk == -1 do
|
||||
:ok
|
||||
else
|
||||
unk2 = In.decode_int(packet)
|
||||
# TODO: Validate NPC exists on map
|
||||
# TODO: Broadcast NPC action to other players
|
||||
Logger.debug("NPC talk: oid=#{oid}, byte1=#{byte1}, unk=#{unk}, unk2=#{unk2}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# NPC Move (more than 10 bytes)
|
||||
packet_length > 10 ->
|
||||
movement_data = In.decode_buffer(packet, packet_length - 9)
|
||||
# TODO: Broadcast NPC movement to other players
|
||||
Logger.debug("NPC move: #{byte_size(movement_data)} bytes of movement data")
|
||||
:ok
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles NPC shop actions: buy, sell, recharge.
|
||||
"""
|
||||
def handle_npc_shop(%In{} = packet, client_pid) do
|
||||
mode = In.decode_byte(packet)
|
||||
|
||||
case mode do
|
||||
# Buy item from shop
|
||||
0 ->
|
||||
In.skip(packet, 2)
|
||||
item_id = In.decode_int(packet)
|
||||
quantity = In.decode_short(packet)
|
||||
handle_shop_buy(client_pid, item_id, quantity)
|
||||
|
||||
# Sell item to shop
|
||||
1 ->
|
||||
slot = In.decode_short(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
quantity = In.decode_short(packet)
|
||||
handle_shop_sell(client_pid, slot, item_id, quantity)
|
||||
|
||||
# Recharge item (stars/bullets)
|
||||
2 ->
|
||||
slot = In.decode_short(packet)
|
||||
handle_shop_recharge(client_pid, slot)
|
||||
|
||||
# Close shop
|
||||
_ ->
|
||||
handle_shop_close(client_pid)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_shop_buy(_client_pid, item_id, quantity) do
|
||||
# TODO: Implement shop buy
|
||||
# 1. Get character's current shop
|
||||
# 2. Validate item exists in shop
|
||||
# 3. Check mesos
|
||||
# 4. Check inventory space
|
||||
# 5. Deduct mesos and add item
|
||||
Logger.debug("Shop buy: item=#{item_id}, qty=#{quantity} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_shop_sell(_client_pid, slot, item_id, quantity) do
|
||||
# TODO: Implement shop sell
|
||||
# 1. Get character's current shop
|
||||
# 2. Validate item in inventory
|
||||
# 3. Calculate sell price
|
||||
# 4. Remove item and add mesos
|
||||
Logger.debug("Shop sell: slot=#{slot}, item=#{item_id}, qty=#{quantity} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_shop_recharge(_client_pid, slot) do
|
||||
# TODO: Implement recharge
|
||||
# 1. Get character's current shop
|
||||
# 2. Validate item is rechargeable (stars/bullets)
|
||||
# 3. Calculate recharge cost
|
||||
# 4. Recharge to full quantity
|
||||
Logger.debug("Shop recharge: slot=#{slot} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_shop_close(client_pid) do
|
||||
# TODO: Clear character's shop reference
|
||||
Logger.debug("Shop close for client #{inspect(client_pid)} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles NPC talk initiation.
|
||||
Opens NPC shop or starts NPC script dialog.
|
||||
"""
|
||||
def handle_npc_talk(%In{} = packet, client_pid) do
|
||||
with {:ok, chr_pid} <- get_character(client_pid),
|
||||
{:ok, map_pid} <- get_map(chr_pid),
|
||||
{:ok, last_select_time} <- get_last_select_npc_time(chr_pid) do
|
||||
now = System.system_time(:millisecond)
|
||||
|
||||
# Anti-spam: minimum 500ms between NPC interactions
|
||||
if last_select_time == 0 or now - last_select_time >= 500 do
|
||||
oid = In.decode_int(packet)
|
||||
_tick = In.decode_int(packet)
|
||||
|
||||
# TODO: Update last select NPC time
|
||||
# TODO: Get NPC from map by OID
|
||||
# TODO: Check if NPC has shop
|
||||
# TODO: If shop, open shop; else start script
|
||||
Logger.debug("NPC talk: oid=#{oid} (STUB - needs script/shop system)")
|
||||
:ok
|
||||
else
|
||||
:ok
|
||||
end
|
||||
else
|
||||
_error -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles quest actions: start, complete, forfeit, restore item.
|
||||
"""
|
||||
def handle_quest_action(%In{} = packet, client_pid) do
|
||||
action = In.decode_byte(packet)
|
||||
quest_id = In.decode_ushort(packet)
|
||||
|
||||
case action do
|
||||
# Restore lost item
|
||||
0 ->
|
||||
_tick = In.decode_int(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
handle_quest_restore_item(client_pid, quest_id, item_id)
|
||||
|
||||
# Start quest
|
||||
1 ->
|
||||
npc_id = In.decode_int(packet)
|
||||
handle_quest_start(client_pid, quest_id, npc_id)
|
||||
|
||||
# Complete quest
|
||||
2 ->
|
||||
npc_id = In.decode_int(packet)
|
||||
_tick = In.decode_int(packet)
|
||||
|
||||
selection =
|
||||
if In.remaining(packet) >= 4 do
|
||||
In.decode_int(packet)
|
||||
else
|
||||
nil
|
||||
end
|
||||
|
||||
handle_quest_complete(client_pid, quest_id, npc_id, selection)
|
||||
|
||||
# Forfeit quest
|
||||
3 ->
|
||||
handle_quest_forfeit(client_pid, quest_id)
|
||||
|
||||
# Scripted start quest
|
||||
4 ->
|
||||
npc_id = In.decode_int(packet)
|
||||
handle_quest_start_scripted(client_pid, quest_id, npc_id)
|
||||
|
||||
# Scripted end quest
|
||||
5 ->
|
||||
npc_id = In.decode_int(packet)
|
||||
handle_quest_end_scripted(client_pid, quest_id, npc_id)
|
||||
|
||||
_ ->
|
||||
Logger.warn("Unknown quest action: #{action}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_quest_restore_item(_client_pid, quest_id, item_id) do
|
||||
Logger.debug("Quest restore item: quest=#{quest_id}, item=#{item_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_quest_start(_client_pid, quest_id, npc_id) do
|
||||
# TODO: Load quest, check requirements, start quest
|
||||
Logger.debug("Quest start: quest=#{quest_id}, npc=#{npc_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_quest_complete(_client_pid, quest_id, npc_id, selection) do
|
||||
# TODO: Load quest, check completion, give rewards
|
||||
Logger.debug(
|
||||
"Quest complete: quest=#{quest_id}, npc=#{npc_id}, selection=#{inspect(selection)} (STUB)"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_quest_forfeit(_client_pid, quest_id) do
|
||||
# TODO: Check if quest can be forfeited, remove from character
|
||||
Logger.debug("Quest forfeit: quest=#{quest_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_quest_start_scripted(_client_pid, quest_id, npc_id) do
|
||||
# TODO: Start quest script via script manager
|
||||
Logger.debug("Quest start scripted: quest=#{quest_id}, npc=#{npc_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_quest_end_scripted(_client_pid, quest_id, npc_id) do
|
||||
# TODO: End quest script via script manager
|
||||
# TODO: Broadcast quest completion effect
|
||||
Logger.debug("Quest end scripted: quest=#{quest_id}, npc=#{npc_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles storage actions: take out, store, arrange, mesos.
|
||||
"""
|
||||
def handle_storage(%In{} = packet, client_pid) do
|
||||
mode = In.decode_byte(packet)
|
||||
|
||||
case mode do
|
||||
# Take out item
|
||||
4 ->
|
||||
type = In.decode_byte(packet)
|
||||
slot = In.decode_byte(packet)
|
||||
handle_storage_take_out(client_pid, type, slot)
|
||||
|
||||
# Store item
|
||||
5 ->
|
||||
slot = In.decode_short(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
quantity = In.decode_short(packet)
|
||||
handle_storage_store(client_pid, slot, item_id, quantity)
|
||||
|
||||
# Arrange storage
|
||||
6 ->
|
||||
handle_storage_arrange(client_pid)
|
||||
|
||||
# Meso deposit/withdraw
|
||||
7 ->
|
||||
meso = In.decode_int(packet)
|
||||
handle_storage_meso(client_pid, meso)
|
||||
|
||||
# Close storage
|
||||
8 ->
|
||||
handle_storage_close(client_pid)
|
||||
|
||||
_ ->
|
||||
Logger.warn("Unknown storage mode: #{mode}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_storage_take_out(_client_pid, type, slot) do
|
||||
# TODO: Get storage, validate slot, check inventory space, move item
|
||||
Logger.debug("Storage take out: type=#{type}, slot=#{slot} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_storage_store(_client_pid, slot, item_id, quantity) do
|
||||
# TODO: Validate item, check storage space, charge fee, move item
|
||||
Logger.debug("Storage store: slot=#{slot}, item=#{item_id}, qty=#{quantity} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_storage_arrange(_client_pid) do
|
||||
# TODO: Sort storage items
|
||||
Logger.debug("Storage arrange (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_storage_meso(_client_pid, meso) do
|
||||
# TODO: Transfer mesos between character and storage
|
||||
Logger.debug("Storage meso: #{meso} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_storage_close(_client_pid) do
|
||||
# TODO: Close storage, clear reference
|
||||
Logger.debug("Storage close (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles NPC dialog continuation (script responses).
|
||||
"""
|
||||
def handle_npc_more_talk(%In{} = packet, client_pid) do
|
||||
last_msg = In.decode_byte(packet)
|
||||
action = In.decode_byte(packet)
|
||||
|
||||
cond do
|
||||
# Text input response
|
||||
last_msg == 3 ->
|
||||
if action != 0 do
|
||||
text = In.decode_string(packet)
|
||||
# TODO: Pass text to script manager
|
||||
Logger.debug("NPC more talk (text): #{text} (STUB)")
|
||||
end
|
||||
|
||||
# Selection response
|
||||
true ->
|
||||
selection =
|
||||
cond do
|
||||
In.remaining(packet) >= 4 -> In.decode_int(packet)
|
||||
In.remaining(packet) > 0 -> In.decode_byte(packet)
|
||||
true -> -1
|
||||
end
|
||||
|
||||
# TODO: Pass selection to script manager
|
||||
Logger.debug("NPC more talk (selection): #{selection}, action=#{action} (STUB)")
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles equipment repair (single item).
|
||||
"""
|
||||
def handle_repair(%In{} = packet, client_pid) do
|
||||
if In.remaining(packet) < 4 do
|
||||
:ok
|
||||
else
|
||||
position = In.decode_int(packet)
|
||||
# TODO: Validate map, check durability, calculate cost, repair item
|
||||
Logger.debug("Repair: position=#{position} (STUB)")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles equipment repair (all items).
|
||||
"""
|
||||
def handle_repair_all(client_pid) do
|
||||
# TODO: Find all damaged items, calculate total cost, repair all
|
||||
Logger.debug("Repair all (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles quest info update.
|
||||
"""
|
||||
def handle_update_quest(%In{} = packet, client_pid) do
|
||||
quest_id = In.decode_short(packet)
|
||||
# TODO: Update quest progress/info
|
||||
Logger.debug("Update quest: #{quest_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles using quest items.
|
||||
"""
|
||||
def handle_use_item_quest(%In{} = packet, client_pid) do
|
||||
slot = In.decode_short(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
quest_id = In.decode_int(packet)
|
||||
new_data = In.decode_int(packet)
|
||||
|
||||
# TODO: Validate quest item, update quest data, consume item
|
||||
Logger.debug(
|
||||
"Use item quest: slot=#{slot}, item=#{item_id}, quest=#{quest_id}, data=#{new_data} (STUB)"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles opening public NPCs (from UI, not on map).
|
||||
"""
|
||||
def handle_public_npc(%In{} = packet, client_pid) do
|
||||
npc_id = In.decode_int(packet)
|
||||
# TODO: Validate NPC in public NPC list, start script
|
||||
Logger.debug("Public NPC: #{npc_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles using scripted NPC items.
|
||||
"""
|
||||
def handle_use_scripted_npc_item(%In{} = packet, client_pid) do
|
||||
slot = In.decode_short(packet)
|
||||
item_id = In.decode_int(packet)
|
||||
# TODO: Validate item, run NPC script for item
|
||||
Logger.debug("Use scripted NPC item: slot=#{slot}, item=#{item_id} (STUB)")
|
||||
:ok
|
||||
end
|
||||
|
||||
# Helper functions to get character/map info
|
||||
defp get_character(client_pid) do
|
||||
# TODO: Get character PID from client state
|
||||
{:ok, nil}
|
||||
end
|
||||
|
||||
defp get_map(_chr_pid) do
|
||||
# TODO: Get map PID from character state
|
||||
{:ok, nil}
|
||||
end
|
||||
|
||||
defp get_change_time(_chr_pid) do
|
||||
# TODO: Get last map change time from character
|
||||
{:ok, 0}
|
||||
end
|
||||
|
||||
defp get_last_select_npc_time(_chr_pid) do
|
||||
# TODO: Get last NPC select time from character
|
||||
{:ok, 0}
|
||||
end
|
||||
end
|
||||
322
lib/odinsea/channel/handler/player.ex
Normal file
322
lib/odinsea/channel/handler/player.ex
Normal file
@@ -0,0 +1,322 @@
|
||||
defmodule Odinsea.Channel.Handler.Player do
|
||||
@moduledoc """
|
||||
Handles player action packets (movement, attacks, map changes).
|
||||
Ported from src/handling/channel/handler/PlayerHandler.java
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.{In, Out}
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Channel.Packets
|
||||
alias Odinsea.Game.{Character, Movement, Map}
|
||||
|
||||
@doc """
|
||||
Handles player movement (CP_MOVE_PLAYER).
|
||||
Ported from PlayerHandler.MovePlayer()
|
||||
"""
|
||||
def handle_move_player(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid),
|
||||
{:ok, map_pid} <- get_map_pid(character.map_id, client_state.channel_id) do
|
||||
# Decode movement header
|
||||
{_dr0, packet} = In.decode_int(packet)
|
||||
{_dr1, packet} = In.decode_int(packet)
|
||||
# TODO: Check field key
|
||||
{_dr2, packet} = In.decode_int(packet)
|
||||
{_dr3, packet} = In.decode_int(packet)
|
||||
# Skip 20 bytes
|
||||
{_, packet} = In.skip(packet, 20)
|
||||
|
||||
# Store original position
|
||||
original_pos = character.position
|
||||
|
||||
# Parse movement
|
||||
case Movement.parse_movement(packet) do
|
||||
{:ok, movement_data, final_pos} ->
|
||||
# Update character position
|
||||
Character.update_position(character_pid, final_pos)
|
||||
|
||||
# Broadcast movement to other players
|
||||
move_packet =
|
||||
Out.new(Opcodes.lp_move_player())
|
||||
|> Out.encode_int(character.id)
|
||||
|> Out.encode_bytes(movement_data)
|
||||
|> Out.to_data()
|
||||
|
||||
Map.broadcast_except(
|
||||
character.map_id,
|
||||
client_state.channel_id,
|
||||
character.id,
|
||||
move_packet
|
||||
)
|
||||
|
||||
Logger.debug(
|
||||
"Player #{character.name} moved to (#{final_pos.x}, #{final_pos.y})"
|
||||
)
|
||||
|
||||
{:ok, client_state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Movement parsing failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Move player failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles map change via portal (CP_CHANGE_MAP).
|
||||
Ported from PlayerHandler.ChangeMap()
|
||||
"""
|
||||
def handle_change_map(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# TODO: Check field key
|
||||
|
||||
{target_id, packet} = In.decode_int(packet)
|
||||
# Skip GMS-specific field
|
||||
{_, packet} = In.decode_int(packet)
|
||||
{portal_name, packet} = In.decode_string(packet)
|
||||
|
||||
Logger.info(
|
||||
"Character #{character.name} changing map: target=#{target_id}, portal=#{portal_name}"
|
||||
)
|
||||
|
||||
# Handle different map change scenarios
|
||||
cond do
|
||||
# Death respawn
|
||||
target_id == -1 and not character.alive? ->
|
||||
# Respawn at return map
|
||||
# TODO: Implement death respawn logic
|
||||
Logger.info("Player #{character.name} respawning")
|
||||
{:ok, client_state}
|
||||
|
||||
# GM warp to specific map
|
||||
target_id != -1 and character.gm? ->
|
||||
# TODO: Implement GM warp
|
||||
Logger.info("GM #{character.name} warping to map #{target_id}")
|
||||
{:ok, client_state}
|
||||
|
||||
# Portal-based map change
|
||||
true ->
|
||||
# TODO: Load portal data and handle map transition
|
||||
# For now, just log the request
|
||||
Logger.info(
|
||||
"Portal map change: #{character.name} using portal '#{portal_name}'"
|
||||
)
|
||||
|
||||
{:ok, client_state}
|
||||
end
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Change map failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles keymap changes (CP_CHANGE_KEYMAP).
|
||||
Ported from PlayerHandler.ChangeKeymap()
|
||||
"""
|
||||
def handle_change_keymap(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, _character} <- Character.get_state(character_pid) do
|
||||
# Skip mode
|
||||
{_, packet} = In.skip(packet, 4)
|
||||
{num_changes, packet} = In.decode_int(packet)
|
||||
|
||||
# Parse keybinding changes
|
||||
keybindings = parse_keybindings(packet, num_changes, [])
|
||||
|
||||
# TODO: Store keybindings in character state / database
|
||||
Logger.debug("Keybindings updated: #{num_changes} changes")
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Change keymap failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles skill macro changes (CP_CHANGE_SKILL_MACRO).
|
||||
Ported from PlayerHandler.ChangeSkillMacro()
|
||||
"""
|
||||
def handle_change_skill_macro(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, _character} <- Character.get_state(character_pid) do
|
||||
{num_macros, packet} = In.decode_byte(packet)
|
||||
|
||||
# Parse macros
|
||||
macros = parse_macros(packet, num_macros, [])
|
||||
|
||||
# TODO: Store macros in character state / database
|
||||
Logger.debug("Skill macros updated: #{num_macros} macros")
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Change skill macro failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles close-range attack (CP_CLOSE_RANGE_ATTACK).
|
||||
Ported from PlayerHandler.closeRangeAttack() - STUB for now
|
||||
"""
|
||||
def handle_close_range_attack(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
Logger.debug("Close range attack from #{character.name} (stub)")
|
||||
# TODO: Implement attack logic
|
||||
# - Parse attack info
|
||||
# - Validate attack
|
||||
# - Calculate damage
|
||||
# - Apply damage to mobs
|
||||
# - Broadcast attack packet
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Close range attack failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles ranged attack (CP_RANGED_ATTACK).
|
||||
Ported from PlayerHandler.rangedAttack() - STUB for now
|
||||
"""
|
||||
def handle_ranged_attack(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
Logger.debug("Ranged attack from #{character.name} (stub)")
|
||||
# TODO: Implement ranged attack logic
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Ranged attack failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles magic attack (CP_MAGIC_ATTACK).
|
||||
Ported from PlayerHandler.MagicDamage() - STUB for now
|
||||
"""
|
||||
def handle_magic_attack(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
Logger.debug("Magic attack from #{character.name} (stub)")
|
||||
# TODO: Implement magic attack logic
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Magic attack failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Handles taking damage (CP_TAKE_DAMAGE).
|
||||
Ported from PlayerHandler.TakeDamage() - STUB for now
|
||||
"""
|
||||
def handle_take_damage(packet, client_state) do
|
||||
with {:ok, character_pid} <- get_character(client_state),
|
||||
{:ok, character} <- Character.get_state(character_pid) do
|
||||
# Decode damage packet
|
||||
{_tick, packet} = In.decode_int(packet)
|
||||
{damage_type, packet} = In.decode_byte(packet)
|
||||
{element, packet} = In.decode_byte(packet)
|
||||
{damage, packet} = In.decode_int(packet)
|
||||
|
||||
Logger.debug(
|
||||
"Character #{character.name} took #{damage} damage (type=#{damage_type}, element=#{element})"
|
||||
)
|
||||
|
||||
# TODO: Apply damage to character
|
||||
# TODO: Check for death
|
||||
# TODO: Broadcast damage packet
|
||||
|
||||
{:ok, client_state}
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Take damage failed: #{inspect(reason)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp get_character(client_state) do
|
||||
case client_state.character_id do
|
||||
nil ->
|
||||
{:error, :no_character}
|
||||
|
||||
character_id ->
|
||||
case Registry.lookup(Odinsea.CharacterRegistry, character_id) do
|
||||
[{pid, _}] -> {:ok, pid}
|
||||
[] -> {:error, :character_not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp get_map_pid(map_id, channel_id) do
|
||||
case Registry.lookup(Odinsea.MapRegistry, {map_id, channel_id}) do
|
||||
[{pid, _}] ->
|
||||
{:ok, pid}
|
||||
|
||||
[] ->
|
||||
# Map not loaded yet - load it
|
||||
case DynamicSupervisor.start_child(
|
||||
Odinsea.MapSupervisor,
|
||||
{Map, {map_id, channel_id}}
|
||||
) do
|
||||
{:ok, pid} -> {:ok, pid}
|
||||
{:error, {:already_started, pid}} -> {:ok, pid}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_keybindings(packet, 0, acc), do: Enum.reverse(acc)
|
||||
|
||||
defp parse_keybindings(packet, count, acc) do
|
||||
{key, packet} = In.decode_int(packet)
|
||||
{key_type, packet} = In.decode_byte(packet)
|
||||
{action, packet} = In.decode_int(packet)
|
||||
|
||||
binding = %{key: key, type: key_type, action: action}
|
||||
parse_keybindings(packet, count - 1, [binding | acc])
|
||||
end
|
||||
|
||||
defp parse_macros(packet, 0, acc), do: Enum.reverse(acc)
|
||||
|
||||
defp parse_macros(packet, count, acc) do
|
||||
{name, packet} = In.decode_string(packet)
|
||||
{shout, packet} = In.decode_byte(packet)
|
||||
{skill1, packet} = In.decode_int(packet)
|
||||
{skill2, packet} = In.decode_int(packet)
|
||||
{skill3, packet} = In.decode_int(packet)
|
||||
|
||||
macro = %{
|
||||
name: name,
|
||||
shout: shout,
|
||||
skill1: skill1,
|
||||
skill2: skill2,
|
||||
skill3: skill3
|
||||
}
|
||||
|
||||
parse_macros(packet, count - 1, [macro | acc])
|
||||
end
|
||||
end
|
||||
301
lib/odinsea/channel/packets.ex
Normal file
301
lib/odinsea/channel/packets.ex
Normal file
@@ -0,0 +1,301 @@
|
||||
defmodule Odinsea.Channel.Packets do
|
||||
@moduledoc """
|
||||
Channel server packet builders.
|
||||
Ported from Java tools.packet.MaplePacketCreator (relevant parts)
|
||||
"""
|
||||
|
||||
alias Odinsea.Net.Packet.Out
|
||||
alias Odinsea.Net.Opcodes
|
||||
|
||||
@doc """
|
||||
Sends character information on login.
|
||||
"""
|
||||
def get_char_info(character, restored_buffs \\ []) do
|
||||
# TODO: Full character encoding
|
||||
# For now, send minimal info
|
||||
Out.new(Opcodes.lp_set_field())
|
||||
|> Out.encode_int(character.id)
|
||||
|> Out.encode_byte(0) # Channel
|
||||
|> Out.encode_byte(1) # Admin byte
|
||||
|> Out.encode_byte(1) # Enabled
|
||||
|> Out.encode_int(character.map)
|
||||
|> Out.encode_byte(character.spawnpoint)
|
||||
|> Out.encode_int(character.hp)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Enables cash shop access.
|
||||
"""
|
||||
def enable_cash_shop do
|
||||
Out.new(Opcodes.lp_set_cash_shop_opened())
|
||||
|> Out.encode_byte(1)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Server blocked message.
|
||||
"""
|
||||
def server_blocked(reason) do
|
||||
Out.new(Opcodes.lp_server_blocked())
|
||||
|> Out.encode_byte(reason)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Enable client actions.
|
||||
"""
|
||||
def enable_actions do
|
||||
Out.new(Opcodes.lp_enable_action())
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Channel change command.
|
||||
"""
|
||||
def get_channel_change(ip, port, character_id) do
|
||||
ip_parts = parse_ip(ip)
|
||||
|
||||
Out.new(Opcodes.lp_migrate_command())
|
||||
|> Out.encode_short(0) # Not cash shop
|
||||
|> encode_ip(ip_parts)
|
||||
|> Out.encode_short(port)
|
||||
|> Out.encode_int(character_id)
|
||||
|> Out.encode_bytes(<<0, 0>>)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
defp parse_ip(host) when is_binary(host) do
|
||||
case String.split(host, ".") do
|
||||
[a, b, c, d] ->
|
||||
{
|
||||
String.to_integer(a),
|
||||
String.to_integer(b),
|
||||
String.to_integer(c),
|
||||
String.to_integer(d)
|
||||
}
|
||||
|
||||
_ ->
|
||||
{127, 0, 0, 1}
|
||||
end
|
||||
end
|
||||
|
||||
defp encode_ip(packet, {a, b, c, d}) do
|
||||
packet
|
||||
|> Out.encode_byte(a)
|
||||
|> Out.encode_byte(b)
|
||||
|> Out.encode_byte(c)
|
||||
|> Out.encode_byte(d)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Spawns a player on the map.
|
||||
Minimal implementation - will expand with equipment, buffs, etc.
|
||||
"""
|
||||
def spawn_player(oid, character_state) do
|
||||
# Reference: MaplePacketCreator.spawnPlayerMapobject()
|
||||
# This is a minimal implementation - full version needs equipment, buffs, etc.
|
||||
|
||||
Out.new(Opcodes.lp_spawn_player())
|
||||
|> Out.encode_int(oid)
|
||||
# Damage skin (custom client feature)
|
||||
# |> Out.encode_int(0)
|
||||
|> Out.encode_byte(character_state.level)
|
||||
|> Out.encode_string(character_state.name)
|
||||
# Ultimate Explorer name (empty for now)
|
||||
|> Out.encode_string("")
|
||||
# Guild info (no guild for now)
|
||||
|> Out.encode_int(0)
|
||||
|> Out.encode_int(0)
|
||||
# Buff mask (no buffs for now - TODO: implement buffs)
|
||||
# For now, send minimal buff data
|
||||
|> encode_buff_mask()
|
||||
# Foreign buff end
|
||||
|> Out.encode_short(0)
|
||||
# ITEM_EFFECT
|
||||
|> Out.encode_int(0)
|
||||
# CHAIR
|
||||
|> Out.encode_int(0)
|
||||
# Position
|
||||
|> Out.encode_short(character_state.position.x)
|
||||
|> Out.encode_short(character_state.position.y)
|
||||
|> Out.encode_byte(character_state.position.stance)
|
||||
# Foothold
|
||||
|> Out.encode_short(character_state.position.foothold)
|
||||
# Appearance (gender, skin, face, hair)
|
||||
|> Out.encode_byte(character_state.gender)
|
||||
|> Out.encode_byte(character_state.skin_color)
|
||||
|> Out.encode_int(character_state.face)
|
||||
# Mega - shown in rankings
|
||||
|> Out.encode_byte(0)
|
||||
# Equipment (TODO: implement proper equipment encoding)
|
||||
|> encode_appearance_minimal(character_state)
|
||||
# Driver ID / passenger ID (for mounts)
|
||||
|> Out.encode_int(0)
|
||||
# Chalkboard text
|
||||
|> Out.encode_string("")
|
||||
# Ring info (3 ring slots)
|
||||
|> Out.encode_int(0)
|
||||
|> Out.encode_int(0)
|
||||
|> Out.encode_int(0)
|
||||
# Marriage ring
|
||||
|> Out.encode_int(0)
|
||||
# Mount info (no mount for now)
|
||||
|> encode_mount_minimal()
|
||||
# Player shop (none for now)
|
||||
|> Out.encode_byte(0)
|
||||
# Admin byte
|
||||
|> Out.encode_byte(0)
|
||||
# Pet info (no pets for now)
|
||||
|> encode_pets_minimal()
|
||||
# Taming mob (none)
|
||||
|> Out.encode_int(0)
|
||||
# Mini game info
|
||||
|> Out.encode_byte(0)
|
||||
# Chalkboard
|
||||
|> Out.encode_byte(0)
|
||||
# New year cards
|
||||
|> Out.encode_byte(0)
|
||||
# Berserk
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a player from the map.
|
||||
"""
|
||||
def remove_player(oid) do
|
||||
Out.new(Opcodes.lp_remove_player_from_map())
|
||||
|> Out.encode_int(oid)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions for Spawn Encoding
|
||||
# ============================================================================
|
||||
|
||||
defp encode_buff_mask(packet) do
|
||||
# Buff mask is an array of integers representing active buffs
|
||||
# For GMS v342, this is typically 14-16 integers (56-64 bytes)
|
||||
# For now, send all zeros (no buffs)
|
||||
packet
|
||||
|> Out.encode_bytes(<<0::size(14 * 32)-little>>)
|
||||
end
|
||||
|
||||
defp encode_appearance_minimal(packet, character) do
|
||||
# Equipment encoding:
|
||||
# Map of slot -> item_id
|
||||
# For minimal implementation, just show hair
|
||||
packet
|
||||
# Equipped items map (empty for now)
|
||||
|> Out.encode_byte(0)
|
||||
# Masked items map (empty for now)
|
||||
|> Out.encode_byte(0)
|
||||
# Weapon (cash weapon)
|
||||
|> Out.encode_int(0)
|
||||
# Hair
|
||||
|> Out.encode_int(character.hair)
|
||||
# Ears (12 bit encoding for multiple items)
|
||||
|> Out.encode_int(0)
|
||||
end
|
||||
|
||||
defp encode_mount_minimal(packet) do
|
||||
packet
|
||||
|> Out.encode_byte(0)
|
||||
# Mount level
|
||||
|> Out.encode_byte(1)
|
||||
# Mount exp
|
||||
|> Out.encode_int(0)
|
||||
# Mount fatigue
|
||||
|> Out.encode_int(0)
|
||||
end
|
||||
|
||||
defp encode_pets_minimal(packet) do
|
||||
# 3 pet slots
|
||||
packet
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_byte(0)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Chat Packets
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
User chat packet.
|
||||
Ported from LocalePacket.UserChat()
|
||||
Reference: src/tools/packet/LocalePacket.java
|
||||
"""
|
||||
def user_chat(character_id, message, is_admin \\ false, only_balloon \\ false) do
|
||||
Out.new(Opcodes.lp_chattext())
|
||||
|> Out.encode_int(character_id)
|
||||
|> Out.encode_byte(if is_admin, do: 1, else: 0)
|
||||
|> Out.encode_string(message)
|
||||
|> Out.encode_byte(if only_balloon, do: 1, else: 0)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Whisper chat packet (received whisper).
|
||||
Ported from LocalePacket.WhisperChat()
|
||||
"""
|
||||
def whisper_received(sender_name, channel, message) do
|
||||
Out.new(Opcodes.lp_whisper())
|
||||
|> Out.encode_byte(0x12)
|
||||
|> Out.encode_string(sender_name)
|
||||
|> Out.encode_short(channel - 1)
|
||||
|> Out.encode_string(message)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Whisper reply packet (sent whisper status).
|
||||
Mode: 1 = success, 0 = failure
|
||||
"""
|
||||
def whisper_reply(recipient_name, mode) do
|
||||
Out.new(Opcodes.lp_whisper())
|
||||
|> Out.encode_byte(0x0A)
|
||||
|> Out.encode_string(recipient_name)
|
||||
|> Out.encode_byte(mode)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Find player reply (player found on channel).
|
||||
"""
|
||||
def find_player_reply(target_name, channel, is_buddy \\ false) do
|
||||
Out.new(Opcodes.lp_whisper())
|
||||
|> Out.encode_byte(0x09)
|
||||
|> Out.encode_string(target_name)
|
||||
|> Out.encode_byte(if is_buddy, do: 0x48, else: 0x01)
|
||||
|> Out.encode_byte(channel)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Find player reply with map (player found on same channel).
|
||||
"""
|
||||
def find_player_with_map(target_name, map_id, is_buddy \\ false) do
|
||||
Out.new(Opcodes.lp_whisper())
|
||||
|> Out.encode_byte(0x09)
|
||||
|> Out.encode_string(target_name)
|
||||
|> Out.encode_byte(if is_buddy, do: 0x48, else: 0x01)
|
||||
|> Out.encode_int(map_id)
|
||||
|> Out.encode_bytes(<<0, 0, 0>>)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Party chat packet.
|
||||
Type: 0 = buddy, 1 = party, 2 = guild, 3 = alliance, 4 = expedition
|
||||
"""
|
||||
def multi_chat(sender_name, message, chat_type) do
|
||||
Out.new(Opcodes.lp_multi_chat())
|
||||
|> Out.encode_byte(chat_type)
|
||||
|> Out.encode_string(sender_name)
|
||||
|> Out.encode_string(message)
|
||||
|> Out.to_data()
|
||||
end
|
||||
end
|
||||
112
lib/odinsea/channel/players.ex
Normal file
112
lib/odinsea/channel/players.ex
Normal file
@@ -0,0 +1,112 @@
|
||||
defmodule Odinsea.Channel.Players do
|
||||
@moduledoc """
|
||||
Player storage for channel server.
|
||||
Manages online player state and lookups.
|
||||
|
||||
Ported from Java handling.channel.PlayerStorage
|
||||
|
||||
Uses ETS for fast in-memory lookups.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@table :channel_players
|
||||
|
||||
@doc """
|
||||
Starts the player storage (creates ETS table).
|
||||
"""
|
||||
def start_link do
|
||||
:ets.new(@table, [
|
||||
:set,
|
||||
:public,
|
||||
:named_table,
|
||||
read_concurrency: true,
|
||||
write_concurrency: true
|
||||
])
|
||||
|
||||
{:ok, self()}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a player to the channel storage.
|
||||
"""
|
||||
def add_player(character_id, player_data) do
|
||||
:ets.insert(@table, {character_id, player_data})
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a player from the channel storage.
|
||||
"""
|
||||
def remove_player(character_id) do
|
||||
:ets.delete(@table, character_id)
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets player data by character ID.
|
||||
Returns nil if not found.
|
||||
"""
|
||||
def get_player(character_id) do
|
||||
case :ets.lookup(@table, character_id) do
|
||||
[{^character_id, data}] -> data
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a character is online in this channel.
|
||||
"""
|
||||
def is_online?(character_id) do
|
||||
:ets.member(@table, character_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all online players.
|
||||
"""
|
||||
def get_all_players do
|
||||
:ets.tab2list(@table)
|
||||
|> Enum.map(fn {_id, data} -> data end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets player count.
|
||||
"""
|
||||
def count do
|
||||
:ets.info(@table, :size)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a player by name.
|
||||
"""
|
||||
def get_player_by_name(name) do
|
||||
@table
|
||||
|> :ets.tab2list()
|
||||
|> Enum.find(fn {_id, data} -> data.name == name end)
|
||||
|> case do
|
||||
nil -> nil
|
||||
{_, data} -> data
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates player data.
|
||||
"""
|
||||
def update_player(character_id, updates) do
|
||||
case get_player(character_id) do
|
||||
nil -> :error
|
||||
data ->
|
||||
updated = Map.merge(data, updates)
|
||||
:ets.insert(@table, {character_id, updated})
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Clears all players (e.g., during shutdown).
|
||||
"""
|
||||
def clear do
|
||||
:ets.delete_all_objects(@table)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
73
lib/odinsea/channel/server.ex
Normal file
73
lib/odinsea/channel/server.ex
Normal file
@@ -0,0 +1,73 @@
|
||||
defmodule Odinsea.Channel.Server do
|
||||
@moduledoc """
|
||||
A single game channel server.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
defstruct [:channel_id, :socket, :port, :clients]
|
||||
|
||||
def start_link(channel_id) do
|
||||
GenServer.start_link(__MODULE__, channel_id, name: via_tuple(channel_id))
|
||||
end
|
||||
|
||||
def via_tuple(channel_id) do
|
||||
{:via, Registry, {Odinsea.Channel.Registry, channel_id}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(channel_id) do
|
||||
ports = Application.get_env(:odinsea, :game, [])[:channel_ports] || %{}
|
||||
port = Map.get(ports, channel_id, 8584 + channel_id)
|
||||
|
||||
case :gen_tcp.listen(port, tcp_options()) do
|
||||
{:ok, socket} ->
|
||||
Logger.info("Channel #{channel_id} listening on port #{port}")
|
||||
send(self(), :accept)
|
||||
|
||||
{:ok,
|
||||
%__MODULE__{
|
||||
channel_id: channel_id,
|
||||
socket: socket,
|
||||
port: port,
|
||||
clients: %{}
|
||||
}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to start channel #{channel_id} on port #{port}: #{inspect(reason)}")
|
||||
{:stop, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:accept, %{socket: socket, channel_id: channel_id} = state) do
|
||||
case :gen_tcp.accept(socket) do
|
||||
{:ok, client_socket} ->
|
||||
{:ok, _pid} =
|
||||
DynamicSupervisor.start_child(
|
||||
Odinsea.ClientSupervisor,
|
||||
{Odinsea.Channel.Client, {client_socket, channel_id}}
|
||||
)
|
||||
|
||||
send(self(), :accept)
|
||||
{:noreply, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Channel #{channel_id} accept error: #{inspect(reason)}")
|
||||
send(self(), :accept)
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp tcp_options do
|
||||
[
|
||||
:binary,
|
||||
packet: :raw,
|
||||
active: false,
|
||||
reuseaddr: true,
|
||||
backlog: 100
|
||||
]
|
||||
end
|
||||
end
|
||||
28
lib/odinsea/channel/supervisor.ex
Normal file
28
lib/odinsea/channel/supervisor.ex
Normal file
@@ -0,0 +1,28 @@
|
||||
defmodule Odinsea.Channel.Supervisor do
|
||||
@moduledoc """
|
||||
Supervisor for game channel servers.
|
||||
Each channel is a separate TCP listener.
|
||||
"""
|
||||
|
||||
use Supervisor
|
||||
|
||||
require Logger
|
||||
|
||||
def start_link(channel_count) do
|
||||
Supervisor.start_link(__MODULE__, channel_count, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(channel_count) do
|
||||
children =
|
||||
for i <- 1..channel_count do
|
||||
%{
|
||||
id: {Odinsea.Channel.Server, i},
|
||||
start: {Odinsea.Channel.Server, :start_link, [i]}
|
||||
}
|
||||
end
|
||||
|
||||
Logger.info("Starting #{channel_count} game channels")
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
end
|
||||
228
lib/odinsea/constants/game.ex
Normal file
228
lib/odinsea/constants/game.ex
Normal file
@@ -0,0 +1,228 @@
|
||||
defmodule Odinsea.Constants.Game do
|
||||
@moduledoc """
|
||||
Game constants ported from Java GameConstants.
|
||||
These define gameplay mechanics, limits, and rates.
|
||||
"""
|
||||
|
||||
# Character limits
|
||||
@max_level 250
|
||||
@max_ap 999
|
||||
@max_hp_mp 999_999
|
||||
@base_max_hp 50
|
||||
@base_max_mp 50
|
||||
|
||||
# Inventory limits
|
||||
@max_inventory_slots 128
|
||||
@equip_slots 96
|
||||
@use_slots 96
|
||||
@setup_slots 96
|
||||
@etc_slots 96
|
||||
@cash_slots 96
|
||||
|
||||
# Meso limits
|
||||
@max_meso 9_999_999_999
|
||||
@max_storage_meso 9_999_999_999
|
||||
|
||||
# Guild limits
|
||||
@max_guild_name_length 12
|
||||
@max_guild_members 100
|
||||
|
||||
# Party limits
|
||||
@max_party_members 6
|
||||
@max_expedition_members 30
|
||||
|
||||
# GMS specific flag
|
||||
@gms true
|
||||
|
||||
@doc """
|
||||
Returns the maximum character level.
|
||||
"""
|
||||
def max_level, do: @max_level
|
||||
|
||||
@doc """
|
||||
Returns the maximum AP.
|
||||
"""
|
||||
def max_ap, do: @max_ap
|
||||
|
||||
@doc """
|
||||
Returns the maximum HP/MP.
|
||||
"""
|
||||
def max_hp_mp, do: @max_hp_mp
|
||||
|
||||
@doc """
|
||||
Returns the base max HP for new characters.
|
||||
"""
|
||||
def base_max_hp, do: @base_max_hp
|
||||
|
||||
@doc """
|
||||
Returns the base max MP for new characters.
|
||||
"""
|
||||
def base_max_mp, do: @base_max_mp
|
||||
|
||||
@doc """
|
||||
Returns the max inventory slots per type.
|
||||
"""
|
||||
def max_inventory_slots, do: @max_inventory_slots
|
||||
|
||||
def equip_slots, do: @equip_slots
|
||||
def use_slots, do: @use_slots
|
||||
def setup_slots, do: @setup_slots
|
||||
def etc_slots, do: @etc_slots
|
||||
def cash_slots, do: @cash_slots
|
||||
|
||||
@doc """
|
||||
Returns the max meso a character can hold.
|
||||
"""
|
||||
def max_meso, do: @max_meso
|
||||
|
||||
@doc """
|
||||
Returns the max meso in storage.
|
||||
"""
|
||||
def max_storage_meso, do: @max_storage_meso
|
||||
|
||||
@doc """
|
||||
Returns true if this is a GMS server.
|
||||
"""
|
||||
def gms?, do: @gms
|
||||
|
||||
@doc """
|
||||
Returns the rates from configuration.
|
||||
"""
|
||||
def rates do
|
||||
Application.get_env(:odinsea, :rates, [])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the EXP rate.
|
||||
"""
|
||||
def exp_rate do
|
||||
rates()[:exp] || 1
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the meso rate.
|
||||
"""
|
||||
def meso_rate do
|
||||
rates()[:meso] || 1
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the drop rate.
|
||||
"""
|
||||
def drop_rate do
|
||||
rates()[:drop] || 1
|
||||
end
|
||||
|
||||
# Job classification helpers
|
||||
|
||||
@doc """
|
||||
Returns true if the job is a beginner job.
|
||||
"""
|
||||
def beginner?(job), do: div(job, 1000) == 0 && rem(job, 100) == 0
|
||||
|
||||
@doc """
|
||||
Returns true if the job is a warrior class.
|
||||
"""
|
||||
def warrior?(job), do: div(job, 100) == 1
|
||||
|
||||
@doc """
|
||||
Returns true if the job is a magician class.
|
||||
"""
|
||||
def magician?(job), do: div(job, 100) == 2
|
||||
|
||||
@doc """
|
||||
Returns true if the job is a bowman class.
|
||||
"""
|
||||
def bowman?(job), do: div(job, 100) == 3
|
||||
|
||||
@doc """
|
||||
Returns true if the job is a thief class.
|
||||
"""
|
||||
def thief?(job), do: div(job, 100) == 4
|
||||
|
||||
@doc """
|
||||
Returns true if the job is a pirate class.
|
||||
"""
|
||||
def pirate?(job), do: div(job, 100) == 5 || div(job, 100) == 51 || div(job, 100) == 52
|
||||
|
||||
@doc """
|
||||
Returns true if the job is a resistance class.
|
||||
"""
|
||||
def resistance?(job), do: div(job, 1000) == 3
|
||||
|
||||
@doc """
|
||||
Returns true if the job is a cygnus class.
|
||||
"""
|
||||
def cygnus?(job), do: div(job, 1000) == 1
|
||||
|
||||
@doc """
|
||||
Returns true if the job is an aran.
|
||||
"""
|
||||
def aran?(job), do: div(job, 100) == 21
|
||||
|
||||
@doc """
|
||||
Returns true if the job is an evan.
|
||||
"""
|
||||
def evan?(job), do: div(job, 100) == 22 || job == 2001
|
||||
|
||||
@doc """
|
||||
Returns the job name for a given job ID.
|
||||
"""
|
||||
def job_name(job) do
|
||||
case job do
|
||||
0 -> "Beginner"
|
||||
100 -> "Warrior"
|
||||
110 -> "Fighter"
|
||||
120 -> "Page"
|
||||
130 -> "Spearman"
|
||||
111 -> "Crusader"
|
||||
121 -> "Knight"
|
||||
131 -> "Dragon Knight"
|
||||
112 -> "Hero"
|
||||
122 -> "Paladin"
|
||||
132 -> "Dark Knight"
|
||||
200 -> "Magician"
|
||||
210 -> "Wizard (F/P)"
|
||||
220 -> "Wizard (I/L)"
|
||||
230 -> "Cleric"
|
||||
211 -> "Mage (F/P)"
|
||||
221 -> "Mage (I/L)"
|
||||
231 -> "Bishop"
|
||||
212 -> "Arch Mage (F/P)"
|
||||
222 -> "Arch Mage (I/L)"
|
||||
232 -> "Arch Mage"
|
||||
300 -> "Bowman"
|
||||
310 -> "Hunter"
|
||||
320 -> "Crossbowman"
|
||||
311 -> "Ranger"
|
||||
321 -> "Sniper"
|
||||
312 -> "Bowmaster"
|
||||
322 -> "Marksman"
|
||||
400 -> "Thief"
|
||||
410 -> "Assassin"
|
||||
420 -> "Bandit"
|
||||
411 -> "Hermit"
|
||||
421 -> "Chief Bandit"
|
||||
412 -> "Night Lord"
|
||||
422 -> "Shadower"
|
||||
500 -> "Pirate"
|
||||
510 -> "Brawler"
|
||||
520 -> "Gunslinger"
|
||||
511 -> "Marauder"
|
||||
521 -> "Outlaw"
|
||||
512 -> "Buccaneer"
|
||||
522 -> "Corsair"
|
||||
1000 -> "Noblesse"
|
||||
1100 -> "Dawn Warrior"
|
||||
1200 -> "Blaze Wizard"
|
||||
1300 -> "Wind Archer"
|
||||
1400 -> "Night Walker"
|
||||
1500 -> "Thunder Breaker"
|
||||
2000 -> "Legend"
|
||||
2100 -> "Aran"
|
||||
2001 -> "Evan"
|
||||
2002 -> "Mercedes"
|
||||
_ -> "Unknown"
|
||||
end
|
||||
end
|
||||
end
|
||||
122
lib/odinsea/constants/server.ex
Normal file
122
lib/odinsea/constants/server.ex
Normal file
@@ -0,0 +1,122 @@
|
||||
defmodule Odinsea.Constants.Server do
|
||||
@moduledoc """
|
||||
Server constants ported from Java ServerConstants.
|
||||
These define the MapleStory client version and protocol details.
|
||||
"""
|
||||
|
||||
# MapleStory Client Version (GMS v342)
|
||||
@maple_version 342
|
||||
@maple_patch "1"
|
||||
@client_version 99
|
||||
|
||||
# Protocol constants
|
||||
@packet_header_size 4
|
||||
@aes_key_size 32
|
||||
@block_size 1460
|
||||
|
||||
# RSA Keys (from ServerConstants.java)
|
||||
@pub_key ""
|
||||
@maplogin_default "default"
|
||||
@maplogin_custom "custom"
|
||||
|
||||
# Packet sequence constants
|
||||
@iv_length 4
|
||||
@short_size 2
|
||||
@int_size 4
|
||||
@long_size 8
|
||||
|
||||
@doc """
|
||||
Returns the MapleStory client version.
|
||||
"""
|
||||
def maple_version, do: @maple_version
|
||||
|
||||
@doc """
|
||||
Returns the MapleStory patch version.
|
||||
"""
|
||||
def maple_patch, do: @maple_patch
|
||||
|
||||
@doc """
|
||||
Returns the full client version string.
|
||||
"""
|
||||
def client_version, do: @client_version
|
||||
|
||||
@doc """
|
||||
Returns the packet header size in bytes.
|
||||
"""
|
||||
def packet_header_size, do: @packet_header_size
|
||||
|
||||
@doc """
|
||||
Returns the AES key size in bytes.
|
||||
"""
|
||||
def aes_key_size, do: @aes_key_size
|
||||
|
||||
@doc """
|
||||
Returns the network block size.
|
||||
"""
|
||||
def block_size, do: @block_size
|
||||
|
||||
@doc """
|
||||
Returns the RSA public key.
|
||||
"""
|
||||
def pub_key do
|
||||
Application.get_env(:odinsea, :rsa_pub_key, @pub_key)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the default login background.
|
||||
"""
|
||||
def maplogin_default, do: @maplogin_default
|
||||
|
||||
@doc """
|
||||
Returns the custom login background.
|
||||
"""
|
||||
def maplogin_custom, do: @maplogin_custom
|
||||
|
||||
@doc """
|
||||
Returns the IV (initialization vector) length.
|
||||
"""
|
||||
def iv_length, do: @iv_length
|
||||
|
||||
@doc """
|
||||
Returns the size of a short in bytes.
|
||||
"""
|
||||
def short_size, do: @short_size
|
||||
|
||||
@doc """
|
||||
Returns the size of an int in bytes.
|
||||
"""
|
||||
def int_size, do: @int_size
|
||||
|
||||
@doc """
|
||||
Returns the size of a long in bytes.
|
||||
"""
|
||||
def long_size, do: @long_size
|
||||
|
||||
@doc """
|
||||
Returns server info from configuration.
|
||||
"""
|
||||
def server_info do
|
||||
Application.get_env(:odinsea, :server, [])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the server name.
|
||||
"""
|
||||
def server_name do
|
||||
server_info()[:name] || "Luna"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the server host.
|
||||
"""
|
||||
def server_host do
|
||||
server_info()[:host] || "127.0.0.1"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the server revision.
|
||||
"""
|
||||
def server_revision do
|
||||
server_info()[:revision] || 1
|
||||
end
|
||||
end
|
||||
484
lib/odinsea/database/context.ex
Normal file
484
lib/odinsea/database/context.ex
Normal file
@@ -0,0 +1,484 @@
|
||||
defmodule Odinsea.Database.Context do
|
||||
@moduledoc """
|
||||
Database context module for Odinsea.
|
||||
Provides high-level database operations for accounts, characters, and related entities.
|
||||
|
||||
Ported from Java UnifiedDB.java and MapleClient.java login functionality.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Odinsea.Repo
|
||||
alias Odinsea.Database.Schema.{Account, Character}
|
||||
alias Odinsea.Net.Cipher.LoginCrypto
|
||||
|
||||
# ==================================================================================================
|
||||
# Account Operations
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Authenticates a user with username and password.
|
||||
|
||||
Returns:
|
||||
- {:ok, account_info} on successful authentication
|
||||
- {:error, reason} on failure (reason can be :invalid_credentials, :banned, :already_logged_in, etc.)
|
||||
|
||||
Ported from MapleClient.java login() method
|
||||
"""
|
||||
def authenticate_user(username, password, ip_address \\ "") do
|
||||
# Filter username (sanitization)
|
||||
username = sanitize_username(username)
|
||||
|
||||
case Repo.get_by(Account, name: username) do
|
||||
nil ->
|
||||
Logger.warning("Login attempt for non-existent account: #{username}")
|
||||
{:error, :account_not_found}
|
||||
|
||||
account ->
|
||||
check_account_login(account, password, ip_address)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates all accounts to logged out state.
|
||||
Used during server startup/shutdown.
|
||||
"""
|
||||
def set_all_accounts_logged_off do
|
||||
Repo.update_all(Account, set: [loggedin: 0])
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates account login state.
|
||||
|
||||
States:
|
||||
- 0 = LOGIN_NOTLOGGEDIN
|
||||
- 1 = LOGIN_SERVER_TRANSITION (migrating between servers)
|
||||
- 2 = LOGIN_LOGGEDIN
|
||||
- 3 = CHANGE_CHANNEL
|
||||
"""
|
||||
def update_login_state(account_id, state, session_ip \\ nil) do
|
||||
updates = [loggedin: state]
|
||||
updates = if session_ip, do: Keyword.put(updates, :session_ip, session_ip), else: updates
|
||||
|
||||
Repo.update_all(
|
||||
from(a in Account, where: a.id == ^account_id),
|
||||
set: updates
|
||||
)
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current login state for an account.
|
||||
"""
|
||||
def get_login_state(account_id) do
|
||||
case Repo.get(Account, account_id) do
|
||||
nil -> 0
|
||||
account -> account.loggedin
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates account last login time.
|
||||
"""
|
||||
def update_last_login(account_id) do
|
||||
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
|
||||
|
||||
Repo.update_all(
|
||||
from(a in Account, where: a.id == ^account_id),
|
||||
set: [lastlogin: now]
|
||||
)
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Records an IP log entry for audit purposes.
|
||||
"""
|
||||
def log_ip_address(account_id, ip_address) do
|
||||
timestamp = format_timestamp(NaiveDateTime.utc_now())
|
||||
|
||||
# Using raw SQL since iplog may not have an Ecto schema yet
|
||||
sql = "INSERT INTO iplog (accid, ip, time) VALUES (?, ?, ?)"
|
||||
|
||||
case Ecto.Adapters.SQL.query(Repo, sql, [account_id, ip_address, timestamp]) do
|
||||
{:ok, _} -> :ok
|
||||
{:error, err} ->
|
||||
Logger.error("Failed to log IP: #{inspect(err)}")
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if an account is banned.
|
||||
Returns {:ok, account} if not banned, {:error, :banned} if banned.
|
||||
"""
|
||||
def check_ban_status(account_id) do
|
||||
case Repo.get(Account, account_id) do
|
||||
nil -> {:error, :not_found}
|
||||
account ->
|
||||
if account.banned > 0 do
|
||||
{:error, :banned}
|
||||
else
|
||||
{:ok, account}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets temporary ban information if account is temp banned.
|
||||
"""
|
||||
def get_temp_ban_info(account_id) do
|
||||
case Repo.get(Account, account_id) do
|
||||
nil -> nil
|
||||
account ->
|
||||
if account.tempban && NaiveDateTime.compare(account.tempban, NaiveDateTime.utc_now()) == :gt do
|
||||
%{reason: account.banreason, expires: account.tempban}
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Character Operations
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Loads character entries for an account in a specific world.
|
||||
Returns a list of character summaries (id, name, gm level).
|
||||
|
||||
Ported from UnifiedDB.loadCharactersEntry()
|
||||
"""
|
||||
def load_character_entries(account_id, world_id) do
|
||||
Character
|
||||
|> where([c], c.accountid == ^account_id and c.world == ^world_id)
|
||||
|> select([c], %{id: c.id, name: c.name, gm: c.gm})
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the count of characters for an account in a world.
|
||||
"""
|
||||
def character_count(account_id, world_id) do
|
||||
Character
|
||||
|> where([c], c.accountid == ^account_id and c.world == ^world_id)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all character IDs for an account in a world.
|
||||
"""
|
||||
def load_character_ids(account_id, world_id) do
|
||||
Character
|
||||
|> where([c], c.accountid == ^account_id and c.world == ^world_id)
|
||||
|> select([c], c.id)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all character names for an account in a world.
|
||||
"""
|
||||
def load_character_names(account_id, world_id) do
|
||||
Character
|
||||
|> where([c], c.accountid == ^account_id and c.world == ^world_id)
|
||||
|> select([c], c.name)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads full character data by ID.
|
||||
Returns the Character struct or nil if not found.
|
||||
|
||||
TODO: Expand to load related data (inventory, skills, quests, etc.)
|
||||
"""
|
||||
def load_character(character_id) do
|
||||
Repo.get(Character, character_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a character name is already in use.
|
||||
"""
|
||||
def character_name_exists?(name) do
|
||||
Character
|
||||
|> where([c], c.name == ^name)
|
||||
|> Repo.exists?()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets character ID by name and world.
|
||||
"""
|
||||
def get_character_id(name, world_id) do
|
||||
Character
|
||||
|> where([c], c.name == ^name and c.world == ^world_id)
|
||||
|> select([c], c.id)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new character.
|
||||
|
||||
TODO: Add initial items, quests, and stats based on job type
|
||||
"""
|
||||
def create_character(attrs) do
|
||||
%Character{}
|
||||
|> Character.creation_changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a character (soft delete - renames and moves to deleted world).
|
||||
|
||||
Returns {:ok, character} on success, {:error, reason} on failure.
|
||||
|
||||
Ported from UnifiedDB.deleteCharacter()
|
||||
"""
|
||||
def delete_character(character_id) do
|
||||
# TODO: Check guild rank (can't delete if guild leader)
|
||||
# TODO: Remove from family
|
||||
# TODO: Handle sidekick
|
||||
|
||||
deleted_world = -1 # WORLD_DELETED
|
||||
|
||||
# Soft delete: rename with # prefix and move to deleted world
|
||||
# Need to get the character name first to construct the new name
|
||||
case Repo.get(Character, character_id) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
character ->
|
||||
new_name = "#" <> character.name
|
||||
|
||||
Repo.update_all(
|
||||
from(c in Character, where: c.id == ^character_id),
|
||||
set: [
|
||||
name: new_name,
|
||||
world: deleted_world
|
||||
]
|
||||
)
|
||||
|
||||
# Clean up related records
|
||||
cleanup_character_assets(character_id)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates character stats.
|
||||
"""
|
||||
def update_character_stats(character_id, attrs) do
|
||||
case Repo.get(Character, character_id) do
|
||||
nil -> {:error, :not_found}
|
||||
character ->
|
||||
character
|
||||
|> Character.stat_changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates character position (map, spawn point).
|
||||
"""
|
||||
def update_character_position(character_id, map_id, spawn_point) do
|
||||
case Repo.get(Character, character_id) do
|
||||
nil -> {:error, :not_found}
|
||||
character ->
|
||||
character
|
||||
|> Character.position_changeset(%{map: map_id, spawnpoint: spawn_point})
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Character Creation Helpers
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Gets default stats for a new character based on job type.
|
||||
|
||||
Job types:
|
||||
- 0 = Resistance
|
||||
- 1 = Adventurer
|
||||
- 2 = Cygnus
|
||||
- 3 = Aran
|
||||
- 4 = Evan
|
||||
"""
|
||||
def get_default_stats_for_job(job_type, subcategory \\ 0) do
|
||||
base_stats = %{
|
||||
level: 1,
|
||||
exp: 0,
|
||||
hp: 50,
|
||||
mp: 5,
|
||||
maxhp: 50,
|
||||
maxmp: 5,
|
||||
str: 12,
|
||||
dex: 5,
|
||||
luk: 4,
|
||||
int: 4,
|
||||
ap: 0
|
||||
}
|
||||
|
||||
case job_type do
|
||||
0 -> # Resistance
|
||||
%{base_stats | job: 3000, str: 12, dex: 5, int: 4, luk: 4}
|
||||
1 -> # Adventurer
|
||||
if subcategory == 1 do # Dual Blade
|
||||
%{base_stats | job: 430, str: 4, dex: 25, int: 4, luk: 4}
|
||||
else
|
||||
%{base_stats | job: 0, str: 12, dex: 5, int: 4, luk: 4}
|
||||
end
|
||||
2 -> # Cygnus
|
||||
%{base_stats | job: 1000, str: 12, dex: 5, int: 4, luk: 4}
|
||||
3 -> # Aran
|
||||
%{base_stats | job: 2000, str: 12, dex: 5, int: 4, luk: 4}
|
||||
4 -> # Evan
|
||||
%{base_stats | job: 2001, str: 4, dex: 4, int: 12, luk: 5}
|
||||
_ ->
|
||||
base_stats
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the default map ID for a job type.
|
||||
"""
|
||||
def get_default_map_for_job(job_type) do
|
||||
case job_type do
|
||||
0 -> 931000000 # Resistance tutorial
|
||||
1 -> 0 # Adventurer - Maple Island (handled specially)
|
||||
2 -> 130030000 # Cygnus tutorial
|
||||
3 -> 914000000 # Aran tutorial
|
||||
4 -> 900010000 # Evan tutorial
|
||||
_ -> 100000000 # Default to Henesys
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Forbidden Names
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Checks if a character name is forbidden.
|
||||
"""
|
||||
def forbidden_name?(name) do
|
||||
forbidden = [
|
||||
"admin", "gm", "gamemaster", "moderator", "mod",
|
||||
"owner", "developer", "dev", "support", "help",
|
||||
"system", "server", "odinsea", "maplestory", "nexon"
|
||||
]
|
||||
|
||||
name_lower = String.downcase(name)
|
||||
Enum.any?(forbidden, fn forbidden ->
|
||||
String.contains?(name_lower, forbidden)
|
||||
end)
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Private Functions
|
||||
# ==================================================================================================
|
||||
|
||||
defp check_account_login(account, password, ip_address) do
|
||||
# Check if banned
|
||||
if account.banned > 0 && account.gm == 0 do
|
||||
Logger.warning("Banned account attempted login: #{account.name}")
|
||||
{:error, :banned}
|
||||
else
|
||||
# Check login state
|
||||
login_state = account.loggedin
|
||||
|
||||
if login_state > 0 do
|
||||
# Already logged in - could check if stale session
|
||||
Logger.warning("Account already logged in: #{account.name}")
|
||||
{:error, :already_logged_in}
|
||||
else
|
||||
verify_password(account, password, ip_address)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp verify_password(account, password, ip_address) do
|
||||
# Check various password formats
|
||||
valid =
|
||||
check_salted_sha512(account.password, password, account.salt) ||
|
||||
check_plain_match(account.password, password) ||
|
||||
check_admin_bypass(password, ip_address)
|
||||
|
||||
if valid do
|
||||
# Log successful login
|
||||
log_ip_address(account.id, ip_address)
|
||||
update_last_login(account.id)
|
||||
|
||||
# Build account info map
|
||||
account_info = %{
|
||||
account_id: account.id,
|
||||
username: account.name,
|
||||
gender: account.gender,
|
||||
is_gm: account.gm > 0,
|
||||
second_password: decrypt_second_password(account.second_password, account.salt2),
|
||||
acash: account.acash,
|
||||
mpoints: account.mpoints
|
||||
}
|
||||
|
||||
{:ok, account_info}
|
||||
else
|
||||
{:error, :invalid_credentials}
|
||||
end
|
||||
end
|
||||
|
||||
defp check_salted_sha512(_hash, _password, nil), do: false
|
||||
defp check_salted_sha512(_hash, _password, ""), do: false
|
||||
defp check_salted_sha512(hash, password, salt) do
|
||||
# Use LoginCrypto to verify salted SHA-512 hash
|
||||
case LoginCrypto.verify_salted_sha512(password, salt, hash) do
|
||||
{:ok, _} -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp check_plain_match(hash, password) do
|
||||
# Direct comparison (legacy/insecure, but needed for compatibility)
|
||||
hash == password
|
||||
end
|
||||
|
||||
defp check_admin_bypass(password, ip_address) do
|
||||
# Check for admin bypass password from specific IPs
|
||||
# TODO: Load admin passwords and allowed IPs from config
|
||||
false
|
||||
end
|
||||
|
||||
defp decrypt_second_password(nil, _), do: nil
|
||||
defp decrypt_second_password("", _), do: nil
|
||||
defp decrypt_second_password(spw, salt2) do
|
||||
if salt2 && salt2 != "" do
|
||||
# Decrypt using rand_r (reverse of rand_s)
|
||||
LoginCrypto.rand_r(spw)
|
||||
else
|
||||
spw
|
||||
end
|
||||
end
|
||||
|
||||
defp sanitize_username(username) do
|
||||
# Remove potentially dangerous characters
|
||||
username
|
||||
|> String.trim()
|
||||
|> String.replace(~r/[<>"'%;()&+\-]/, "")
|
||||
end
|
||||
|
||||
defp format_timestamp(naive_datetime) do
|
||||
NaiveDateTime.to_string(naive_datetime)
|
||||
end
|
||||
|
||||
defp cleanup_character_assets(character_id) do
|
||||
# Clean up pokemon, buddies, etc.
|
||||
# Using raw SQL for tables that don't have schemas yet
|
||||
|
||||
try do
|
||||
Ecto.Adapters.SQL.query(Repo, "DELETE FROM buddies WHERE buddyid = ?", [character_id])
|
||||
rescue
|
||||
_ -> :ok
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
9
lib/odinsea/database/repo.ex
Normal file
9
lib/odinsea/database/repo.ex
Normal file
@@ -0,0 +1,9 @@
|
||||
defmodule Odinsea.Repo do
|
||||
@moduledoc """
|
||||
Ecto repository for Odinsea database.
|
||||
"""
|
||||
|
||||
use Ecto.Repo,
|
||||
otp_app: :odinsea,
|
||||
adapter: Ecto.Adapters.MyXQL
|
||||
end
|
||||
70
lib/odinsea/database/schema/account.ex
Normal file
70
lib/odinsea/database/schema/account.ex
Normal file
@@ -0,0 +1,70 @@
|
||||
defmodule Odinsea.Database.Schema.Account do
|
||||
@moduledoc """
|
||||
Ecto schema for the accounts table.
|
||||
Represents a user account in the game.
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :id, autogenerate: true}
|
||||
@timestamps_opts [inserted_at: :createdat, updated_at: false]
|
||||
|
||||
schema "accounts" do
|
||||
field :name, :string
|
||||
field :password, :string
|
||||
field :salt, :string
|
||||
field :second_password, :string, source: :"2ndpassword"
|
||||
field :salt2, :string
|
||||
field :loggedin, :integer, default: 0
|
||||
field :lastlogin, :naive_datetime
|
||||
field :createdat, :naive_datetime
|
||||
field :birthday, :date
|
||||
field :banned, :integer, default: 0
|
||||
field :banreason, :string
|
||||
field :gm, :integer, default: 0
|
||||
field :email, :string
|
||||
field :macs, :string
|
||||
field :tempban, :naive_datetime
|
||||
field :greason, :integer
|
||||
field :acash, :integer, default: 0, source: :ACash
|
||||
field :mpoints, :integer, default: 0, source: :mPoints
|
||||
field :gender, :integer, default: 0
|
||||
field :session_ip, :string, source: :SessionIP
|
||||
field :points, :integer, default: 0
|
||||
field :vpoints, :integer, default: 0
|
||||
field :totalvotes, :integer, default: 0
|
||||
field :lastlogon, :naive_datetime
|
||||
field :lastvoteip, :string
|
||||
|
||||
has_many :characters, Odinsea.Database.Schema.Character, foreign_key: :accountid
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for account registration.
|
||||
"""
|
||||
def registration_changeset(account, attrs) do
|
||||
account
|
||||
|> cast(attrs, [:name, :password, :salt, :birthday, :gender, :email])
|
||||
|> validate_required([:name, :password, :salt])
|
||||
|> validate_length(:name, min: 3, max: 13)
|
||||
|> validate_format(:name, ~r/^[a-zA-Z0-9]+$/, message: "only letters and numbers allowed")
|
||||
|> unique_constraint(:name)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for login updates (last login time, IP, etc).
|
||||
"""
|
||||
def login_changeset(account, attrs) do
|
||||
account
|
||||
|> cast(attrs, [:loggedin, :lastlogin, :session_ip])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for ban updates.
|
||||
"""
|
||||
def ban_changeset(account, attrs) do
|
||||
account
|
||||
|> cast(attrs, [:banned, :banreason, :tempban, :greason])
|
||||
end
|
||||
end
|
||||
134
lib/odinsea/database/schema/character.ex
Normal file
134
lib/odinsea/database/schema/character.ex
Normal file
@@ -0,0 +1,134 @@
|
||||
defmodule Odinsea.Database.Schema.Character do
|
||||
@moduledoc """
|
||||
Ecto schema for the characters table.
|
||||
Represents a player character in the game.
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@primary_key {:id, :id, autogenerate: true}
|
||||
@timestamps_opts [inserted_at: :createdate, updated_at: false]
|
||||
|
||||
schema "characters" do
|
||||
field :accountid, :integer
|
||||
field :world, :integer, default: 0
|
||||
field :name, :string
|
||||
field :level, :integer, default: 1
|
||||
field :exp, :integer, default: 0
|
||||
field :str, :integer, default: 4
|
||||
field :dex, :integer, default: 4
|
||||
field :luk, :integer, default: 4
|
||||
field :int, :integer, default: 4
|
||||
field :hp, :integer, default: 50
|
||||
field :mp, :integer, default: 5
|
||||
field :maxhp, :integer, default: 50
|
||||
field :maxmp, :integer, default: 5
|
||||
field :meso, :integer, default: 0
|
||||
field :hp_ap_used, :integer, default: 0, source: :hpApUsed
|
||||
field :job, :integer, default: 0
|
||||
field :skincolor, :integer, default: 0
|
||||
field :gender, :integer, default: 0
|
||||
field :fame, :integer, default: 0
|
||||
field :hair, :integer, default: 0
|
||||
field :face, :integer, default: 0
|
||||
field :ap, :integer, default: 0
|
||||
field :map, :integer, default: 100000000
|
||||
field :spawnpoint, :integer, default: 0
|
||||
field :gm, :integer, default: 0
|
||||
field :party, :integer, default: 0
|
||||
field :buddy_capacity, :integer, default: 25, source: :buddyCapacity
|
||||
field :createdate, :naive_datetime
|
||||
field :guildid, :integer, default: 0
|
||||
field :guildrank, :integer, default: 5
|
||||
field :alliance_rank, :integer, default: 5, source: :allianceRank
|
||||
field :guild_contribution, :integer, default: 0, source: :guildContribution
|
||||
field :pets, :string, default: "-1,-1,-1"
|
||||
field :sp, :string, default: "0,0,0,0,0,0,0,0,0,0"
|
||||
field :subcategory, :integer, default: 0
|
||||
field :rank, :integer, default: 1
|
||||
field :rank_move, :integer, default: 0, source: :rankMove
|
||||
field :job_rank, :integer, default: 1, source: :jobRank
|
||||
field :job_rank_move, :integer, default: 0, source: :jobRankMove
|
||||
field :marriage_id, :integer, default: 0, source: :marriageId
|
||||
field :familyid, :integer, default: 0
|
||||
field :seniorid, :integer, default: 0
|
||||
field :junior1, :integer, default: 0
|
||||
field :junior2, :integer, default: 0
|
||||
field :currentrep, :integer, default: 0
|
||||
field :totalrep, :integer, default: 0
|
||||
field :gachexp, :integer, default: 0
|
||||
field :fatigue, :integer, default: 0
|
||||
field :charm, :integer, default: 0
|
||||
field :craft, :integer, default: 0
|
||||
field :charisma, :integer, default: 0
|
||||
field :will, :integer, default: 0
|
||||
field :sense, :integer, default: 0
|
||||
field :insight, :integer, default: 0
|
||||
field :total_wins, :integer, default: 0, source: :totalWins
|
||||
field :total_losses, :integer, default: 0, source: :totalLosses
|
||||
field :pvp_exp, :integer, default: 0, source: :pvpExp
|
||||
field :pvp_points, :integer, default: 0, source: :pvpPoints
|
||||
|
||||
belongs_to :account, Odinsea.Database.Schema.Account,
|
||||
foreign_key: :accountid,
|
||||
references: :id,
|
||||
define_field: false
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for character creation.
|
||||
"""
|
||||
def creation_changeset(character, attrs) do
|
||||
character
|
||||
|> cast(attrs, [
|
||||
:accountid,
|
||||
:world,
|
||||
:name,
|
||||
:job,
|
||||
:gender,
|
||||
:skincolor,
|
||||
:hair,
|
||||
:face,
|
||||
:str,
|
||||
:dex,
|
||||
:luk,
|
||||
:int,
|
||||
:hp,
|
||||
:mp,
|
||||
:maxhp,
|
||||
:maxmp,
|
||||
:ap,
|
||||
:map,
|
||||
:spawnpoint
|
||||
])
|
||||
|> validate_required([:accountid, :world, :name, :job, :gender])
|
||||
|> validate_length(:name, min: 3, max: 13)
|
||||
|> validate_format(:name, ~r/^[a-zA-Z]+$/, message: "only letters allowed")
|
||||
|> unique_constraint(:name)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for character stat updates.
|
||||
"""
|
||||
def stat_changeset(character, attrs) do
|
||||
character
|
||||
|> cast(attrs, [:level, :exp, :str, :dex, :luk, :int, :hp, :mp, :maxhp, :maxmp, :ap, :meso, :fame])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for character position updates.
|
||||
"""
|
||||
def position_changeset(character, attrs) do
|
||||
character
|
||||
|> cast(attrs, [:map, :spawnpoint])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for guild/party updates.
|
||||
"""
|
||||
def social_changeset(character, attrs) do
|
||||
character
|
||||
|> cast(attrs, [:party, :guildid, :guildrank, :familyid])
|
||||
end
|
||||
end
|
||||
434
lib/odinsea/game/character.ex
Normal file
434
lib/odinsea/game/character.ex
Normal file
@@ -0,0 +1,434 @@
|
||||
defmodule Odinsea.Game.Character do
|
||||
@moduledoc """
|
||||
Represents an in-game character (player) with stats, position, inventory, skills, etc.
|
||||
This is a GenServer that manages character state while the player is logged into a channel.
|
||||
|
||||
Unlike the database schema (Odinsea.Database.Schema.Character), this module represents
|
||||
the live, mutable game state including position, buffs, equipment effects, etc.
|
||||
"""
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Database.Schema.Character, as: CharacterDB
|
||||
alias Odinsea.Game.Map, as: GameMap
|
||||
alias Odinsea.Net.Packet.Out
|
||||
|
||||
# ============================================================================
|
||||
# Data Structures
|
||||
# ============================================================================
|
||||
|
||||
defmodule Stats do
|
||||
@moduledoc "Character stats (base + equipped + buffed)"
|
||||
defstruct [
|
||||
# Base stats
|
||||
:str,
|
||||
:dex,
|
||||
:int,
|
||||
:luk,
|
||||
:hp,
|
||||
:max_hp,
|
||||
:mp,
|
||||
:max_mp,
|
||||
# Computed stats (from equipment + buffs)
|
||||
:weapon_attack,
|
||||
:magic_attack,
|
||||
:weapon_defense,
|
||||
:magic_defense,
|
||||
:accuracy,
|
||||
:avoidability,
|
||||
:speed,
|
||||
:jump
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
str: non_neg_integer(),
|
||||
dex: non_neg_integer(),
|
||||
int: non_neg_integer(),
|
||||
luk: non_neg_integer(),
|
||||
hp: non_neg_integer(),
|
||||
max_hp: non_neg_integer(),
|
||||
mp: non_neg_integer(),
|
||||
max_mp: non_neg_integer(),
|
||||
weapon_attack: non_neg_integer(),
|
||||
magic_attack: non_neg_integer(),
|
||||
weapon_defense: non_neg_integer(),
|
||||
magic_defense: non_neg_integer(),
|
||||
accuracy: non_neg_integer(),
|
||||
avoidability: non_neg_integer(),
|
||||
speed: non_neg_integer(),
|
||||
jump: non_neg_integer()
|
||||
}
|
||||
end
|
||||
|
||||
defmodule Position do
|
||||
@moduledoc "Character position and stance"
|
||||
defstruct [:x, :y, :foothold, :stance]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
x: integer(),
|
||||
y: integer(),
|
||||
foothold: non_neg_integer(),
|
||||
stance: byte()
|
||||
}
|
||||
end
|
||||
|
||||
defmodule State do
|
||||
@moduledoc "In-game character state"
|
||||
defstruct [
|
||||
# Identity
|
||||
:character_id,
|
||||
:account_id,
|
||||
:name,
|
||||
:world_id,
|
||||
:channel_id,
|
||||
# Character data
|
||||
:level,
|
||||
:job,
|
||||
:exp,
|
||||
:meso,
|
||||
:fame,
|
||||
:gender,
|
||||
:skin_color,
|
||||
:hair,
|
||||
:face,
|
||||
# Stats
|
||||
:stats,
|
||||
# Position & Map
|
||||
:map_id,
|
||||
:position,
|
||||
:spawn_point,
|
||||
# AP/SP
|
||||
:remaining_ap,
|
||||
:remaining_sp,
|
||||
# Client connection
|
||||
:client_pid,
|
||||
# Inventory (TODO)
|
||||
:inventories,
|
||||
# Skills (TODO)
|
||||
:skills,
|
||||
# Buffs (TODO)
|
||||
:buffs,
|
||||
# Pets (TODO)
|
||||
:pets,
|
||||
# Timestamps
|
||||
:created_at,
|
||||
:updated_at
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
character_id: pos_integer(),
|
||||
account_id: pos_integer(),
|
||||
name: String.t(),
|
||||
world_id: byte(),
|
||||
channel_id: byte(),
|
||||
level: non_neg_integer(),
|
||||
job: non_neg_integer(),
|
||||
exp: non_neg_integer(),
|
||||
meso: non_neg_integer(),
|
||||
fame: integer(),
|
||||
gender: byte(),
|
||||
skin_color: byte(),
|
||||
hair: non_neg_integer(),
|
||||
face: non_neg_integer(),
|
||||
stats: Stats.t(),
|
||||
map_id: non_neg_integer(),
|
||||
position: Position.t(),
|
||||
spawn_point: byte(),
|
||||
remaining_ap: non_neg_integer(),
|
||||
remaining_sp: list(non_neg_integer()),
|
||||
client_pid: pid() | nil,
|
||||
inventories: map(),
|
||||
skills: map(),
|
||||
buffs: list(),
|
||||
pets: list(),
|
||||
created_at: DateTime.t(),
|
||||
updated_at: DateTime.t()
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts a character GenServer for an in-game player.
|
||||
"""
|
||||
def start_link(opts) do
|
||||
character_id = Keyword.fetch!(opts, :character_id)
|
||||
GenServer.start_link(__MODULE__, opts, name: via_tuple(character_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Loads a character from the database and starts their GenServer.
|
||||
"""
|
||||
def load(character_id, account_id, world_id, channel_id, client_pid) do
|
||||
case Odinsea.Database.Context.get_character(character_id) do
|
||||
nil ->
|
||||
{:error, :character_not_found}
|
||||
|
||||
db_char ->
|
||||
# Verify ownership
|
||||
if db_char.account_id != account_id do
|
||||
{:error, :unauthorized}
|
||||
else
|
||||
state = from_database(db_char, world_id, channel_id, client_pid)
|
||||
|
||||
case start_link(character_id: character_id, state: state) do
|
||||
{:ok, pid} -> {:ok, pid, state}
|
||||
{:error, {:already_started, pid}} -> {:ok, pid, state}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current character state.
|
||||
"""
|
||||
def get_state(character_id) do
|
||||
GenServer.call(via_tuple(character_id), :get_state)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates character position (from movement packet).
|
||||
"""
|
||||
def update_position(character_id, position) do
|
||||
GenServer.cast(via_tuple(character_id), {:update_position, position})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changes the character's map.
|
||||
"""
|
||||
def change_map(character_id, new_map_id, spawn_point \\ 0) do
|
||||
GenServer.call(via_tuple(character_id), {:change_map, new_map_id, spawn_point})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the character's current map ID.
|
||||
"""
|
||||
def get_map_id(character_id) do
|
||||
GenServer.call(via_tuple(character_id), :get_map_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the character's client PID.
|
||||
"""
|
||||
def get_client_pid(character_id) do
|
||||
GenServer.call(via_tuple(character_id), :get_client_pid)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Saves character to database.
|
||||
"""
|
||||
def save(character_id) do
|
||||
GenServer.call(via_tuple(character_id), :save)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stops the character GenServer (logout).
|
||||
"""
|
||||
def logout(character_id) do
|
||||
GenServer.stop(via_tuple(character_id), :normal)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
state = Keyword.fetch!(opts, :state)
|
||||
Logger.debug("Character loaded: #{state.name} (ID: #{state.character_id})")
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_state, _from, state) do
|
||||
{:reply, state, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_map_id, _from, state) do
|
||||
{:reply, state.map_id, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_client_pid, _from, state) do
|
||||
{:reply, state.client_pid, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:change_map, new_map_id, spawn_point}, _from, state) do
|
||||
old_map_id = state.map_id
|
||||
|
||||
# Remove from old map
|
||||
if old_map_id do
|
||||
GameMap.remove_player(old_map_id, state.character_id)
|
||||
end
|
||||
|
||||
# Update state
|
||||
new_state = %{
|
||||
state
|
||||
| map_id: new_map_id,
|
||||
spawn_point: spawn_point,
|
||||
updated_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
# Add to new map
|
||||
GameMap.add_player(new_map_id, state.character_id)
|
||||
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:save, _from, state) do
|
||||
result = save_to_database(state)
|
||||
{:reply, result, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:update_position, position}, state) do
|
||||
new_state = %{
|
||||
state
|
||||
| position: position,
|
||||
updated_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
{:noreply, new_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(reason, state) do
|
||||
Logger.debug(
|
||||
"Character logout: #{state.name} (ID: #{state.character_id}), reason: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
# Remove from map
|
||||
if state.map_id do
|
||||
GameMap.remove_player(state.map_id, state.character_id)
|
||||
end
|
||||
|
||||
# Save to database
|
||||
save_to_database(state)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp via_tuple(character_id) do
|
||||
{:via, Registry, {Odinsea.CharacterRegistry, character_id}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts database character to in-game state.
|
||||
"""
|
||||
def from_database(%CharacterDB{} = db_char, world_id, channel_id, client_pid) do
|
||||
stats = %Stats{
|
||||
str: db_char.str,
|
||||
dex: db_char.dex,
|
||||
int: db_char.int,
|
||||
luk: db_char.luk,
|
||||
hp: db_char.hp,
|
||||
max_hp: db_char.max_hp,
|
||||
mp: db_char.mp,
|
||||
max_mp: db_char.max_mp,
|
||||
# Computed stats - TODO: calculate from equipment
|
||||
weapon_attack: 0,
|
||||
magic_attack: 0,
|
||||
weapon_defense: 0,
|
||||
magic_defense: 0,
|
||||
accuracy: 0,
|
||||
avoidability: 0,
|
||||
speed: 100,
|
||||
jump: 100
|
||||
}
|
||||
|
||||
position = %Position{
|
||||
x: 0,
|
||||
y: 0,
|
||||
foothold: 0,
|
||||
stance: 0
|
||||
}
|
||||
|
||||
# Parse remaining_sp (stored as comma-separated string in Java version)
|
||||
remaining_sp =
|
||||
case db_char.remaining_sp do
|
||||
nil -> [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
sp_str when is_binary(sp_str) -> parse_sp_string(sp_str)
|
||||
sp_list when is_list(sp_list) -> sp_list
|
||||
end
|
||||
|
||||
%State{
|
||||
character_id: db_char.id,
|
||||
account_id: db_char.account_id,
|
||||
name: db_char.name,
|
||||
world_id: world_id,
|
||||
channel_id: channel_id,
|
||||
level: db_char.level,
|
||||
job: db_char.job,
|
||||
exp: db_char.exp,
|
||||
meso: db_char.meso,
|
||||
fame: db_char.fame,
|
||||
gender: db_char.gender,
|
||||
skin_color: db_char.skin_color,
|
||||
hair: db_char.hair,
|
||||
face: db_char.face,
|
||||
stats: stats,
|
||||
map_id: db_char.map_id,
|
||||
position: position,
|
||||
spawn_point: db_char.spawn_point,
|
||||
remaining_ap: db_char.remaining_ap,
|
||||
remaining_sp: remaining_sp,
|
||||
client_pid: client_pid,
|
||||
inventories: %{},
|
||||
skills: %{},
|
||||
buffs: [],
|
||||
pets: [],
|
||||
created_at: db_char.inserted_at,
|
||||
updated_at: DateTime.utc_now()
|
||||
}
|
||||
end
|
||||
|
||||
defp parse_sp_string(sp_str) do
|
||||
sp_str
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.to_integer/1)
|
||||
|> Kernel.++(List.duplicate(0, 10))
|
||||
|> Enum.take(10)
|
||||
rescue
|
||||
_ -> [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Saves character state to database.
|
||||
"""
|
||||
def save_to_database(%State{} = state) do
|
||||
# Convert remaining_sp list to comma-separated string for database
|
||||
sp_string = Enum.join(state.remaining_sp, ",")
|
||||
|
||||
attrs = %{
|
||||
level: state.level,
|
||||
job: state.job,
|
||||
exp: state.exp,
|
||||
str: state.stats.str,
|
||||
dex: state.stats.dex,
|
||||
int: state.stats.int,
|
||||
luk: state.stats.luk,
|
||||
hp: state.stats.hp,
|
||||
max_hp: state.stats.max_hp,
|
||||
mp: state.stats.mp,
|
||||
max_mp: state.stats.max_mp,
|
||||
meso: state.meso,
|
||||
fame: state.fame,
|
||||
map_id: state.map_id,
|
||||
spawn_point: state.spawn_point,
|
||||
remaining_ap: state.remaining_ap,
|
||||
remaining_sp: sp_string
|
||||
}
|
||||
|
||||
Odinsea.Database.Context.update_character(state.character_id, attrs)
|
||||
end
|
||||
end
|
||||
300
lib/odinsea/game/map.ex
Normal file
300
lib/odinsea/game/map.ex
Normal file
@@ -0,0 +1,300 @@
|
||||
defmodule Odinsea.Game.Map do
|
||||
@moduledoc """
|
||||
Represents a game map instance.
|
||||
|
||||
Each map is a GenServer that manages all objects on the map:
|
||||
- Players
|
||||
- Monsters (mobs)
|
||||
- NPCs
|
||||
- Items (drops)
|
||||
- Reactors
|
||||
- Portals
|
||||
|
||||
Maps are registered by map_id and belong to a specific channel.
|
||||
"""
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Game.Character
|
||||
alias Odinsea.Channel.Packets, as: ChannelPackets
|
||||
|
||||
# ============================================================================
|
||||
# Data Structures
|
||||
# ============================================================================
|
||||
|
||||
defmodule State do
|
||||
@moduledoc "Map instance state"
|
||||
defstruct [
|
||||
# Map identity
|
||||
:map_id,
|
||||
:channel_id,
|
||||
# Objects on map (by type)
|
||||
:players,
|
||||
# Map stores character_id => %{oid: integer(), character: Character.State}
|
||||
:monsters,
|
||||
# Map stores oid => Monster
|
||||
:npcs,
|
||||
# Map stores oid => NPC
|
||||
:items,
|
||||
# Map stores oid => Item
|
||||
:reactors,
|
||||
# Map stores oid => Reactor
|
||||
# Object ID counter
|
||||
:next_oid,
|
||||
# Map properties (TODO: load from WZ data)
|
||||
:return_map,
|
||||
:forced_return,
|
||||
:time_limit,
|
||||
:field_limit,
|
||||
:mob_rate,
|
||||
:drop_rate,
|
||||
# Timestamps
|
||||
:created_at
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
map_id: non_neg_integer(),
|
||||
channel_id: byte(),
|
||||
players: %{pos_integer() => map()},
|
||||
monsters: %{pos_integer() => any()},
|
||||
npcs: %{pos_integer() => any()},
|
||||
items: %{pos_integer() => any()},
|
||||
reactors: %{pos_integer() => any()},
|
||||
next_oid: pos_integer(),
|
||||
return_map: non_neg_integer() | nil,
|
||||
forced_return: non_neg_integer() | nil,
|
||||
time_limit: non_neg_integer() | nil,
|
||||
field_limit: non_neg_integer() | nil,
|
||||
mob_rate: float(),
|
||||
drop_rate: float(),
|
||||
created_at: DateTime.t()
|
||||
}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Client API
|
||||
# ============================================================================
|
||||
|
||||
@doc """
|
||||
Starts a map GenServer.
|
||||
"""
|
||||
def start_link(opts) do
|
||||
map_id = Keyword.fetch!(opts, :map_id)
|
||||
channel_id = Keyword.fetch!(opts, :channel_id)
|
||||
|
||||
GenServer.start_link(__MODULE__, opts, name: via_tuple(map_id, channel_id))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Ensures a map is loaded for the given channel.
|
||||
"""
|
||||
def ensure_map(map_id, channel_id) do
|
||||
case Registry.lookup(Odinsea.MapRegistry, {map_id, channel_id}) do
|
||||
[{pid, _}] ->
|
||||
{:ok, pid}
|
||||
|
||||
[] ->
|
||||
# Start map via DynamicSupervisor
|
||||
spec = {__MODULE__, map_id: map_id, channel_id: channel_id}
|
||||
|
||||
case DynamicSupervisor.start_child(Odinsea.MapSupervisor, spec) do
|
||||
{:ok, pid} -> {:ok, pid}
|
||||
{:error, {:already_started, pid}} -> {:ok, pid}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a player to the map.
|
||||
"""
|
||||
def add_player(map_id, character_id) do
|
||||
# TODO: Get channel_id from somewhere
|
||||
channel_id = 1
|
||||
{:ok, _pid} = ensure_map(map_id, channel_id)
|
||||
GenServer.call(via_tuple(map_id, channel_id), {:add_player, character_id})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes a player from the map.
|
||||
"""
|
||||
def remove_player(map_id, character_id) do
|
||||
# TODO: Get channel_id from somewhere
|
||||
channel_id = 1
|
||||
|
||||
case Registry.lookup(Odinsea.MapRegistry, {map_id, channel_id}) do
|
||||
[{_pid, _}] ->
|
||||
GenServer.call(via_tuple(map_id, channel_id), {:remove_player, character_id})
|
||||
|
||||
[] ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a packet to all players on the map.
|
||||
"""
|
||||
def broadcast(map_id, channel_id, packet) do
|
||||
GenServer.cast(via_tuple(map_id, channel_id), {:broadcast, packet})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Broadcasts a packet to all players except the specified character.
|
||||
"""
|
||||
def broadcast_except(map_id, channel_id, except_character_id, packet) do
|
||||
GenServer.cast(via_tuple(map_id, channel_id), {:broadcast_except, except_character_id, packet})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all players on the map.
|
||||
"""
|
||||
def get_players(map_id, channel_id) do
|
||||
GenServer.call(via_tuple(map_id, channel_id), :get_players)
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# GenServer Callbacks
|
||||
# ============================================================================
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
map_id = Keyword.fetch!(opts, :map_id)
|
||||
channel_id = Keyword.fetch!(opts, :channel_id)
|
||||
|
||||
state = %State{
|
||||
map_id: map_id,
|
||||
channel_id: channel_id,
|
||||
players: %{},
|
||||
monsters: %{},
|
||||
npcs: %{},
|
||||
items: %{},
|
||||
reactors: %{},
|
||||
next_oid: 500_000,
|
||||
return_map: nil,
|
||||
forced_return: nil,
|
||||
time_limit: nil,
|
||||
field_limit: 0,
|
||||
mob_rate: 1.0,
|
||||
drop_rate: 1.0,
|
||||
created_at: DateTime.utc_now()
|
||||
}
|
||||
|
||||
Logger.debug("Map loaded: #{map_id} (channel #{channel_id})")
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:add_player, character_id}, _from, state) do
|
||||
# Allocate OID for this player
|
||||
oid = state.next_oid
|
||||
|
||||
# Get character state
|
||||
case Character.get_state(character_id) do
|
||||
%Character.State{} = char_state ->
|
||||
# Add player to map
|
||||
player_entry = %{
|
||||
oid: oid,
|
||||
character: char_state
|
||||
}
|
||||
|
||||
new_players = Map.put(state.players, character_id, player_entry)
|
||||
|
||||
# Broadcast spawn packet to other players
|
||||
spawn_packet = ChannelPackets.spawn_player(oid, char_state)
|
||||
broadcast_to_players(new_players, spawn_packet, except: character_id)
|
||||
|
||||
# Send existing players to new player
|
||||
client_pid = char_state.client_pid
|
||||
|
||||
if client_pid do
|
||||
send_existing_players(client_pid, new_players, except: character_id)
|
||||
end
|
||||
|
||||
new_state = %{
|
||||
state
|
||||
| players: new_players,
|
||||
next_oid: oid + 1
|
||||
}
|
||||
|
||||
{:reply, {:ok, oid}, new_state}
|
||||
|
||||
nil ->
|
||||
{:reply, {:error, :character_not_found}, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:remove_player, character_id}, _from, state) do
|
||||
case Map.get(state.players, character_id) do
|
||||
nil ->
|
||||
{:reply, :ok, state}
|
||||
|
||||
player_entry ->
|
||||
# Broadcast despawn packet
|
||||
despawn_packet = ChannelPackets.remove_player(player_entry.oid)
|
||||
broadcast_to_players(state.players, despawn_packet, except: character_id)
|
||||
|
||||
# Remove from map
|
||||
new_players = Map.delete(state.players, character_id)
|
||||
new_state = %{state | players: new_players}
|
||||
|
||||
{:reply, :ok, new_state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call(:get_players, _from, state) do
|
||||
{:reply, state.players, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:broadcast, packet}, state) do
|
||||
broadcast_to_players(state.players, packet)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_cast({:broadcast_except, except_character_id, packet}, state) do
|
||||
broadcast_to_players(state.players, packet, except: except_character_id)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
defp via_tuple(map_id, channel_id) do
|
||||
{:via, Registry, {Odinsea.MapRegistry, {map_id, channel_id}}}
|
||||
end
|
||||
|
||||
defp broadcast_to_players(players, packet, opts \\ []) do
|
||||
except_char_id = Keyword.get(opts, :except)
|
||||
|
||||
Enum.each(players, fn
|
||||
{char_id, %{character: char_state}} when char_id != except_char_id ->
|
||||
if char_state.client_pid do
|
||||
send_packet(char_state.client_pid, packet)
|
||||
end
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end)
|
||||
end
|
||||
|
||||
defp send_existing_players(client_pid, players, opts) do
|
||||
except_char_id = Keyword.get(opts, :except)
|
||||
|
||||
Enum.each(players, fn
|
||||
{char_id, %{oid: oid, character: char_state}} when char_id != except_char_id ->
|
||||
spawn_packet = ChannelPackets.spawn_player(oid, char_state)
|
||||
send_packet(client_pid, spawn_packet)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end)
|
||||
end
|
||||
|
||||
defp send_packet(client_pid, packet) do
|
||||
send(client_pid, {:send_packet, packet})
|
||||
end
|
||||
end
|
||||
146
lib/odinsea/game/movement.ex
Normal file
146
lib/odinsea/game/movement.ex
Normal file
@@ -0,0 +1,146 @@
|
||||
defmodule Odinsea.Game.Movement do
|
||||
@moduledoc """
|
||||
Movement parsing and validation for players, mobs, pets, summons, and dragons.
|
||||
Ported from Java MovementParse.java.
|
||||
|
||||
Movement types (kind):
|
||||
- 1: Player
|
||||
- 2: Mob
|
||||
- 3: Pet
|
||||
- 4: Summon
|
||||
- 5: Dragon
|
||||
|
||||
This is a SIMPLIFIED implementation for now. The full Java version has complex
|
||||
parsing for different movement command types. We'll expand this as needed.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Game.Character.Position
|
||||
|
||||
@doc """
|
||||
Parses movement data from a packet.
|
||||
Returns {:ok, movements} or {:error, reason}.
|
||||
|
||||
For now, this returns a simplified structure. The full implementation
|
||||
would parse all movement fragment types.
|
||||
"""
|
||||
def parse_movement(packet, _kind) do
|
||||
num_commands = In.decode_byte(packet)
|
||||
|
||||
# For now, just skip through the movement data and extract final position
|
||||
# TODO: Implement full movement parsing with all command types
|
||||
case extract_final_position(packet, num_commands) do
|
||||
{:ok, position} ->
|
||||
{:ok, %{num_commands: num_commands, final_position: position}}
|
||||
|
||||
:error ->
|
||||
{:error, :invalid_movement}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates an entity's position from movement data.
|
||||
"""
|
||||
def update_position(_movements, character_id) do
|
||||
# TODO: Implement position update logic
|
||||
# For now, just return ok
|
||||
Logger.debug("Update position for character #{character_id}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# ============================================================================
|
||||
# Private Functions
|
||||
# ============================================================================
|
||||
|
||||
# Extract the final position from movement data
|
||||
# This is a TEMPORARY simplification - we just read through the movement
|
||||
# commands and try to extract the last absolute position
|
||||
defp extract_final_position(packet, num_commands) do
|
||||
try do
|
||||
final_pos = parse_commands(packet, num_commands, nil)
|
||||
{:ok, final_pos || %{x: 0, y: 0, stance: 0, foothold: 0}}
|
||||
rescue
|
||||
_ ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_commands(_packet, 0, last_position) do
|
||||
last_position
|
||||
end
|
||||
|
||||
defp parse_commands(packet, remaining, last_position) do
|
||||
command = In.decode_byte(packet)
|
||||
|
||||
new_position =
|
||||
case command do
|
||||
# Absolute movement commands - extract position
|
||||
cmd when cmd in [0, 37, 38, 39, 40, 41, 42] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_xwobble = In.decode_short(packet)
|
||||
_ywobble = In.decode_short(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
_xoffset = In.decode_short(packet)
|
||||
_yoffset = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Relative movement - skip for now
|
||||
cmd when cmd in [1, 2, 33, 34, 36] ->
|
||||
_xmod = In.decode_short(packet)
|
||||
_ymod = In.decode_short(packet)
|
||||
_stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
last_position
|
||||
|
||||
# Teleport movement
|
||||
cmd when cmd in [3, 4, 8, 100, 101] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_xwobble = In.decode_short(packet)
|
||||
_ywobble = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Chair movement
|
||||
cmd when cmd in [9, 12] ->
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Aran combat step
|
||||
cmd when cmd in [21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 35] ->
|
||||
_stance = In.decode_byte(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
last_position
|
||||
|
||||
# Jump down
|
||||
cmd when cmd in [13, 14] ->
|
||||
# Simplified - just skip the data
|
||||
x = In.decode_short(packet)
|
||||
y = In.decode_short(packet)
|
||||
_xwobble = In.decode_short(packet)
|
||||
_ywobble = In.decode_short(packet)
|
||||
_unk = In.decode_short(packet)
|
||||
_fh = In.decode_short(packet)
|
||||
_xoffset = In.decode_short(packet)
|
||||
_yoffset = In.decode_short(packet)
|
||||
stance = In.decode_byte(packet)
|
||||
_duration = In.decode_short(packet)
|
||||
%{x: x, y: y, stance: stance, foothold: 0}
|
||||
|
||||
# Unknown/unhandled - log and skip
|
||||
_ ->
|
||||
Logger.warning("Unhandled movement command: #{command}")
|
||||
last_position
|
||||
end
|
||||
|
||||
parse_commands(packet, remaining - 1, new_position || last_position)
|
||||
end
|
||||
end
|
||||
140
lib/odinsea/login/client.ex
Normal file
140
lib/odinsea/login/client.ex
Normal file
@@ -0,0 +1,140 @@
|
||||
defmodule Odinsea.Login.Client do
|
||||
@moduledoc """
|
||||
Client connection handler for the login server.
|
||||
Manages the login session state.
|
||||
"""
|
||||
|
||||
use GenServer, restart: :temporary
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Net.Opcodes
|
||||
|
||||
defstruct [
|
||||
:socket,
|
||||
:ip,
|
||||
:state,
|
||||
:account_id,
|
||||
:account_name,
|
||||
:character_id,
|
||||
:world,
|
||||
:channel,
|
||||
:logged_in,
|
||||
:login_attempts,
|
||||
:second_password,
|
||||
:gender,
|
||||
:is_gm,
|
||||
:hardware_info
|
||||
]
|
||||
|
||||
def start_link(socket) do
|
||||
GenServer.start_link(__MODULE__, socket)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(socket) do
|
||||
{:ok, {ip, _port}} = :inet.peername(socket)
|
||||
ip_string = format_ip(ip)
|
||||
|
||||
Logger.info("Login client connected from #{ip_string}")
|
||||
|
||||
state = %__MODULE__{
|
||||
socket: socket,
|
||||
ip: ip_string,
|
||||
state: :connected,
|
||||
account_id: nil,
|
||||
account_name: nil,
|
||||
character_id: nil,
|
||||
world: nil,
|
||||
channel: nil,
|
||||
logged_in: false,
|
||||
login_attempts: 0,
|
||||
second_password: nil,
|
||||
gender: 0,
|
||||
is_gm: false,
|
||||
hardware_info: nil
|
||||
}
|
||||
|
||||
# Start receiving packets
|
||||
send(self(), :receive)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:receive, %{socket: socket} = state) do
|
||||
case :gen_tcp.recv(socket, 0, 30_000) do
|
||||
{:ok, data} ->
|
||||
# Handle packet
|
||||
new_state = handle_packet(data, state)
|
||||
send(self(), :receive)
|
||||
{:noreply, new_state}
|
||||
|
||||
{:error, :closed} ->
|
||||
Logger.info("Login client disconnected: #{state.ip}")
|
||||
{:stop, :normal, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Login client error: #{inspect(reason)}")
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:disconnect, reason}, state) do
|
||||
Logger.info("Disconnecting client #{state.ip}: #{inspect(reason)}")
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, state) do
|
||||
if state.socket do
|
||||
:gen_tcp.close(state.socket)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_packet(data, state) do
|
||||
packet = In.new(data)
|
||||
|
||||
# Read opcode (first 2 bytes)
|
||||
case In.decode_short(packet) do
|
||||
{opcode, packet} ->
|
||||
Logger.debug("Login packet received: opcode=0x#{Integer.to_string(opcode, 16)}")
|
||||
dispatch_packet(opcode, packet, state)
|
||||
|
||||
:error ->
|
||||
Logger.warning("Failed to read packet opcode")
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch_packet(opcode, packet, state) do
|
||||
# Use PacketProcessor to route packets
|
||||
alias Odinsea.Net.Processor
|
||||
|
||||
case Processor.handle(opcode, packet, state, :login) do
|
||||
{:ok, new_state} ->
|
||||
new_state
|
||||
|
||||
{:error, reason, new_state} ->
|
||||
Logger.error("Packet processing error: #{inspect(reason)}")
|
||||
new_state
|
||||
|
||||
{:disconnect, reason} ->
|
||||
Logger.warning("Client disconnected: #{inspect(reason)}")
|
||||
send(self(), {:disconnect, reason})
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp format_ip({a, b, c, d}) do
|
||||
"#{a}.#{b}.#{c}.#{d}"
|
||||
end
|
||||
|
||||
defp format_ip({a, b, c, d, e, f, g, h}) do
|
||||
"#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
|
||||
end
|
||||
end
|
||||
538
lib/odinsea/login/handler.ex
Normal file
538
lib/odinsea/login/handler.ex
Normal file
@@ -0,0 +1,538 @@
|
||||
defmodule Odinsea.Login.Handler do
|
||||
@moduledoc """
|
||||
Login server packet handlers.
|
||||
Ported from Java CharLoginHandler.java
|
||||
|
||||
Handles all login-related operations:
|
||||
- Authentication (login/password)
|
||||
- World/server selection
|
||||
- Character list
|
||||
- Character creation/deletion
|
||||
- Character selection (migration to channel)
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.{In, Out}
|
||||
alias Odinsea.Net.Cipher.LoginCrypto
|
||||
alias Odinsea.Login.Packets
|
||||
alias Odinsea.Constants.Server
|
||||
alias Odinsea.Database.Context
|
||||
|
||||
# ==================================================================================================
|
||||
# Permission Request (Client Hello / Version Check)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles permission request (initial client hello).
|
||||
Validates client version, locale, and patch.
|
||||
|
||||
Packet structure:
|
||||
- byte: locale
|
||||
- short: major version
|
||||
- short: minor version (patch)
|
||||
"""
|
||||
def on_permission_request(packet, state) do
|
||||
{locale, packet} = In.decode_byte(packet)
|
||||
{major, packet} = In.decode_short(packet)
|
||||
{minor, _packet} = In.decode_short(packet)
|
||||
|
||||
# Validate version
|
||||
if locale != Server.maple_locale() or
|
||||
major != Server.maple_version() or
|
||||
Integer.to_string(minor) != Server.maple_patch() do
|
||||
Logger.warning("Invalid client version: locale=#{locale}, major=#{major}, minor=#{minor}")
|
||||
{:disconnect, :invalid_version}
|
||||
else
|
||||
Logger.debug("Permission request validated")
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Password Check (Authentication)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles password check (login authentication).
|
||||
|
||||
Packet structure:
|
||||
- string: username
|
||||
- string: password (RSA encrypted if enabled)
|
||||
"""
|
||||
def on_check_password(packet, state) do
|
||||
{username, packet} = In.decode_string(packet)
|
||||
{password_encrypted, _packet} = In.decode_string(packet)
|
||||
|
||||
# Decrypt password if encryption is enabled
|
||||
password =
|
||||
if Application.get_env(:odinsea, :encrypt_passwords, false) do
|
||||
case LoginCrypto.decrypt_rsa(password_encrypted) do
|
||||
{:ok, decrypted} -> decrypted
|
||||
{:error, _} -> password_encrypted
|
||||
end
|
||||
else
|
||||
password_encrypted
|
||||
end
|
||||
|
||||
Logger.info("Login attempt: username=#{username} from #{state.ip}")
|
||||
|
||||
# Check if IP/MAC is banned
|
||||
# TODO: Implement IP/MAC ban checking
|
||||
is_banned = false
|
||||
|
||||
# Authenticate with database
|
||||
case Context.authenticate_user(username, password, state.ip) do
|
||||
{:ok, account_info} ->
|
||||
# TODO: Check if account is banned or temp banned
|
||||
# TODO: Check if already logged in (kick other session)
|
||||
|
||||
# Send success response
|
||||
response = Packets.get_auth_success(
|
||||
account_info.account_id,
|
||||
account_info.username,
|
||||
account_info.gender,
|
||||
account_info.is_gm,
|
||||
account_info.second_password
|
||||
)
|
||||
|
||||
new_state =
|
||||
state
|
||||
|> Map.put(:logged_in, true)
|
||||
|> Map.put(:account_id, account_info.account_id)
|
||||
|> Map.put(:account_name, account_info.username)
|
||||
|> Map.put(:gender, account_info.gender)
|
||||
|> Map.put(:is_gm, account_info.is_gm)
|
||||
|> Map.put(:second_password, account_info.second_password)
|
||||
|> Map.put(:login_attempts, 0)
|
||||
|
||||
send_packet(state, response)
|
||||
{:ok, new_state}
|
||||
|
||||
{:error, :invalid_credentials} ->
|
||||
# Increment login attempts
|
||||
login_attempts = Map.get(state, :login_attempts, 0) + 1
|
||||
|
||||
if login_attempts > 5 do
|
||||
Logger.warning("Too many login attempts from #{state.ip}")
|
||||
{:disconnect, :too_many_attempts}
|
||||
else
|
||||
# Send login failed (reason 4 = incorrect password)
|
||||
response = Packets.get_login_failed(4)
|
||||
send_packet(state, response)
|
||||
|
||||
new_state = Map.put(state, :login_attempts, login_attempts)
|
||||
{:ok, new_state}
|
||||
end
|
||||
|
||||
{:error, :account_not_found} ->
|
||||
# Send login failed (reason 5 = not registered ID)
|
||||
response = Packets.get_login_failed(5)
|
||||
send_packet(state, response)
|
||||
{:ok, state}
|
||||
|
||||
{:error, :already_logged_in} ->
|
||||
# Send login failed (reason 7 = already logged in)
|
||||
response = Packets.get_login_failed(7)
|
||||
send_packet(state, response)
|
||||
{:ok, state}
|
||||
|
||||
{:error, :banned} ->
|
||||
# TODO: Check temp ban vs perm ban
|
||||
response = Packets.get_perm_ban(0)
|
||||
send_packet(state, response)
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# World Info Request (Server List)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles world info request.
|
||||
Sends the list of available worlds/servers and channels.
|
||||
"""
|
||||
def on_world_info_request(state) do
|
||||
# TODO: Get actual channel load from World server
|
||||
# For now, send a stub response
|
||||
channel_load = get_channel_load()
|
||||
|
||||
# Send server list
|
||||
server_list = Packets.get_server_list(
|
||||
0, # server_id
|
||||
"Odinsea", # world_name
|
||||
0, # flag (0=normal, 1=event, 2=new, 3=hot)
|
||||
"Welcome to Odinsea!", # event_message
|
||||
channel_load
|
||||
)
|
||||
|
||||
send_packet(state, server_list)
|
||||
|
||||
# Send end of server list
|
||||
end_list = Packets.get_end_of_server_list()
|
||||
send_packet(state, end_list)
|
||||
|
||||
# Send latest connected world
|
||||
latest_world = Packets.get_latest_connected_world(0)
|
||||
send_packet(state, latest_world)
|
||||
|
||||
# Send recommended world message
|
||||
recommend = Packets.get_recommend_world_message(0, "Join now!")
|
||||
send_packet(state, recommend)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Check User Limit (Server Capacity)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles check user limit request.
|
||||
Returns server population status.
|
||||
"""
|
||||
def on_check_user_limit(state) do
|
||||
# TODO: Get actual user count from World server
|
||||
{users_online, user_limit} = get_user_count()
|
||||
|
||||
status =
|
||||
cond do
|
||||
users_online >= user_limit -> 2 # Full
|
||||
users_online * 2 >= user_limit -> 1 # Highly populated
|
||||
true -> 0 # Normal
|
||||
end
|
||||
|
||||
response = Packets.get_server_status(status)
|
||||
send_packet(state, response)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Select World (Load Character List)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles world selection.
|
||||
Validates world/channel and sends character list.
|
||||
|
||||
Packet structure:
|
||||
- byte: world_id
|
||||
- byte: channel_id (0-based, add 1 for actual channel)
|
||||
"""
|
||||
def on_select_world(packet, state) do
|
||||
if not Map.get(state, :logged_in, false) do
|
||||
Logger.warning("Select world: not logged in")
|
||||
{:disconnect, :not_logged_in}
|
||||
else
|
||||
{world_id, packet} = In.decode_byte(packet)
|
||||
{channel_id, _packet} = In.decode_byte(packet)
|
||||
|
||||
actual_channel = channel_id + 1
|
||||
|
||||
# Validate world
|
||||
if world_id != 0 do
|
||||
Logger.warning("Invalid world ID: #{world_id}")
|
||||
response = Packets.get_login_failed(10)
|
||||
send_packet(state, response)
|
||||
{:ok, state}
|
||||
else
|
||||
# TODO: Check if channel is available
|
||||
# if not World.is_channel_available(actual_channel) do
|
||||
# response = Packets.get_login_failed(10)
|
||||
# send_packet(state, response)
|
||||
# {:ok, state}
|
||||
# else
|
||||
|
||||
# TODO: Load character list from database
|
||||
characters = load_characters(state.account_id, world_id)
|
||||
|
||||
response = Packets.get_char_list(
|
||||
characters,
|
||||
state.second_password,
|
||||
3 # character slots
|
||||
)
|
||||
|
||||
send_packet(state, response)
|
||||
|
||||
new_state =
|
||||
state
|
||||
|> Map.put(:world, world_id)
|
||||
|> Map.put(:channel, actual_channel)
|
||||
|
||||
{:ok, new_state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Check Duplicated ID (Character Name Availability)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles character name availability check.
|
||||
|
||||
Packet structure:
|
||||
- string: character_name
|
||||
"""
|
||||
def on_check_duplicated_id(packet, state) do
|
||||
if not Map.get(state, :logged_in, false) do
|
||||
{:disconnect, :not_logged_in}
|
||||
else
|
||||
{char_name, _packet} = In.decode_string(packet)
|
||||
|
||||
# TODO: Check if name is forbidden or already exists
|
||||
name_used = check_name_used(char_name, state)
|
||||
|
||||
response = Packets.get_char_name_response(char_name, name_used)
|
||||
send_packet(state, response)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Create New Character
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles character creation.
|
||||
|
||||
Packet structure:
|
||||
- string: name
|
||||
- int: job_type (0=Resistance, 1=Adventurer, 2=Cygnus, 3=Aran, 4=Evan)
|
||||
- short: dual_blade (1=DB, 0=Adventurer)
|
||||
- byte: gender (GMS only)
|
||||
- int: face
|
||||
- int: hair
|
||||
- int: hair_color
|
||||
- int: skin_color
|
||||
- int: top
|
||||
- int: bottom
|
||||
- int: shoes
|
||||
- int: weapon
|
||||
"""
|
||||
def on_create_new_character(packet, state) do
|
||||
if not Map.get(state, :logged_in, false) do
|
||||
{:disconnect, :not_logged_in}
|
||||
else
|
||||
{name, packet} = In.decode_string(packet)
|
||||
{job_type, packet} = In.decode_int(packet)
|
||||
{_dual_blade, packet} = In.decode_short(packet)
|
||||
{gender, packet} = In.decode_byte(packet)
|
||||
{face, packet} = In.decode_int(packet)
|
||||
{hair, packet} = In.decode_int(packet)
|
||||
{hair_color, packet} = In.decode_int(packet)
|
||||
{skin_color, packet} = In.decode_int(packet)
|
||||
{top, packet} = In.decode_int(packet)
|
||||
{bottom, packet} = In.decode_int(packet)
|
||||
{shoes, packet} = In.decode_int(packet)
|
||||
{weapon, _packet} = In.decode_int(packet)
|
||||
|
||||
Logger.info("Create character: name=#{name}, job_type=#{job_type}")
|
||||
|
||||
# TODO: Validate appearance items
|
||||
# TODO: Create character in database
|
||||
# TODO: Add default items and quests
|
||||
|
||||
# For now, send success stub
|
||||
response = Packets.get_add_new_char_entry(%{}, false) # TODO: Pass actual character
|
||||
send_packet(state, response)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Create Ultimate (Cygnus Knight → Ultimate Adventurer)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles ultimate adventurer creation (Cygnus Knight transformation).
|
||||
"""
|
||||
def on_create_ultimate(_packet, state) do
|
||||
# TODO: Implement ultimate creation
|
||||
# Requires level 120 Cygnus Knight in Ereve
|
||||
Logger.debug("Create ultimate request (not implemented)")
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Delete Character
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles character deletion.
|
||||
|
||||
Packet structure:
|
||||
- string: second_password (if enabled)
|
||||
- int: character_id
|
||||
"""
|
||||
def on_delete_character(packet, state) do
|
||||
if not Map.get(state, :logged_in, false) do
|
||||
{:disconnect, :not_logged_in}
|
||||
else
|
||||
# TODO: Read second password if enabled
|
||||
{_spw, packet} = In.decode_string(packet)
|
||||
{character_id, _packet} = In.decode_int(packet)
|
||||
|
||||
Logger.info("Delete character: character_id=#{character_id}")
|
||||
|
||||
# TODO: Validate second password
|
||||
# TODO: Check if character belongs to account
|
||||
# TODO: Delete character from database
|
||||
|
||||
# For now, send success stub
|
||||
response = Packets.get_delete_char_response(character_id, 0)
|
||||
send_packet(state, response)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Select Character (Enter Game / Migrate to Channel)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles character selection (enter game).
|
||||
Initiates migration to the selected channel.
|
||||
|
||||
Packet structure:
|
||||
- int: character_id
|
||||
"""
|
||||
def on_select_character(packet, state) do
|
||||
if not Map.get(state, :logged_in, false) do
|
||||
{:disconnect, :not_logged_in}
|
||||
else
|
||||
{character_id, _packet} = In.decode_int(packet)
|
||||
|
||||
Logger.info("Select character: character_id=#{character_id}, channel=#{state.channel}")
|
||||
|
||||
# TODO: Validate character belongs to account
|
||||
# TODO: Load character data
|
||||
# TODO: Register migration token with channel server
|
||||
|
||||
# Send migration command to connect to channel
|
||||
# TODO: Get actual channel IP/port
|
||||
channel_ip = "127.0.0.1"
|
||||
channel_port = 8585 + (state.channel - 1)
|
||||
|
||||
response = Packets.get_server_ip(false, channel_ip, channel_port, character_id)
|
||||
send_packet(state, response)
|
||||
|
||||
new_state = Map.put(state, :character_id, character_id)
|
||||
{:ok, new_state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Check Second Password
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles second password verification (SPW / PIC).
|
||||
|
||||
Packet structure:
|
||||
- string: second_password
|
||||
"""
|
||||
def on_check_spw_request(packet, state) do
|
||||
{spw, _packet} = In.decode_string(packet)
|
||||
|
||||
# TODO: Validate second password
|
||||
stored_spw = Map.get(state, :second_password)
|
||||
|
||||
if stored_spw == nil or stored_spw == spw do
|
||||
# Success - continue with operation
|
||||
{:ok, state}
|
||||
else
|
||||
# Failure - send error
|
||||
response = Packets.get_second_pw_error(15) # Incorrect SPW
|
||||
send_packet(state, response)
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# RSA Key Request
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Handles RSA key request.
|
||||
Sends RSA public key and login background.
|
||||
"""
|
||||
def on_rsa_key(_packet, state) do
|
||||
# TODO: Check if custom client is enabled
|
||||
# TODO: Send damage cap packet if custom client
|
||||
|
||||
# Send login background
|
||||
background = Application.get_env(:odinsea, :login_background, "MapLogin")
|
||||
bg_response = Packets.get_login_background(background)
|
||||
send_packet(state, bg_response)
|
||||
|
||||
# Send RSA public key
|
||||
pub_key = Application.get_env(:odinsea, :rsa_public_key, "")
|
||||
key_response = Packets.get_rsa_key(pub_key)
|
||||
send_packet(state, key_response)
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Helper Functions
|
||||
# ==================================================================================================
|
||||
|
||||
defp send_packet(%{socket: socket} = state, packet_data) do
|
||||
# Add header (2 bytes: packet length)
|
||||
packet_length = byte_size(packet_data)
|
||||
header = <<packet_length::little-size(16)>>
|
||||
full_packet = header <> packet_data
|
||||
|
||||
case :gen_tcp.send(socket, full_packet) do
|
||||
:ok ->
|
||||
Logger.debug("Sent packet: #{packet_length} bytes")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to send packet: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp send_packet(_state, _packet_data) do
|
||||
# Socket not available in state
|
||||
Logger.error("Cannot send packet: socket not in state")
|
||||
:error
|
||||
end
|
||||
|
||||
defp authenticate_user(username, password, _state) do
|
||||
# Delegated to Context.authenticate_user/3
|
||||
Context.authenticate_user(username, password)
|
||||
end
|
||||
|
||||
defp get_channel_load do
|
||||
# TODO: Get actual channel load from World server
|
||||
# For now, return stub data
|
||||
%{
|
||||
1 => 100,
|
||||
2 => 200,
|
||||
3 => 150
|
||||
}
|
||||
end
|
||||
|
||||
defp get_user_count do
|
||||
# TODO: Get actual user count from World server
|
||||
{50, 1000}
|
||||
end
|
||||
|
||||
defp load_characters(account_id, world_id) do
|
||||
Context.load_character_entries(account_id, world_id)
|
||||
end
|
||||
|
||||
defp check_name_used(char_name, _state) do
|
||||
# Check if name is forbidden or already exists
|
||||
Context.forbidden_name?(char_name) or
|
||||
Context.character_name_exists?(char_name)
|
||||
end
|
||||
end
|
||||
62
lib/odinsea/login/listener.ex
Normal file
62
lib/odinsea/login/listener.ex
Normal file
@@ -0,0 +1,62 @@
|
||||
defmodule Odinsea.Login.Listener do
|
||||
@moduledoc """
|
||||
TCP listener for the login server.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
port = Application.get_env(:odinsea, :login, [])[:port] || 8584
|
||||
|
||||
case :gen_tcp.listen(port, tcp_options()) do
|
||||
{:ok, socket} ->
|
||||
Logger.info("Login server listening on port #{port}")
|
||||
# Start accepting connections
|
||||
send(self(), :accept)
|
||||
{:ok, %{socket: socket, port: port}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to start login server on port #{port}: #{inspect(reason)}")
|
||||
{:stop, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:accept, %{socket: socket} = state) do
|
||||
case :gen_tcp.accept(socket) do
|
||||
{:ok, client_socket} ->
|
||||
# Start a new client handler
|
||||
{:ok, _pid} =
|
||||
DynamicSupervisor.start_child(
|
||||
Odinsea.ClientSupervisor,
|
||||
{Odinsea.Login.Client, client_socket}
|
||||
)
|
||||
|
||||
# Continue accepting
|
||||
send(self(), :accept)
|
||||
{:noreply, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Login accept error: #{inspect(reason)}")
|
||||
send(self(), :accept)
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp tcp_options do
|
||||
[
|
||||
:binary,
|
||||
packet: :raw,
|
||||
active: false,
|
||||
reuseaddr: true,
|
||||
backlog: 100
|
||||
]
|
||||
end
|
||||
end
|
||||
434
lib/odinsea/login/packets.ex
Normal file
434
lib/odinsea/login/packets.ex
Normal file
@@ -0,0 +1,434 @@
|
||||
defmodule Odinsea.Login.Packets do
|
||||
@moduledoc """
|
||||
Login server packet builders.
|
||||
Ported from Java LoginPacket.java
|
||||
|
||||
Builds outgoing packets for the login server:
|
||||
- Authentication responses
|
||||
- Server/world lists
|
||||
- Character lists
|
||||
- Character creation/deletion responses
|
||||
"""
|
||||
|
||||
alias Odinsea.Net.Packet.Out
|
||||
alias Odinsea.Net.Opcodes
|
||||
alias Odinsea.Constants.Server
|
||||
|
||||
# ==================================================================================================
|
||||
# Connection & Handshake
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Builds the initial hello/handshake packet sent to the client.
|
||||
Contains maple version, patch version, send/recv IVs, and locale.
|
||||
|
||||
## Parameters
|
||||
- `maple_version` - MapleStory version (342 for GMS v342)
|
||||
- `send_iv` - 4-byte send IV for encryption
|
||||
- `recv_iv` - 4-byte recv IV for encryption
|
||||
|
||||
## Returns
|
||||
Raw binary packet (with length header prepended)
|
||||
"""
|
||||
def get_hello(maple_version, send_iv, recv_iv) when byte_size(send_iv) == 4 and byte_size(recv_iv) == 4 do
|
||||
packet_length = 13 + byte_size(Server.maple_patch())
|
||||
|
||||
Out.new()
|
||||
|> Out.encode_short(packet_length)
|
||||
|> Out.encode_short(maple_version)
|
||||
|> Out.encode_string(Server.maple_patch())
|
||||
|> Out.encode_buffer(recv_iv)
|
||||
|> Out.encode_buffer(send_iv)
|
||||
|> Out.encode_byte(Server.maple_locale())
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Builds a ping/alive request packet.
|
||||
Client should respond with CP_AliveAck.
|
||||
"""
|
||||
def get_ping do
|
||||
Out.new(Opcodes.lp_alive_req())
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends the login background image path to the client.
|
||||
"""
|
||||
def get_login_background(background_path) do
|
||||
# Note: In Java this uses LoopbackPacket.LOGIN_AUTH
|
||||
# Need to verify the correct opcode for this
|
||||
Out.new(Opcodes.lp_set_client_key()) # TODO: Verify opcode
|
||||
|> Out.encode_string(background_path)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends the RSA public key to the client for password encryption.
|
||||
"""
|
||||
def get_rsa_key(public_key) do
|
||||
Out.new(Opcodes.lp_set_client_key())
|
||||
|> Out.encode_string(public_key)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Authentication
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Login failed response with reason code.
|
||||
|
||||
## Reason Codes
|
||||
- 3: ID deleted or blocked
|
||||
- 4: Incorrect password
|
||||
- 5: Not a registered ID
|
||||
- 6: System error
|
||||
- 7: Already logged in
|
||||
- 8: System error
|
||||
- 10: Cannot process so many connections
|
||||
- 13: Unable to log on as master at this IP
|
||||
- 16: Please verify your account through email
|
||||
- 32: IP blocked
|
||||
"""
|
||||
def get_login_failed(reason) do
|
||||
packet =
|
||||
Out.new(Opcodes.lp_check_password_result())
|
||||
|> Out.encode_byte(reason)
|
||||
|
||||
packet =
|
||||
cond do
|
||||
reason == 84 ->
|
||||
# Password change required
|
||||
Out.encode_long(packet, get_time(-2))
|
||||
|
||||
reason == 7 ->
|
||||
# Already logged in
|
||||
Out.encode_bytes(packet, <<0, 0, 0, 0, 0>>)
|
||||
|
||||
true ->
|
||||
packet
|
||||
end
|
||||
|
||||
Out.to_data(packet)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Permanent ban response.
|
||||
"""
|
||||
def get_perm_ban(reason) do
|
||||
Out.new(Opcodes.lp_check_password_result())
|
||||
|> Out.encode_short(2) # Account is banned
|
||||
|> Out.encode_int(0)
|
||||
|> Out.encode_short(reason)
|
||||
|> Out.encode_buffer(<<1, 1, 1, 1, 0>>)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Temporary ban response.
|
||||
"""
|
||||
def get_temp_ban(timestamp_till, reason) do
|
||||
Out.new(Opcodes.lp_check_password_result())
|
||||
|> Out.encode_int(2)
|
||||
|> Out.encode_short(0)
|
||||
|> Out.encode_byte(reason)
|
||||
|> Out.encode_long(timestamp_till)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Successful authentication response.
|
||||
|
||||
## Parameters
|
||||
- `account_id` - Account ID
|
||||
- `account_name` - Account username
|
||||
- `gender` - Gender (0=male, 1=female, 2=unset)
|
||||
- `is_gm` - Admin/GM status
|
||||
- `second_password` - Second password (nil if not set)
|
||||
"""
|
||||
def get_auth_success(account_id, account_name, gender, is_gm, second_password) do
|
||||
admin_byte = if is_gm, do: 1, else: 0
|
||||
spw_byte = get_second_password_byte(second_password)
|
||||
|
||||
Out.new(Opcodes.lp_check_password_result())
|
||||
|> Out.encode_bytes(<<0, 0, 0, 0, 0, 0>>) # GMS specific
|
||||
|> Out.encode_int(account_id)
|
||||
|> Out.encode_byte(gender)
|
||||
|> Out.encode_byte(admin_byte) # Admin byte - Find, Trade, etc.
|
||||
|> Out.encode_short(2) # GMS: 2 for existing accounts, 0 for new
|
||||
|> Out.encode_byte(admin_byte) # Admin byte - Commands
|
||||
|> Out.encode_string(account_name)
|
||||
|> Out.encode_int(3) # 3 for existing accounts, 0 for new
|
||||
|> Out.encode_bytes(<<0, 0, 0, 0, 0, 0>>)
|
||||
|> Out.encode_long(get_time(System.system_time(:millisecond))) # Account creation date
|
||||
|> Out.encode_int(4) # 4 for existing accounts, 0 for new
|
||||
|> Out.encode_byte(1) # 1 = PIN disabled, 0 = PIN enabled
|
||||
|> Out.encode_byte(spw_byte) # Second password status
|
||||
|> Out.encode_long(:rand.uniform(1_000_000_000_000_000_000)) # Random long for anti-hack
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# World/Server List
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Sends a single world/server entry to the client.
|
||||
|
||||
## Parameters
|
||||
- `server_id` - World ID (0-based)
|
||||
- `world_name` - World name (e.g., "Scania")
|
||||
- `flag` - World flag/ribbon (0=none, 1=event, 2=new, 3=hot)
|
||||
- `event_message` - Event message shown on world
|
||||
- `channel_load` - Map of channel_id => population
|
||||
"""
|
||||
def get_server_list(server_id, world_name, flag, event_message, channel_load) do
|
||||
last_channel = get_last_channel(channel_load)
|
||||
|
||||
packet =
|
||||
Out.new(Opcodes.lp_world_information())
|
||||
|> Out.encode_byte(server_id)
|
||||
|> Out.encode_string(world_name)
|
||||
|> Out.encode_byte(flag)
|
||||
|> Out.encode_string(event_message)
|
||||
|> Out.encode_short(100) # EXP rate display
|
||||
|> Out.encode_short(100) # Drop rate display
|
||||
|> Out.encode_byte(0) # GMS specific
|
||||
|> Out.encode_byte(last_channel)
|
||||
|
||||
# Encode channel list
|
||||
packet =
|
||||
Enum.reduce(1..last_channel, packet, fn channel_id, acc ->
|
||||
load = Map.get(channel_load, channel_id, 1200)
|
||||
|
||||
acc
|
||||
|> Out.encode_string("#{world_name}-#{channel_id}")
|
||||
|> Out.encode_int(load)
|
||||
|> Out.encode_byte(server_id)
|
||||
|> Out.encode_short(channel_id - 1)
|
||||
end)
|
||||
|
||||
packet
|
||||
|> Out.encode_short(0) # Balloon message size
|
||||
|> Out.encode_int(0)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends the end-of-server-list marker.
|
||||
"""
|
||||
def get_end_of_server_list do
|
||||
Out.new(Opcodes.lp_world_information())
|
||||
|> Out.encode_byte(0xFF)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends server status (population indicator).
|
||||
|
||||
## Status codes
|
||||
- 0: Normal
|
||||
- 1: Highly populated
|
||||
- 2: Full
|
||||
"""
|
||||
def get_server_status(status) do
|
||||
Out.new(Opcodes.lp_select_world_result())
|
||||
|> Out.encode_short(status)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends latest connected world ID.
|
||||
"""
|
||||
def get_latest_connected_world(world_id) do
|
||||
Out.new(Opcodes.lp_latest_connected_world())
|
||||
|> Out.encode_int(world_id)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends recommended world message.
|
||||
"""
|
||||
def get_recommend_world_message(world_id, message) do
|
||||
packet = Out.new(Opcodes.lp_recommend_world_message())
|
||||
|
||||
packet =
|
||||
if message do
|
||||
packet
|
||||
|> Out.encode_byte(1)
|
||||
|> Out.encode_int(world_id)
|
||||
|> Out.encode_string(message)
|
||||
else
|
||||
Out.encode_byte(packet, 0)
|
||||
end
|
||||
|
||||
Out.to_data(packet)
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Character List
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Sends character list for selected world.
|
||||
|
||||
## Parameters
|
||||
- `characters` - List of character maps
|
||||
- `second_password` - Second password (nil if not set)
|
||||
- `char_slots` - Number of character slots (default 3)
|
||||
"""
|
||||
def get_char_list(characters, second_password, char_slots \\ 3) do
|
||||
spw_byte = get_second_password_byte(second_password)
|
||||
|
||||
packet =
|
||||
Out.new(Opcodes.lp_select_character_result())
|
||||
|> Out.encode_byte(0)
|
||||
|> Out.encode_byte(length(characters))
|
||||
|
||||
# TODO: Encode each character entry
|
||||
# For now, just encode empty list structure
|
||||
packet =
|
||||
Enum.reduce(characters, packet, fn _char, acc ->
|
||||
# add_char_entry(acc, char)
|
||||
acc # TODO: Implement character encoding
|
||||
end)
|
||||
|
||||
packet
|
||||
|> Out.encode_byte(spw_byte)
|
||||
|> Out.encode_long(char_slots)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Character name check response.
|
||||
"""
|
||||
def get_char_name_response(char_name, name_used) do
|
||||
Out.new(Opcodes.lp_check_duplicated_id_result())
|
||||
|> Out.encode_string(char_name)
|
||||
|> Out.encode_byte(if name_used, do: 1, else: 0)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Character creation response.
|
||||
"""
|
||||
def get_add_new_char_entry(character, worked) do
|
||||
Out.new(Opcodes.lp_create_new_character_result())
|
||||
|> Out.encode_byte(if worked, do: 0, else: 1)
|
||||
# TODO: Add character entry if worked
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Character deletion response.
|
||||
"""
|
||||
def get_delete_char_response(character_id, state) do
|
||||
Out.new(Opcodes.lp_delete_character_result())
|
||||
|> Out.encode_int(character_id)
|
||||
|> Out.encode_byte(state)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Second Password
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Second password error response.
|
||||
|
||||
## Mode codes
|
||||
- 14: Invalid password
|
||||
- 15: Second password is incorrect
|
||||
"""
|
||||
def get_second_pw_error(mode) do
|
||||
Out.new(Opcodes.lp_check_spw_result())
|
||||
|> Out.encode_byte(mode)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Migration (Channel Change)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Sends migration command to connect to channel/cash shop.
|
||||
|
||||
## Parameters
|
||||
- `is_cash_shop` - true for cash shop, false for channel
|
||||
- `host` - IP address to connect to
|
||||
- `port` - Port to connect to
|
||||
- `character_id` - Character ID for migration
|
||||
"""
|
||||
def get_server_ip(is_cash_shop, host, port, character_id) do
|
||||
# Parse IP address
|
||||
ip_parts = parse_ip(host)
|
||||
|
||||
Out.new(Opcodes.lp_migrate_command())
|
||||
|> Out.encode_short(if is_cash_shop, do: 1, else: 0)
|
||||
|> encode_ip(ip_parts)
|
||||
|> Out.encode_short(port)
|
||||
|> Out.encode_int(character_id)
|
||||
|> Out.encode_bytes(<<0, 0>>)
|
||||
|> Out.to_data()
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Helper Functions
|
||||
# ==================================================================================================
|
||||
|
||||
defp get_second_password_byte(second_password) do
|
||||
cond do
|
||||
second_password == nil -> 0
|
||||
second_password == "" -> 2
|
||||
true -> 1
|
||||
end
|
||||
end
|
||||
|
||||
defp get_last_channel(channel_load) do
|
||||
channel_load
|
||||
|> Map.keys()
|
||||
|> Enum.max(fn -> 1 end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts a Unix timestamp (milliseconds) to MapleStory time format.
|
||||
MapleStory uses Windows FILETIME (100-nanosecond intervals since 1601-01-01).
|
||||
"""
|
||||
def get_time(timestamp_ms) when timestamp_ms < 0 do
|
||||
# Special values
|
||||
0
|
||||
end
|
||||
|
||||
def get_time(timestamp_ms) do
|
||||
# Convert Unix epoch (1970-01-01) to Windows epoch (1601-01-01)
|
||||
# Difference: 11644473600 seconds = 11644473600000 milliseconds
|
||||
windows_epoch_offset = 11_644_473_600_000
|
||||
|
||||
# Convert to 100-nanosecond intervals
|
||||
(timestamp_ms + windows_epoch_offset) * 10_000
|
||||
end
|
||||
|
||||
defp parse_ip(host) when is_binary(host) do
|
||||
case String.split(host, ".") do
|
||||
[a, b, c, d] ->
|
||||
{
|
||||
String.to_integer(a),
|
||||
String.to_integer(b),
|
||||
String.to_integer(c),
|
||||
String.to_integer(d)
|
||||
}
|
||||
|
||||
_ ->
|
||||
{127, 0, 0, 1} # Default to localhost
|
||||
end
|
||||
end
|
||||
|
||||
defp encode_ip(packet, {a, b, c, d}) do
|
||||
packet
|
||||
|> Out.encode_byte(a)
|
||||
|> Out.encode_byte(b)
|
||||
|> Out.encode_byte(c)
|
||||
|> Out.encode_byte(d)
|
||||
end
|
||||
end
|
||||
124
lib/odinsea/net/cipher/aes_cipher.ex
Normal file
124
lib/odinsea/net/cipher/aes_cipher.ex
Normal file
@@ -0,0 +1,124 @@
|
||||
defmodule Odinsea.Net.Cipher.AESCipher do
|
||||
@moduledoc """
|
||||
MapleStory AES cipher implementation (AES in ECB mode with custom IV handling).
|
||||
This is used for encrypting/decrypting packet data.
|
||||
|
||||
Ported from: src/handling/netty/cipher/AESCipher.java
|
||||
"""
|
||||
|
||||
@block_size 1460
|
||||
|
||||
# MapleStory AES key (32 bytes, expanded from the Java version)
|
||||
@aes_key <<
|
||||
0x13, 0x00, 0x00, 0x00,
|
||||
0x08, 0x00, 0x00, 0x00,
|
||||
0x06, 0x00, 0x00, 0x00,
|
||||
0xB4, 0x00, 0x00, 0x00,
|
||||
0x1B, 0x00, 0x00, 0x00,
|
||||
0x0F, 0x00, 0x00, 0x00,
|
||||
0x33, 0x00, 0x00, 0x00,
|
||||
0x52, 0x00, 0x00, 0x00
|
||||
>>
|
||||
|
||||
@doc """
|
||||
Encrypts or decrypts packet data in place using AES-ECB with IV.
|
||||
|
||||
## Parameters
|
||||
- data: Binary data to encrypt/decrypt
|
||||
- iv: 4-byte IV binary
|
||||
|
||||
## Returns
|
||||
- Encrypted/decrypted binary data
|
||||
"""
|
||||
@spec crypt(binary(), binary()) :: binary()
|
||||
def crypt(data, <<_::binary-size(4)>> = iv) when is_binary(data) do
|
||||
crypt_recursive(data, iv, 0, byte_size(data), @block_size - 4)
|
||||
end
|
||||
|
||||
# Recursive encryption/decryption function
|
||||
@spec crypt_recursive(binary(), binary(), non_neg_integer(), non_neg_integer(), non_neg_integer()) :: binary()
|
||||
defp crypt_recursive(data, _iv, start, remaining, _length) when remaining <= 0 do
|
||||
# Return the portion of data we've processed
|
||||
binary_part(data, 0, start)
|
||||
end
|
||||
|
||||
defp crypt_recursive(data, iv, start, remaining, length) do
|
||||
# Multiply the IV by 4
|
||||
seq_iv = multiply_bytes(iv, byte_size(iv), 4)
|
||||
|
||||
# Adjust length if remaining is smaller
|
||||
actual_length = min(remaining, length)
|
||||
|
||||
# Extract the portion of data to process
|
||||
data_bytes = :binary.bin_to_list(data)
|
||||
|
||||
# Process the data chunk
|
||||
{new_data_bytes, _final_seq_iv} =
|
||||
process_chunk(data_bytes, seq_iv, start, start + actual_length, 0)
|
||||
|
||||
# Convert back to binary
|
||||
new_data = :binary.list_to_bin(new_data_bytes)
|
||||
|
||||
# Continue with next chunk
|
||||
new_start = start + actual_length
|
||||
new_remaining = remaining - actual_length
|
||||
new_length = @block_size
|
||||
|
||||
crypt_recursive(new_data, iv, new_start, new_remaining, new_length)
|
||||
end
|
||||
|
||||
# Process a single chunk of data
|
||||
@spec process_chunk(list(byte()), binary(), non_neg_integer(), non_neg_integer(), non_neg_integer()) ::
|
||||
{list(byte()), binary()}
|
||||
defp process_chunk(data_bytes, seq_iv, x, end_x, _offset) when x >= end_x do
|
||||
{data_bytes, seq_iv}
|
||||
end
|
||||
|
||||
defp process_chunk(data_bytes, seq_iv, x, end_x, offset) do
|
||||
# Check if we need to re-encrypt the IV
|
||||
{new_seq_iv, new_offset} =
|
||||
if rem(offset, byte_size(seq_iv)) == 0 do
|
||||
# Encrypt the IV using AES
|
||||
encrypted_iv = aes_encrypt_block(seq_iv)
|
||||
{encrypted_iv, 0}
|
||||
else
|
||||
{seq_iv, offset}
|
||||
end
|
||||
|
||||
# XOR the data byte with the IV byte
|
||||
seq_iv_bytes = :binary.bin_to_list(new_seq_iv)
|
||||
iv_index = rem(new_offset, length(seq_iv_bytes))
|
||||
iv_byte = Enum.at(seq_iv_bytes, iv_index)
|
||||
data_byte = Enum.at(data_bytes, x)
|
||||
xor_byte = Bitwise.bxor(data_byte, iv_byte)
|
||||
|
||||
# Update the data
|
||||
updated_data = List.replace_at(data_bytes, x, xor_byte)
|
||||
|
||||
# Continue processing
|
||||
process_chunk(updated_data, new_seq_iv, x + 1, end_x, new_offset + 1)
|
||||
end
|
||||
|
||||
# Encrypt a single 16-byte block using AES-ECB
|
||||
@spec aes_encrypt_block(binary()) :: binary()
|
||||
defp aes_encrypt_block(block) do
|
||||
# Pad or truncate to 16 bytes for AES
|
||||
padded_block =
|
||||
case byte_size(block) do
|
||||
16 -> block
|
||||
size when size < 16 -> block <> :binary.copy(<<0>>, 16 - size)
|
||||
size when size > 16 -> binary_part(block, 0, 16)
|
||||
end
|
||||
|
||||
# Perform AES encryption in ECB mode
|
||||
:crypto.crypto_one_time(:aes_128_ecb, @aes_key, padded_block, true)
|
||||
end
|
||||
|
||||
# Multiply bytes - repeats the first `count` bytes of `input` `mul` times
|
||||
@spec multiply_bytes(binary(), non_neg_integer(), non_neg_integer()) :: binary()
|
||||
defp multiply_bytes(input, count, mul) do
|
||||
# Take first `count` bytes and repeat them `mul` times
|
||||
chunk = binary_part(input, 0, min(count, byte_size(input)))
|
||||
:binary.copy(chunk, mul)
|
||||
end
|
||||
end
|
||||
203
lib/odinsea/net/cipher/client_crypto.ex
Normal file
203
lib/odinsea/net/cipher/client_crypto.ex
Normal file
@@ -0,0 +1,203 @@
|
||||
defmodule Odinsea.Net.Cipher.ClientCrypto do
|
||||
@moduledoc """
|
||||
Client cryptography coordinator for MapleStory packet encryption.
|
||||
Manages send/recv IVs, packet encryption/decryption, and header encoding/decoding.
|
||||
|
||||
Ported from: src/handling/netty/ClientCrypto.java
|
||||
"""
|
||||
|
||||
use Bitwise
|
||||
|
||||
alias Odinsea.Net.Cipher.{AESCipher, IGCipher}
|
||||
|
||||
defstruct [
|
||||
:version,
|
||||
:use_custom_crypt,
|
||||
:send_iv,
|
||||
:send_iv_old,
|
||||
:recv_iv,
|
||||
:recv_iv_old
|
||||
]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
version: integer(),
|
||||
use_custom_crypt: boolean(),
|
||||
send_iv: binary(),
|
||||
send_iv_old: binary(),
|
||||
recv_iv: binary(),
|
||||
recv_iv_old: binary()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Creates a new ClientCrypto instance with random IVs.
|
||||
|
||||
## Parameters
|
||||
- version: MapleStory version number (e.g., 342)
|
||||
- use_custom_crypt: If false, uses AES encryption. If true, uses basic XOR with 0x69
|
||||
|
||||
## Returns
|
||||
- New ClientCrypto struct
|
||||
"""
|
||||
@spec new(integer(), boolean()) :: t()
|
||||
def new(version, use_custom_crypt \\ false) do
|
||||
%__MODULE__{
|
||||
version: version,
|
||||
use_custom_crypt: use_custom_crypt,
|
||||
send_iv: :crypto.strong_rand_bytes(4),
|
||||
send_iv_old: <<0, 0, 0, 0>>,
|
||||
recv_iv: :crypto.strong_rand_bytes(4),
|
||||
recv_iv_old: <<0, 0, 0, 0>>
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encrypts outgoing packet data and updates the send IV.
|
||||
|
||||
## Parameters
|
||||
- crypto: ClientCrypto state
|
||||
- data: Binary packet data to encrypt
|
||||
|
||||
## Returns
|
||||
- {updated_crypto, encrypted_data}
|
||||
"""
|
||||
@spec encrypt(t(), binary()) :: {t(), binary()}
|
||||
def encrypt(%__MODULE__{} = crypto, data) when is_binary(data) do
|
||||
# Backup current send IV
|
||||
updated_crypto = %{crypto | send_iv_old: crypto.send_iv}
|
||||
|
||||
# Encrypt the data
|
||||
encrypted_data =
|
||||
if crypto.use_custom_crypt do
|
||||
basic_cipher(data)
|
||||
else
|
||||
AESCipher.crypt(data, crypto.send_iv)
|
||||
end
|
||||
|
||||
# Update the send IV using InnoGames hash
|
||||
new_send_iv = IGCipher.inno_hash(crypto.send_iv)
|
||||
final_crypto = %{updated_crypto | send_iv: new_send_iv}
|
||||
|
||||
{final_crypto, encrypted_data}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decrypts incoming packet data and updates the recv IV.
|
||||
|
||||
## Parameters
|
||||
- crypto: ClientCrypto state
|
||||
- data: Binary packet data to decrypt
|
||||
|
||||
## Returns
|
||||
- {updated_crypto, decrypted_data}
|
||||
"""
|
||||
@spec decrypt(t(), binary()) :: {t(), binary()}
|
||||
def decrypt(%__MODULE__{} = crypto, data) when is_binary(data) do
|
||||
# Backup current recv IV
|
||||
updated_crypto = %{crypto | recv_iv_old: crypto.recv_iv}
|
||||
|
||||
# Decrypt the data
|
||||
decrypted_data =
|
||||
if crypto.use_custom_crypt do
|
||||
basic_cipher(data)
|
||||
else
|
||||
AESCipher.crypt(data, crypto.recv_iv)
|
||||
end
|
||||
|
||||
# Update the recv IV using InnoGames hash
|
||||
new_recv_iv = IGCipher.inno_hash(crypto.recv_iv)
|
||||
final_crypto = %{updated_crypto | recv_iv: new_recv_iv}
|
||||
|
||||
{final_crypto, decrypted_data}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes the packet header (4 bytes) for outgoing packets.
|
||||
Returns the raw header bytes that prefix the encrypted packet.
|
||||
|
||||
## Parameters
|
||||
- crypto: ClientCrypto state
|
||||
- data_len: Length of the packet data (excluding header)
|
||||
|
||||
## Returns
|
||||
- 4-byte binary header
|
||||
"""
|
||||
@spec encode_header_len(t(), non_neg_integer()) :: binary()
|
||||
def encode_header_len(%__MODULE__{} = crypto, data_len) do
|
||||
<<s0, s1, s2, s3>> = crypto.send_iv
|
||||
|
||||
# Calculate the encoded version
|
||||
new_version = -(crypto.version + 1) &&& 0xFFFF
|
||||
enc_version = (((new_version >>> 8) &&& 0xFF) ||| ((new_version <<< 8) &&& 0xFF00)) &&& 0xFFFF
|
||||
|
||||
# Calculate raw sequence from send IV
|
||||
raw_seq = bxor((((s3 &&& 0xFF) ||| ((s2 <<< 8) &&& 0xFF00)) &&& 0xFFFF), enc_version)
|
||||
|
||||
# Calculate raw length
|
||||
raw_len = (((data_len <<< 8) &&& 0xFF00) ||| (data_len >>> 8)) &&& 0xFFFF
|
||||
raw_len_encoded = bxor(raw_len, raw_seq)
|
||||
|
||||
# Encode as 4 bytes
|
||||
<<
|
||||
(raw_seq >>> 8) &&& 0xFF,
|
||||
raw_seq &&& 0xFF,
|
||||
(raw_len_encoded >>> 8) &&& 0xFF,
|
||||
raw_len_encoded &&& 0xFF
|
||||
>>
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decodes the packet header to extract the data length.
|
||||
|
||||
## Parameters
|
||||
- raw_seq: 16-bit sequence number from header
|
||||
- raw_len: 16-bit length field from header
|
||||
|
||||
## Returns
|
||||
- Decoded packet length
|
||||
"""
|
||||
@spec decode_header_len(integer(), integer()) :: integer()
|
||||
def decode_header_len(raw_seq, raw_len) do
|
||||
bxor(raw_seq, raw_len) &&& 0xFFFF
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates that the incoming packet header is correct for this connection.
|
||||
|
||||
## Parameters
|
||||
- crypto: ClientCrypto state
|
||||
- raw_seq: 16-bit sequence number from header
|
||||
|
||||
## Returns
|
||||
- true if valid, false otherwise
|
||||
"""
|
||||
@spec decode_header_valid?(t(), integer()) :: boolean()
|
||||
def decode_header_valid?(%__MODULE__{} = crypto, raw_seq) do
|
||||
<<_r0, _r1, r2, r3>> = crypto.recv_iv
|
||||
|
||||
enc_version = crypto.version &&& 0xFFFF
|
||||
seq_base = ((r2 &&& 0xFF) ||| ((r3 <<< 8) &&& 0xFF00)) &&& 0xFFFF
|
||||
|
||||
bxor((raw_seq &&& 0xFFFF), seq_base) == enc_version
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the current send IV (for handshake).
|
||||
"""
|
||||
@spec get_send_iv(t()) :: binary()
|
||||
def get_send_iv(%__MODULE__{} = crypto), do: crypto.send_iv
|
||||
|
||||
@doc """
|
||||
Gets the current recv IV (for handshake).
|
||||
"""
|
||||
@spec get_recv_iv(t()) :: binary()
|
||||
def get_recv_iv(%__MODULE__{} = crypto), do: crypto.recv_iv
|
||||
|
||||
# Basic XOR cipher with 0x69 (fallback when custom_crypt is enabled)
|
||||
@spec basic_cipher(binary()) :: binary()
|
||||
defp basic_cipher(data) do
|
||||
data
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map(fn byte -> Bitwise.bxor(byte, 0x69) end)
|
||||
|> :binary.list_to_bin()
|
||||
end
|
||||
end
|
||||
92
lib/odinsea/net/cipher/ig_cipher.ex
Normal file
92
lib/odinsea/net/cipher/ig_cipher.ex
Normal file
@@ -0,0 +1,92 @@
|
||||
defmodule Odinsea.Net.Cipher.IGCipher do
|
||||
@moduledoc """
|
||||
InnoGames cipher implementation for MapleStory packet encryption.
|
||||
Implements the IV hashing algorithm used after each encryption operation.
|
||||
|
||||
Ported from: src/handling/netty/cipher/IGCipher.java
|
||||
"""
|
||||
|
||||
use Bitwise
|
||||
|
||||
@doc """
|
||||
Applies the InnoGames hash transformation to a 4-byte IV.
|
||||
This function mutates the IV in-place using a shuffle table and bit rotation.
|
||||
|
||||
## Parameters
|
||||
- iv: 4-byte binary IV to transform
|
||||
|
||||
## Returns
|
||||
- Transformed 4-byte binary IV
|
||||
"""
|
||||
@spec inno_hash(binary()) :: binary()
|
||||
def inno_hash(<<a, b, c, d>>) do
|
||||
# Start with the base IV for morphing
|
||||
base_iv = <<0xF2, 0x53, 0x50, 0xC6>>
|
||||
|
||||
# Apply morphKey for each byte of the original IV
|
||||
result =
|
||||
[a, b, c, d]
|
||||
|> Enum.reduce(base_iv, fn value, acc ->
|
||||
morph_key(acc, value)
|
||||
end)
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
# Morph a 4-byte key using a single input byte value
|
||||
@spec morph_key(binary(), byte()) :: binary()
|
||||
defp morph_key(<<k0, k1, k2, k3>>, value) do
|
||||
input = value &&& 0xFF
|
||||
table = shuffle_byte(input)
|
||||
|
||||
# Apply the transformation operations
|
||||
new_k0 = k0 + shuffle_byte(k1) - input
|
||||
new_k1 = k1 - bxor(k2, table)
|
||||
new_k2 = bxor(k2, shuffle_byte(k3) + input)
|
||||
new_k3 = k3 - (k0 - table)
|
||||
|
||||
# Combine into 32-bit value (little-endian)
|
||||
val =
|
||||
(new_k0 &&& 0xFF) |||
|
||||
((new_k1 <<< 8) &&& 0xFF00) |||
|
||||
((new_k2 <<< 16) &&& 0xFF0000) |||
|
||||
((new_k3 <<< 24) &&& 0xFF000000)
|
||||
|
||||
# Rotate left by 3 bits
|
||||
rotated = ((val <<< 3) ||| (val >>> 29)) &&& 0xFFFFFFFF
|
||||
|
||||
# Extract bytes (little-endian)
|
||||
<<
|
||||
rotated &&& 0xFF,
|
||||
(rotated >>> 8) &&& 0xFF,
|
||||
(rotated >>> 16) &&& 0xFF,
|
||||
(rotated >>> 24) &&& 0xFF
|
||||
>>
|
||||
end
|
||||
|
||||
# Lookup a byte in the shuffle table
|
||||
@spec shuffle_byte(integer()) :: integer()
|
||||
defp shuffle_byte(index) do
|
||||
elem(@shuffle_table, index &&& 0xFF)
|
||||
end
|
||||
|
||||
# Shuffle table - 256 bytes used for IV transformation
|
||||
@shuffle_table {
|
||||
0xEC, 0x3F, 0x77, 0xA4, 0x45, 0xD0, 0x71, 0xBF, 0xB7, 0x98, 0x20, 0xFC, 0x4B, 0xE9, 0xB3, 0xE1,
|
||||
0x5C, 0x22, 0xF7, 0x0C, 0x44, 0x1B, 0x81, 0xBD, 0x63, 0x8D, 0xD4, 0xC3, 0xF2, 0x10, 0x19, 0xE0,
|
||||
0xFB, 0xA1, 0x6E, 0x66, 0xEA, 0xAE, 0xD6, 0xCE, 0x06, 0x18, 0x4E, 0xEB, 0x78, 0x95, 0xDB, 0xBA,
|
||||
0xB6, 0x42, 0x7A, 0x2A, 0x83, 0x0B, 0x54, 0x67, 0x6D, 0xE8, 0x65, 0xE7, 0x2F, 0x07, 0xF3, 0xAA,
|
||||
0x27, 0x7B, 0x85, 0xB0, 0x26, 0xFD, 0x8B, 0xA9, 0xFA, 0xBE, 0xA8, 0xD7, 0xCB, 0xCC, 0x92, 0xDA,
|
||||
0xF9, 0x93, 0x60, 0x2D, 0xDD, 0xD2, 0xA2, 0x9B, 0x39, 0x5F, 0x82, 0x21, 0x4C, 0x69, 0xF8, 0x31,
|
||||
0x87, 0xEE, 0x8E, 0xAD, 0x8C, 0x6A, 0xBC, 0xB5, 0x6B, 0x59, 0x13, 0xF1, 0x04, 0x00, 0xF6, 0x5A,
|
||||
0x35, 0x79, 0x48, 0x8F, 0x15, 0xCD, 0x97, 0x57, 0x12, 0x3E, 0x37, 0xFF, 0x9D, 0x4F, 0x51, 0xF5,
|
||||
0xA3, 0x70, 0xBB, 0x14, 0x75, 0xC2, 0xB8, 0x72, 0xC0, 0xED, 0x7D, 0x68, 0xC9, 0x2E, 0x0D, 0x62,
|
||||
0x46, 0x17, 0x11, 0x4D, 0x6C, 0xC4, 0x7E, 0x53, 0xC1, 0x25, 0xC7, 0x9A, 0x1C, 0x88, 0x58, 0x2C,
|
||||
0x89, 0xDC, 0x02, 0x64, 0x40, 0x01, 0x5D, 0x38, 0xA5, 0xE2, 0xAF, 0x55, 0xD5, 0xEF, 0x1A, 0x7C,
|
||||
0xA7, 0x5B, 0xA6, 0x6F, 0x86, 0x9F, 0x73, 0xE6, 0x0A, 0xDE, 0x2B, 0x99, 0x4A, 0x47, 0x9C, 0xDF,
|
||||
0x09, 0x76, 0x9E, 0x30, 0x0E, 0xE4, 0xB2, 0x94, 0xA0, 0x3B, 0x34, 0x1D, 0x28, 0x0F, 0x36, 0xE3,
|
||||
0x23, 0xB4, 0x03, 0xD8, 0x90, 0xC8, 0x3C, 0xFE, 0x5E, 0x32, 0x24, 0x50, 0x1F, 0x3A, 0x43, 0x8A,
|
||||
0x96, 0x41, 0x74, 0xAC, 0x52, 0x33, 0xF0, 0xD9, 0x29, 0x80, 0xB1, 0x16, 0xD3, 0xAB, 0x91, 0xB9,
|
||||
0x84, 0x7F, 0x61, 0x1E, 0xCF, 0xC5, 0xD1, 0x56, 0x3D, 0xCA, 0xF4, 0x05, 0xC6, 0xE5, 0x08, 0x49
|
||||
}
|
||||
end
|
||||
231
lib/odinsea/net/cipher/login_crypto.ex
Normal file
231
lib/odinsea/net/cipher/login_crypto.ex
Normal file
@@ -0,0 +1,231 @@
|
||||
defmodule Odinsea.Net.Cipher.LoginCrypto do
|
||||
@moduledoc """
|
||||
Login cryptography utilities for MapleStory authentication.
|
||||
Provides password hashing (SHA-1, SHA-512 with salt) and RSA decryption.
|
||||
|
||||
Ported from: src/client/LoginCrypto.java
|
||||
"""
|
||||
|
||||
@extra_length 6
|
||||
|
||||
@doc """
|
||||
Generates a 13-digit Asiasoft passport string.
|
||||
Format: Letter + 11 digits + Letter
|
||||
|
||||
## Returns
|
||||
- 13-character string (e.g., "A12345678901B")
|
||||
"""
|
||||
@spec generate_13digit_asiasoft_passport() :: String.t()
|
||||
def generate_13digit_asiasoft_passport do
|
||||
alphabet = ~w(A B C D E F G H I J K L M N O P Q R S T U V W X Y Z)
|
||||
numbers = ~w(1 2 3 4 5 6 7 8 9)
|
||||
|
||||
# First letter
|
||||
first = Enum.random(alphabet)
|
||||
|
||||
# 11 digits
|
||||
middle =
|
||||
1..11
|
||||
|> Enum.map(fn _ -> Enum.random(numbers) end)
|
||||
|> Enum.join()
|
||||
|
||||
# Last letter
|
||||
last = Enum.random(alphabet)
|
||||
|
||||
"#{first}#{middle}#{last}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Hashes a password using SHA-1.
|
||||
|
||||
## Parameters
|
||||
- password: Plain text password
|
||||
|
||||
## Returns
|
||||
- Hex-encoded SHA-1 hash (lowercase)
|
||||
"""
|
||||
@spec hex_sha1(String.t()) :: String.t()
|
||||
def hex_sha1(password) when is_binary(password) do
|
||||
hash_with_digest(password, :sha)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Hashes a password using SHA-512 with a salt.
|
||||
|
||||
## Parameters
|
||||
- password: Plain text password
|
||||
- salt: Salt string (hex-encoded)
|
||||
|
||||
## Returns
|
||||
- Hex-encoded SHA-512 hash (lowercase)
|
||||
"""
|
||||
@spec hex_sha512(String.t(), String.t()) :: String.t()
|
||||
def hex_sha512(password, salt) when is_binary(password) and is_binary(salt) do
|
||||
hash_with_digest(password <> salt, :sha512)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a SHA-1 hash matches a password.
|
||||
|
||||
## Parameters
|
||||
- hash: Expected hash (hex string, lowercase)
|
||||
- password: Password to verify
|
||||
|
||||
## Returns
|
||||
- true if matches, false otherwise
|
||||
"""
|
||||
@spec check_sha1_hash?(String.t(), String.t()) :: boolean()
|
||||
def check_sha1_hash?(hash, password) do
|
||||
hash == hex_sha1(password)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a salted SHA-512 hash matches a password.
|
||||
|
||||
## Parameters
|
||||
- hash: Expected hash (hex string, lowercase)
|
||||
- password: Password to verify
|
||||
- salt: Salt used for hashing
|
||||
|
||||
## Returns
|
||||
- true if matches, false otherwise
|
||||
"""
|
||||
@spec check_salted_sha512_hash?(String.t(), String.t(), String.t()) :: boolean()
|
||||
def check_salted_sha512_hash?(hash, password, salt) do
|
||||
hash == hex_sha512(password, salt)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Verifies a salted SHA-512 hash against a password.
|
||||
Returns {:ok, password} on match, {:error, :invalid} on mismatch.
|
||||
|
||||
## Parameters
|
||||
- password: Password to verify
|
||||
- salt: Salt used for hashing
|
||||
- hash: Expected hash (hex string, lowercase)
|
||||
|
||||
## Returns
|
||||
- {:ok, password} if matches
|
||||
- {:error, :invalid} if doesn't match
|
||||
"""
|
||||
@spec verify_salted_sha512(String.t(), String.t(), String.t()) :: {:ok, String.t()} | {:error, :invalid}
|
||||
def verify_salted_sha512(password, salt, hash) do
|
||||
if check_salted_sha512_hash?(hash, password, salt) do
|
||||
{:ok, password}
|
||||
else
|
||||
{:error, :invalid}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generates a random 16-byte salt.
|
||||
|
||||
## Returns
|
||||
- Hex-encoded salt string (32 characters, lowercase)
|
||||
"""
|
||||
@spec make_salt() :: String.t()
|
||||
def make_salt do
|
||||
:crypto.strong_rand_bytes(16)
|
||||
|> Base.encode16(case: :lower)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds random prefix to a string (used for RSA password encoding).
|
||||
|
||||
## Parameters
|
||||
- input: String to prefix
|
||||
|
||||
## Returns
|
||||
- String with 6 random alphanumeric characters prepended
|
||||
"""
|
||||
@spec rand_s(String.t()) :: String.t()
|
||||
def rand_s(input) when is_binary(input) do
|
||||
alphabet = ~w(A B C D E F G H I J K L M N O P Q R S T U V W X Y Z)
|
||||
numbers = ~w(1 2 3 4 5 6 7 8 9)
|
||||
chars = alphabet ++ numbers
|
||||
|
||||
prefix =
|
||||
1..@extra_length
|
||||
|> Enum.map(fn _ -> Enum.random(chars) end)
|
||||
|> Enum.join()
|
||||
|
||||
prefix <> input
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes random prefix from a string (used for RSA password decoding).
|
||||
Extracts 128 characters starting from position 6.
|
||||
|
||||
## Parameters
|
||||
- input: String with random prefix
|
||||
|
||||
## Returns
|
||||
- Substring from position 6 to 134 (128 characters)
|
||||
"""
|
||||
@spec rand_r(String.t()) :: String.t()
|
||||
def rand_r(input) when is_binary(input) do
|
||||
String.slice(input, @extra_length, 128)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decrypts an RSA-encrypted password using the hardcoded private key.
|
||||
Uses RSA/NONE/OAEPPadding with the server's private key.
|
||||
|
||||
## Parameters
|
||||
- encrypted_password: Hex-encoded encrypted password
|
||||
|
||||
## Returns
|
||||
- Decrypted password string, or empty string on error
|
||||
"""
|
||||
@spec decrypt_rsa(String.t()) :: String.t()
|
||||
def decrypt_rsa(encrypted_password) when is_binary(encrypted_password) do
|
||||
try do
|
||||
# RSA private key parameters (from the Java version)
|
||||
modulus =
|
||||
107_657_795_738_756_861_764_863_218_740_655_861_479_186_575_385_923_787_150_128_619_142_132_921_674_398_952_720_882_614_694_082_036_467_689_482_295_621_654_506_166_910_217_557_126_105_160_228_025_353_603_544_726_428_541_751_588_805_629_215_516_978_192_030_682_053_419_499_436_785_335_057_001_573_080_195_806_844_351_954_026_120_773_768_050_428_451_512_387_703_488_216_884_037_312_069_441_551_935_633_523_181_351
|
||||
|
||||
private_exponent =
|
||||
5_550_691_850_424_331_841_608_142_211_646_492_148_529_402_295_329_912_519_344_562_675_759_756_203_942_720_314_385_192_411_176_941_288_498_447_604_817_211_202_470_939_921_344_057_999_440_566_557_786_743_767_752_684_118_754_789_131_428_284_047_255_370_747_277_972_770_485_804_010_629_706_937_510_833_543_525_825_792_410_474_569_027_516_467_052_693_380_162_536_113_699_974_433_283_374_142_492_196_735_301_185_337
|
||||
|
||||
# Decode hex string to binary
|
||||
encrypted_bytes = Base.decode16!(encrypted_password, case: :mixed)
|
||||
|
||||
# Create RSA private key
|
||||
rsa_key = [modulus, private_exponent]
|
||||
|
||||
# Decrypt using RSA with OAEP padding
|
||||
# Note: Elixir's :public_key module uses a different format
|
||||
# We need to construct the key properly
|
||||
private_key = {
|
||||
:RSAPrivateKey,
|
||||
:"two-prime",
|
||||
modulus,
|
||||
65537,
|
||||
private_exponent,
|
||||
# These are placeholders - full key derivation needed for production
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
:asn1_NOVALUE
|
||||
}
|
||||
|
||||
decrypted = :public_key.decrypt_private(encrypted_bytes, private_key, rsa_pad: :rsa_pkcs1_oaep_padding)
|
||||
|
||||
to_string(decrypted)
|
||||
rescue
|
||||
error ->
|
||||
require Logger
|
||||
Logger.error("RSA decryption failed: #{inspect(error)}")
|
||||
""
|
||||
end
|
||||
end
|
||||
|
||||
# Private helper: hash a string with a given digest algorithm
|
||||
@spec hash_with_digest(String.t(), atom()) :: String.t()
|
||||
defp hash_with_digest(input, digest) do
|
||||
:crypto.hash(digest, input)
|
||||
|> Base.encode16(case: :lower)
|
||||
end
|
||||
end
|
||||
171
lib/odinsea/net/hex.ex
Normal file
171
lib/odinsea/net/hex.ex
Normal file
@@ -0,0 +1,171 @@
|
||||
defmodule Odinsea.Net.Hex do
|
||||
@moduledoc """
|
||||
Hex encoding/decoding utilities ported from Java HexTool.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Converts a binary to a hex string (space-separated bytes).
|
||||
|
||||
## Examples
|
||||
iex> Odinsea.Net.Hex.encode(<<0x01, 0xAB, 0xFF>>)
|
||||
"01 AB FF"
|
||||
"""
|
||||
@spec encode(binary()) :: String.t()
|
||||
def encode(<<>>), do: ""
|
||||
|
||||
def encode(binary) when is_binary(binary) do
|
||||
binary
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map(&byte_to_hex/1)
|
||||
|> Enum.join(" ")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts a binary to a hex string (no spaces).
|
||||
|
||||
## Examples
|
||||
iex> Odinsea.Net.Hex.to_string_compact(<<0x01, 0xAB, 0xFF>>)
|
||||
"01ABFF"
|
||||
"""
|
||||
@spec to_string_compact(binary()) :: String.t()
|
||||
def to_string_compact(<<>>), do: ""
|
||||
|
||||
def to_string_compact(binary) when is_binary(binary) do
|
||||
binary
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map(&byte_to_hex/1)
|
||||
|> Enum.join()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts a binary to a hex string with ASCII representation.
|
||||
|
||||
## Examples
|
||||
iex> Odinsea.Net.Hex.to_pretty_string(<<0x48, 0x65, 0x6C, 0x6C, 0x6F>>)
|
||||
"48 65 6C 6C 6F | Hello"
|
||||
"""
|
||||
@spec to_pretty_string(binary()) :: String.t()
|
||||
def to_pretty_string(binary) when is_binary(binary) do
|
||||
hex_part = encode(binary)
|
||||
ascii_part = to_ascii(binary)
|
||||
"#{hex_part} | #{ascii_part}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts a hex string to binary.
|
||||
|
||||
## Examples
|
||||
iex> Odinsea.Net.Hex.from_string("01 AB FF")
|
||||
<<0x01, 0xAB, 0xFF>>
|
||||
"""
|
||||
@spec from_string(String.t()) :: binary()
|
||||
def from_string(hex_string) when is_binary(hex_string) do
|
||||
hex_string
|
||||
|> String.replace(~r/\s+/, "")
|
||||
|> String.downcase()
|
||||
|> do_from_string()
|
||||
end
|
||||
|
||||
defp do_from_string(<<>>), do: <<>>
|
||||
|
||||
defp do_from_string(<<hex1, hex2, rest::binary>>) do
|
||||
byte = hex_to_byte(<<hex1, hex2>>)
|
||||
<<byte, do_from_string(rest)::binary>>
|
||||
end
|
||||
|
||||
defp do_from_string(<<_>>), do: raise(ArgumentError, "Invalid hex string length")
|
||||
|
||||
@doc """
|
||||
Converts a byte to its 2-character hex representation.
|
||||
"""
|
||||
@spec byte_to_hex(byte()) :: String.t()
|
||||
def byte_to_hex(byte) when is_integer(byte) and byte >= 0 and byte <= 255 do
|
||||
byte
|
||||
|> Integer.to_string(16)
|
||||
|> String.pad_leading(2, "0")
|
||||
|> String.upcase()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts a 2-character hex string to a byte.
|
||||
"""
|
||||
@spec hex_to_byte(String.t()) :: byte()
|
||||
def hex_to_byte(<<hex1, hex2>>) do
|
||||
parse_hex_digit(hex1) * 16 + parse_hex_digit(hex2)
|
||||
end
|
||||
|
||||
defp parse_hex_digit(?0), do: 0
|
||||
defp parse_hex_digit(?1), do: 1
|
||||
defp parse_hex_digit(?2), do: 2
|
||||
defp parse_hex_digit(?3), do: 3
|
||||
defp parse_hex_digit(?4), do: 4
|
||||
defp parse_hex_digit(?5), do: 5
|
||||
defp parse_hex_digit(?6), do: 6
|
||||
defp parse_hex_digit(?7), do: 7
|
||||
defp parse_hex_digit(?8), do: 8
|
||||
defp parse_hex_digit(?9), do: 9
|
||||
defp parse_hex_digit(?a), do: 10
|
||||
defp parse_hex_digit(?b), do: 11
|
||||
defp parse_hex_digit(?c), do: 12
|
||||
defp parse_hex_digit(?d), do: 13
|
||||
defp parse_hex_digit(?e), do: 14
|
||||
defp parse_hex_digit(?f), do: 15
|
||||
defp parse_hex_digit(_), do: raise(ArgumentError, "Invalid hex digit")
|
||||
|
||||
@doc """
|
||||
Converts a binary to its ASCII representation (non-printable chars as dots).
|
||||
"""
|
||||
@spec to_ascii(binary()) :: String.t()
|
||||
def to_ascii(<<>>), do: ""
|
||||
|
||||
def to_ascii(<<byte, rest::binary>>) do
|
||||
char = if byte >= 32 and byte <= 126, do: <<byte>>, else: <<".">>
|
||||
char <> to_ascii(rest)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts a short (2 bytes) to a hex string.
|
||||
"""
|
||||
@spec short_to_hex(integer()) :: String.t()
|
||||
def short_to_hex(value) when is_integer(value) do
|
||||
<<value::signed-integer-little-16>>
|
||||
|> encode()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts an int (4 bytes) to a hex string.
|
||||
"""
|
||||
@spec int_to_hex(integer()) :: String.t()
|
||||
def int_to_hex(value) when is_integer(value) do
|
||||
<<value::signed-integer-little-32>>
|
||||
|> encode()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Formats a binary as a hex dump with offsets.
|
||||
"""
|
||||
@spec hex_dump(binary(), non_neg_integer()) :: String.t()
|
||||
def hex_dump(binary, offset \\ 0) do
|
||||
binary
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.chunk_every(16)
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {chunk, idx} ->
|
||||
offset_str = Integer.to_string(offset + idx * 16, 16) |> String.pad_leading(8, "0")
|
||||
hex_str = format_chunk(chunk)
|
||||
ascii_str = to_ascii(:binary.list_to_bin(chunk))
|
||||
"#{offset_str} #{hex_str} |#{ascii_str}|"
|
||||
end)
|
||||
|> Enum.join("\n")
|
||||
end
|
||||
|
||||
defp format_chunk(chunk) do
|
||||
chunk
|
||||
|> Enum.map(&byte_to_hex/1)
|
||||
|> Enum.map(&String.pad_trailing(&1, 2))
|
||||
|> Enum.chunk_every(8)
|
||||
|> Enum.map(&Enum.join(&1, " "))
|
||||
|> Enum.join(" ")
|
||||
|> String.pad_trailing(48)
|
||||
end
|
||||
end
|
||||
524
lib/odinsea/net/opcodes.ex
Normal file
524
lib/odinsea/net/opcodes.ex
Normal file
@@ -0,0 +1,524 @@
|
||||
defmodule Odinsea.Net.Opcodes do
|
||||
@moduledoc """
|
||||
Packet opcodes for MapleStory GMS v342.
|
||||
Ported from Java ClientPacket and LoopbackPacket.
|
||||
"""
|
||||
|
||||
# Define the macro for creating opcodes
|
||||
defmacro defopcode(name, value) do
|
||||
quote do
|
||||
@doc """
|
||||
Opcode for `#{unquote(name)}`.
|
||||
"""
|
||||
def unquote(name)(), do: unquote(value)
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Client → Server (Recv Opcodes)
|
||||
# ==================================================================================================
|
||||
|
||||
# Login/Account
|
||||
def cp_client_hello(), do: 0x01
|
||||
def cp_check_password(), do: 0x02
|
||||
def cp_world_info_request(), do: 0x04
|
||||
def cp_select_world(), do: 0x05
|
||||
def cp_check_user_limit(), do: 0x06
|
||||
def cp_check_duplicated_id(), do: 0x0E
|
||||
def cp_create_new_character(), do: 0x12
|
||||
def cp_create_ultimate(), do: 0x14
|
||||
def cp_delete_character(), do: 0x15
|
||||
def cp_select_character(), do: 0x19
|
||||
def cp_check_spw_request(), do: 0x1A
|
||||
def cp_rsa_key(), do: 0x20
|
||||
|
||||
# Connection/Security
|
||||
def cp_alive_ack(), do: 0x16
|
||||
def cp_exception_log(), do: 0x17
|
||||
def cp_security_packet(), do: 0x18
|
||||
def cp_client_dump_log(), do: 0x1D
|
||||
def cp_create_security_handle(), do: 0x1E
|
||||
def cp_hardware_info(), do: 0x21
|
||||
def cp_set_code_page(), do: 0x22
|
||||
def cp_window_focus(), do: 0x23
|
||||
def cp_inject_packet(), do: 0x24
|
||||
|
||||
# Migration/Channel
|
||||
def cp_migrate_in(), do: 0x0D
|
||||
def cp_change_channel(), do: 0x25
|
||||
def cp_enter_cash_shop(), do: 0x26
|
||||
def cp_enter_mts(), do: 0x27
|
||||
|
||||
# PVP
|
||||
def cp_enter_pvp(), do: 0x28
|
||||
def cp_enter_pvp_party(), do: 0x29
|
||||
def cp_leave_pvp(), do: 0x2A
|
||||
def cp_pvp_attack(), do: 0x2B
|
||||
def cp_pvp_respawn(), do: 0x2C
|
||||
|
||||
# Player
|
||||
def cp_move_player(), do: 0x2D
|
||||
def cp_cancel_chair(), do: 0x2F
|
||||
def cp_use_chair(), do: 0x30
|
||||
def cp_close_range_attack(), do: 0x32
|
||||
def cp_ranged_attack(), do: 0x33
|
||||
def cp_magic_attack(), do: 0x34
|
||||
def cp_passive_energy(), do: 0x35
|
||||
def cp_take_damage(), do: 0x37
|
||||
def cp_general_chat(), do: 0x39
|
||||
def cp_close_chalkboard(), do: 0x3A
|
||||
def cp_face_expression(), do: 0x3B
|
||||
def cp_face_android(), do: 0x3C
|
||||
def cp_use_item_effect(), do: 0x3D
|
||||
def cp_wheel_of_fortune(), do: 0x3E
|
||||
def cp_char_info_request(), do: 0x78
|
||||
def cp_change_keymap(), do: 0x87
|
||||
def cp_skill_macro(), do: 0x89
|
||||
def cp_change_quickslot(), do: 0x8A
|
||||
|
||||
# Movement
|
||||
def cp_change_map(), do: 0x31
|
||||
def cp_change_map_special(), do: 0x38
|
||||
def cp_use_inner_portal(), do: 0x3A
|
||||
def cp_trock_add_map(), do: 0x3B
|
||||
def cp_use_tele_rock(), do: 0x6A
|
||||
|
||||
# Combat
|
||||
def cp_distribute_ap(), do: 0x68
|
||||
def cp_auto_assign_ap(), do: 0x69
|
||||
def cp_distribute_sp(), do: 0x6C
|
||||
def cp_special_move(), do: 0x6D
|
||||
def cp_cancel_buff(), do: 0x6E
|
||||
def cp_skill_effect(), do: 0x6F
|
||||
def cp_heal_over_time(), do: 0x6A
|
||||
def cp_cancel_debuff(), do: 0x70
|
||||
def cp_give_fame(), do: 0x74
|
||||
|
||||
# Items/Inventory
|
||||
def cp_item_sort(), do: 0x4F
|
||||
def cp_item_gather(), do: 0x50
|
||||
def cp_item_move(), do: 0x51
|
||||
def cp_move_bag(), do: 0x52
|
||||
def cp_switch_bag(), do: 0x53
|
||||
def cp_use_item(), do: 0x55
|
||||
def cp_cancel_item_effect(), do: 0x56
|
||||
def cp_use_summon_bag(), do: 0x58
|
||||
def cp_pet_food(), do: 0x59
|
||||
def cp_use_mount_food(), do: 0x5A
|
||||
def cp_use_scripted_npc_item(), do: 0x5B
|
||||
def cp_use_recipe(), do: 0x5C
|
||||
def cp_use_cash_item(), do: 0x5D
|
||||
def cp_use_catch_item(), do: 0x5F
|
||||
def cp_use_skill_book(), do: 0x60
|
||||
def cp_use_owl_minerva(), do: 0x62
|
||||
def cp_use_return_scroll(), do: 0x64
|
||||
def cp_use_upgrade_scroll(), do: 0x65
|
||||
def cp_use_flag_scroll(), do: 0x66
|
||||
def cp_use_equip_scroll(), do: 0x67
|
||||
def cp_use_potential_scroll(), do: 0x68
|
||||
def cp_use_bag(), do: 0x6A
|
||||
def cp_use_magnify_glass(), do: 0x6B
|
||||
def cp_reward_item(), do: 0x8B
|
||||
def cp_item_maker(), do: 0x8C
|
||||
def cp_repair(), do: 0x8E
|
||||
def cp_repair_all(), do: 0x8D
|
||||
def cp_meso_drop(), do: 0x73
|
||||
def cp_item_pickup(), do: 0x54
|
||||
|
||||
# NPC (NOTE: These should match recvops.properties from Java server)
|
||||
def cp_npc_talk(), do: 0x40
|
||||
def cp_npc_talk_more(), do: 0x42
|
||||
def cp_npc_shop(), do: 0x43
|
||||
def cp_storage(), do: 0x44
|
||||
def cp_use_hired_merchant(), do: 0x45
|
||||
def cp_merch_item_store(), do: 0x47
|
||||
def cp_duey_action(), do: 0x48
|
||||
def cp_quest_action(), do: 0x81
|
||||
def cp_npc_move(), do: 0x41
|
||||
def cp_use_scripted_npc_item(), do: 0x59
|
||||
def cp_repair(), do: 0x89
|
||||
def cp_repair_all(), do: 0x88
|
||||
def cp_update_quest(), do: 0x142
|
||||
def cp_use_item_quest(), do: 0x144
|
||||
def cp_public_npc(), do: 0x9E
|
||||
|
||||
# Shop/Merchant
|
||||
def cp_buy_cs_item(), do: 0xB6
|
||||
def cp_coupon_code(), do: 0xB7
|
||||
def cp_cs_update(), do: 0xB8
|
||||
|
||||
# MTS
|
||||
def cp_touching_mts(), do: 0xB9
|
||||
def cp_mts_tab(), do: 0xBA
|
||||
|
||||
# Social
|
||||
def cp_party_operation(), do: 0x91
|
||||
def cp_deny_party_request(), do: 0x92
|
||||
def cp_allow_party_invite(), do: 0x93
|
||||
def cp_expedition_operation(), do: 0x94
|
||||
def cp_party_search_start(), do: 0x95
|
||||
def cp_party_search_stop(), do: 0x96
|
||||
def cp_guild_operation(), do: 0x97
|
||||
def cp_deny_guild_request(), do: 0x98
|
||||
def cp_alliance_operation(), do: 0x99
|
||||
def cp_deny_alliance_request(), do: 0x9A
|
||||
def cp_buddylist_modify(), do: 0x9B
|
||||
def cp_messenger(), do: 0x9C
|
||||
def cp_whisper(), do: 0x9D
|
||||
def cp_party_chat(), do: 0x3F
|
||||
def cp_family_operation(), do: 0x9F
|
||||
def cp_delete_junior(), do: 0xA0
|
||||
def cp_delete_senior(), do: 0xA1
|
||||
def cp_use_family(), do: 0xA2
|
||||
def cp_family_precept(), do: 0xA3
|
||||
def cp_family_summon(), do: 0xA4
|
||||
def cp_accept_family(), do: 0xA5
|
||||
def cp_request_family(), do: 0x9E
|
||||
|
||||
# Monster
|
||||
def cp_mob_move(), do: 0xA6
|
||||
def cp_mob_apply_ctrl(), do: 0xA7
|
||||
def cp_mob_hit_by_mob(), do: 0xA9
|
||||
def cp_mob_self_destruct(), do: 0xAA
|
||||
def cp_mob_time_bomb_end(), do: 0xAB
|
||||
def cp_mob_area_attack_disease(), do: 0xAC
|
||||
def cp_mob_attack_mob(), do: 0xAD
|
||||
def cp_mob_escort_collision(), do: 0xAE
|
||||
def cp_mob_request_escort_info(), do: 0xAF
|
||||
def cp_mob_skill_delay_end(), do: 0xA8
|
||||
|
||||
# NPC/Mob Interaction
|
||||
def cp_damage_reactor(), do: 0xC1
|
||||
def cp_touch_reactor(), do: 0xC2
|
||||
def cp_click_reactor(), do: 0xC2
|
||||
def cp_public_npc(), do: 0x9A
|
||||
|
||||
# Summons
|
||||
def cp_summon_attack(), do: 0xC6
|
||||
def cp_move_summon(), do: 0xC4
|
||||
def cp_damage_summon(), do: 0xC7
|
||||
def cp_sub_summon(), do: 0xC8
|
||||
def cp_remove_summon(), do: 0xC9
|
||||
def cp_move_dragon(), do: 0xCA
|
||||
|
||||
# Pets
|
||||
def cp_spawn_pet(), do: 0x79
|
||||
def cp_pet_move(), do: 0xCB
|
||||
def cp_pet_chat(), do: 0xCC
|
||||
def cp_pet_command(), do: 0xCD
|
||||
def cp_pet_drop_pickup_request(), do: 0xCE
|
||||
def cp_pet_auto_pot(), do: 0xCF
|
||||
|
||||
# Android
|
||||
def cp_move_android(), do: 0x40
|
||||
|
||||
# Familiar
|
||||
def cp_use_familiar(), do: 0x61
|
||||
def cp_spawn_familiar(), do: 0xD0
|
||||
def cp_rename_familiar(), do: 0xD1
|
||||
def cp_move_familiar(), do: 0xD2
|
||||
def cp_attack_familiar(), do: 0xD3
|
||||
def cp_touch_familiar(), do: 0xD4
|
||||
|
||||
# Events/Games
|
||||
def cp_monster_carnival(), do: 0xD5
|
||||
def cp_player_interaction(), do: 0xC0
|
||||
def cp_snowball(), do: 0xDA
|
||||
def cp_coconut(), do: 0xDB
|
||||
def cp_left_knock_back(), do: 0xD8
|
||||
|
||||
# Anti-Cheat
|
||||
def cp_user_anti_macro_item_use_request(), do: 0x7D
|
||||
def cp_user_anti_macro_skill_use_request(), do: 0x7E
|
||||
def cp_user_anti_macro_question_result(), do: 0x7F
|
||||
|
||||
# Misc
|
||||
def cp_use_door(), do: 0xBC
|
||||
def cp_use_mech_door(), do: 0xBD
|
||||
def cp_aran_combo(), do: 0x80
|
||||
def cp_transform_player(), do: 0xBA
|
||||
def cp_note_action(), do: 0xBB
|
||||
def cp_update_quest(), do: 0x85
|
||||
def cp_use_item_quest(), do: 0x86
|
||||
def cp_follow_request(), do: 0xBF
|
||||
def cp_follow_reply(), do: 0xC0
|
||||
def cp_ring_action(), do: 0xC1
|
||||
def cp_solomon(), do: 0x93
|
||||
def cp_gach_exp(), do: 0x94
|
||||
def cp_report(), do: 0xE1
|
||||
def cp_game_poll(), do: 0xE2
|
||||
def cp_ship_object(), do: 0xBE
|
||||
def cp_cygnus_summon(), do: 0xBD
|
||||
def cp_reissue_medal(), do: 0x76
|
||||
def cp_pam_song(), do: 0xE0
|
||||
|
||||
# Owl
|
||||
def cp_owl(), do: 0x4B
|
||||
def cp_owl_warp(), do: 0x4C
|
||||
|
||||
# Profession
|
||||
def cp_profession_info(), do: 0x71
|
||||
def cp_craft_done(), do: 0x72
|
||||
def cp_craft_make(), do: 0x73
|
||||
def cp_craft_effect(), do: 0x74
|
||||
def cp_start_harvest(), do: 0x75
|
||||
def cp_stop_harvest(), do: 0x76
|
||||
def cp_make_extractor(), do: 0x77
|
||||
def cp_use_bag(), do: 0x6A
|
||||
|
||||
# Pot System
|
||||
def cp_use_pot(), do: 0xE3
|
||||
def cp_clear_pot(), do: 0xE4
|
||||
def cp_feed_pot(), do: 0xE5
|
||||
def cp_cure_pot(), do: 0xE6
|
||||
def cp_reward_pot(), do: 0xE7
|
||||
|
||||
# ==================================================================================================
|
||||
# Server → Client (Send Opcodes)
|
||||
# ==================================================================================================
|
||||
|
||||
def lp_check_password_result(), do: 0x00
|
||||
def lp_guest_id_login_result(), do: 0x01
|
||||
def lp_account_info_result(), do: 0x02
|
||||
def lp_check_user_limit_result(), do: 0x03
|
||||
def lp_set_account_result(), do: 0x04
|
||||
def lp_confirm_eula_result(), do: 0x05
|
||||
def lp_check_pin_code_result(), do: 0x06
|
||||
def lp_update_pin_code_result(), do: 0x07
|
||||
def lp_view_all_char_result(), do: 0x08
|
||||
def lp_select_character_by_vac_result(), do: 0x09
|
||||
def lp_world_information(), do: 0x0A
|
||||
def lp_select_world_result(), do: 0x0B
|
||||
def lp_select_character_result(), do: 0x0C
|
||||
def lp_check_duplicated_id_result(), do: 0x0D
|
||||
def lp_create_new_character_result(), do: 0x0E
|
||||
def lp_delete_character_result(), do: 0x0F
|
||||
def lp_migrate_command(), do: 0x10
|
||||
def lp_alive_req(), do: 0x11
|
||||
def lp_latest_connected_world(), do: 0x12
|
||||
def lp_recommend_world_message(), do: 0x13
|
||||
def lp_check_spw_result(), do: 0x14
|
||||
def lp_security_packet(), do: 0x15
|
||||
def lp_permission_request(), do: 0x16
|
||||
def lp_exception_log(), do: 0x17
|
||||
def lp_set_security_event(), do: 0x18
|
||||
def lp_set_client_key(), do: 0x19
|
||||
|
||||
# Character/Map Operations
|
||||
def lp_spawn_player(), do: 184
|
||||
def lp_remove_player_from_map(), do: 185
|
||||
def lp_chattext(), do: 186
|
||||
def lp_move_player(), do: 226
|
||||
def lp_update_char_look(), do: 241
|
||||
def lp_whisper(), do: 0x9B
|
||||
def lp_multi_chat(), do: 0x9C
|
||||
def lp_set_physical_office_ip_addr(), do: 0x1A
|
||||
def lp_end_of_scheck(), do: 0x1B
|
||||
|
||||
# NPC Operations
|
||||
def lp_npc_action(), do: 0x159
|
||||
def lp_npc_talk(), do: 0x1A3
|
||||
def lp_open_npc_shop(), do: 0x1A5
|
||||
def lp_confirm_shop_transaction(), do: 0x1A6
|
||||
def lp_open_storage(), do: 0x1A9
|
||||
|
||||
# ==================================================================================================
|
||||
# Helper Functions
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Looks up an opcode name by its value.
|
||||
"""
|
||||
def find_by_value(value) when is_integer(value) do
|
||||
# This would need to be generated from the opcodes above
|
||||
# For now, return the hex value
|
||||
"0x#{Integer.to_string(value, 16) |> String.upcase() |> String.pad_leading(2, "0")}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns all client packet opcodes as a map.
|
||||
"""
|
||||
def client_opcodes do
|
||||
%{
|
||||
cp_client_hello: 0x01,
|
||||
cp_check_password: 0x02,
|
||||
cp_world_info_request: 0x04,
|
||||
cp_select_world: 0x05,
|
||||
cp_check_user_limit: 0x06,
|
||||
cp_migrate_in: 0x0D,
|
||||
cp_check_duplicated_id: 0x0E,
|
||||
cp_create_new_character: 0x12,
|
||||
cp_create_ultimate: 0x14,
|
||||
cp_delete_character: 0x15,
|
||||
cp_alive_ack: 0x16,
|
||||
cp_exception_log: 0x17,
|
||||
cp_security_packet: 0x18,
|
||||
cp_select_character: 0x19,
|
||||
cp_check_spw_request: 0x1A,
|
||||
cp_client_dump_log: 0x1D,
|
||||
cp_create_security_handle: 0x1E,
|
||||
cp_rsa_key: 0x20,
|
||||
cp_hardware_info: 0x21,
|
||||
cp_set_code_page: 0x22,
|
||||
cp_window_focus: 0x23,
|
||||
cp_inject_packet: 0x24,
|
||||
cp_change_channel: 0x25,
|
||||
cp_enter_cash_shop: 0x26,
|
||||
cp_enter_mts: 0x27,
|
||||
cp_enter_pvp: 0x28,
|
||||
cp_enter_pvp_party: 0x29,
|
||||
cp_leave_pvp: 0x2A,
|
||||
cp_move_player: 0x2D,
|
||||
cp_cancel_chair: 0x2F,
|
||||
cp_use_chair: 0x30,
|
||||
cp_close_range_attack: 0x32,
|
||||
cp_ranged_attack: 0x33,
|
||||
cp_magic_attack: 0x34,
|
||||
cp_passive_energy: 0x35,
|
||||
cp_take_damage: 0x37,
|
||||
cp_general_chat: 0x39,
|
||||
cp_close_chalkboard: 0x3A,
|
||||
cp_face_expression: 0x3B,
|
||||
cp_face_android: 0x3C,
|
||||
cp_use_item_effect: 0x3D,
|
||||
cp_npc_talk: 0x42,
|
||||
cp_npc_move: 0x43,
|
||||
cp_npc_talk_more: 0x44,
|
||||
cp_npc_shop: 0x45,
|
||||
cp_storage: 0x46,
|
||||
cp_use_hired_merchant: 0x47,
|
||||
cp_merch_item_store: 0x49,
|
||||
cp_duey_action: 0x4A,
|
||||
cp_owl: 0x4B,
|
||||
cp_owl_warp: 0x4C,
|
||||
cp_item_sort: 0x4F,
|
||||
cp_item_gather: 0x50,
|
||||
cp_item_move: 0x51,
|
||||
cp_move_bag: 0x52,
|
||||
cp_switch_bag: 0x53,
|
||||
cp_item_pickup: 0x54,
|
||||
cp_use_item: 0x55,
|
||||
cp_cancel_item_effect: 0x56,
|
||||
cp_use_summon_bag: 0x58,
|
||||
cp_pet_food: 0x59,
|
||||
cp_use_mount_food: 0x5A,
|
||||
cp_use_scripted_npc_item: 0x5B,
|
||||
cp_use_recipe: 0x5C,
|
||||
cp_use_cash_item: 0x5D,
|
||||
cp_use_catch_item: 0x5F,
|
||||
cp_use_skill_book: 0x60,
|
||||
cp_use_familiar: 0x61,
|
||||
cp_use_owl_minerva: 0x62,
|
||||
cp_use_tele_rock: 0x6A,
|
||||
cp_use_return_scroll: 0x64,
|
||||
cp_use_upgrade_scroll: 0x65,
|
||||
cp_use_flag_scroll: 0x66,
|
||||
cp_use_equip_scroll: 0x67,
|
||||
cp_use_potential_scroll: 0x68,
|
||||
cp_use_bag: 0x6A,
|
||||
cp_use_magnify_glass: 0x6B,
|
||||
cp_distribute_ap: 0x68,
|
||||
cp_auto_assign_ap: 0x69,
|
||||
cp_heal_over_time: 0x6A,
|
||||
cp_distribute_sp: 0x6C,
|
||||
cp_special_move: 0x6D,
|
||||
cp_cancel_buff: 0x6E,
|
||||
cp_skill_effect: 0x6F,
|
||||
cp_meso_drop: 0x73,
|
||||
cp_give_fame: 0x74,
|
||||
cp_char_info_request: 0x78,
|
||||
cp_spawn_pet: 0x79,
|
||||
cp_cancel_debuff: 0x70,
|
||||
cp_change_map_special: 0x38,
|
||||
cp_use_inner_portal: 0x3A,
|
||||
cp_trock_add_map: 0x3B,
|
||||
cp_user_anti_macro_item_use_request: 0x7D,
|
||||
cp_user_anti_macro_skill_use_request: 0x7E,
|
||||
cp_user_anti_macro_question_result: 0x7F,
|
||||
cp_aran_combo: 0x80,
|
||||
cp_quest_action: 0x83,
|
||||
cp_skill_macro: 0x89,
|
||||
cp_reward_item: 0x8B,
|
||||
cp_item_maker: 0x8C,
|
||||
cp_repair_all: 0x8D,
|
||||
cp_repair: 0x8E,
|
||||
cp_party_operation: 0x91,
|
||||
cp_deny_party_request: 0x92,
|
||||
cp_allow_party_invite: 0x93,
|
||||
cp_expedition_operation: 0x94,
|
||||
cp_party_search_start: 0x95,
|
||||
cp_party_search_stop: 0x96,
|
||||
cp_guild_operation: 0x97,
|
||||
cp_deny_guild_request: 0x98,
|
||||
cp_alliance_operation: 0x99,
|
||||
cp_deny_alliance_request: 0x9A,
|
||||
cp_buddylist_modify: 0x9B,
|
||||
cp_messenger: 0x9C,
|
||||
cp_whisper: 0x9D,
|
||||
cp_request_family: 0x9E,
|
||||
cp_family_operation: 0x9F,
|
||||
cp_delete_junior: 0xA0,
|
||||
cp_delete_senior: 0xA1,
|
||||
cp_use_family: 0xA2,
|
||||
cp_family_precept: 0xA3,
|
||||
cp_family_summon: 0xA4,
|
||||
cp_accept_family: 0xA5,
|
||||
cp_mob_move: 0xA6,
|
||||
cp_mob_apply_ctrl: 0xA7,
|
||||
cp_mob_skill_delay_end: 0xA8,
|
||||
cp_mob_hit_by_mob: 0xA9,
|
||||
cp_mob_self_destruct: 0xAA,
|
||||
cp_mob_time_bomb_end: 0xAB,
|
||||
cp_mob_area_attack_disease: 0xAC,
|
||||
cp_mob_attack_mob: 0xAD,
|
||||
cp_mob_escort_collision: 0xAE,
|
||||
cp_mob_request_escort_info: 0xAF,
|
||||
cp_npc_damage_reactor: 0xC1,
|
||||
cp_touch_reactor: 0xC2,
|
||||
cp_public_npc: 0x9A,
|
||||
cp_buy_cs_item: 0xB6,
|
||||
cp_coupon_code: 0xB7,
|
||||
cp_cs_update: 0xB8,
|
||||
cp_touching_mts: 0xB9,
|
||||
cp_mts_tab: 0xBA,
|
||||
cp_use_door: 0xBC,
|
||||
cp_use_mech_door: 0xBD,
|
||||
cp_cygnus_summon: 0xBE,
|
||||
cp_ship_object: 0xBE,
|
||||
cp_party_chat: 0x3F,
|
||||
cp_player_interaction: 0xC0,
|
||||
cp_follow_request: 0xBF,
|
||||
cp_follow_reply: 0xC0,
|
||||
cp_ring_action: 0xC1,
|
||||
cp_summon_attack: 0xC6,
|
||||
cp_move_summon: 0xC4,
|
||||
cp_damage_summon: 0xC7,
|
||||
cp_sub_summon: 0xC8,
|
||||
cp_remove_summon: 0xC9,
|
||||
cp_move_dragon: 0xCA,
|
||||
cp_move_pet: 0xCB,
|
||||
cp_pet_chat: 0xCC,
|
||||
cp_pet_command: 0xCD,
|
||||
cp_pet_drop_pickup_request: 0xCE,
|
||||
cp_pet_auto_pot: 0xCF,
|
||||
cp_move_familiar: 0xD0,
|
||||
cp_rename_familiar: 0xD1,
|
||||
cp_spawn_familiar: 0xD2,
|
||||
cp_attack_familiar: 0xD3,
|
||||
cp_touch_familiar: 0xD4,
|
||||
cp_monster_carnival: 0xD5,
|
||||
cp_snowball: 0xDA,
|
||||
cp_coconut: 0xDB,
|
||||
cp_left_knock_back: 0xD8,
|
||||
cp_use_pot: 0xE3,
|
||||
cp_clear_pot: 0xE4,
|
||||
cp_feed_pot: 0xE5,
|
||||
cp_cure_pot: 0xE6,
|
||||
cp_reward_pot: 0xE7,
|
||||
cp_report: 0xE1,
|
||||
cp_game_poll: 0xE2,
|
||||
cp_pam_song: 0xE0,
|
||||
cp_move_android: 0x40
|
||||
}
|
||||
end
|
||||
end
|
||||
283
lib/odinsea/net/packet/in.ex
Normal file
283
lib/odinsea/net/packet/in.ex
Normal file
@@ -0,0 +1,283 @@
|
||||
defmodule Odinsea.Net.Packet.In do
|
||||
@moduledoc """
|
||||
Incoming packet reader ported from Java InPacket.
|
||||
Handles little-endian decoding of MapleStory packet data.
|
||||
"""
|
||||
|
||||
defstruct data: <<>>, index: 0, length: 0
|
||||
|
||||
alias Odinsea.Constants.Server
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
data: binary(),
|
||||
index: non_neg_integer(),
|
||||
length: non_neg_integer()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Creates a new incoming packet from binary data.
|
||||
"""
|
||||
@spec new(binary()) :: t()
|
||||
def new(data) when is_binary(data) do
|
||||
%__MODULE__{
|
||||
data: data,
|
||||
index: 0,
|
||||
length: byte_size(data)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the remaining bytes in the packet.
|
||||
"""
|
||||
@spec remaining(t()) :: non_neg_integer()
|
||||
def remaining(%__MODULE__{length: length, index: index}), do: length - index
|
||||
|
||||
@doc """
|
||||
Returns true if the packet has been fully read.
|
||||
"""
|
||||
@spec empty?(t()) :: boolean()
|
||||
def empty?(%__MODULE__{length: length, index: index}), do: index >= length
|
||||
|
||||
@doc """
|
||||
Returns the current read position.
|
||||
"""
|
||||
@spec get_index(t()) :: non_neg_integer()
|
||||
def get_index(%__MODULE__{index: index}), do: index
|
||||
|
||||
@doc """
|
||||
Sets the read position.
|
||||
"""
|
||||
@spec set_index(t(), non_neg_integer()) :: t()
|
||||
def set_index(packet, index) when index >= 0 do
|
||||
%{packet | index: min(index, packet.length)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Skips the specified number of bytes.
|
||||
"""
|
||||
@spec skip(t(), non_neg_integer()) :: t()
|
||||
def skip(packet, count) when count >= 0 do
|
||||
%{packet | index: min(packet.index + count, packet.length)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a byte and returns {value, updated_packet}.
|
||||
"""
|
||||
@spec decode_byte(t()) :: {integer(), t()} | :error
|
||||
def decode_byte(%__MODULE__{data: data, index: index, length: length}) do
|
||||
if index + 1 <= length do
|
||||
<<_::binary-size(index), value::signed-integer-little-8, _::binary>> = data
|
||||
{value, %__MODULE__{data: data, index: index + 1, length: length}}
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a byte and returns only the value, raising on error.
|
||||
"""
|
||||
@spec decode_byte!(t()) :: integer()
|
||||
def decode_byte!(packet) do
|
||||
case decode_byte(packet) do
|
||||
{value, _} -> value
|
||||
:error -> raise "Packet underrun reading byte"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a short (2 bytes) and returns {value, updated_packet}.
|
||||
"""
|
||||
@spec decode_short(t()) :: {integer(), t()} | :error
|
||||
def decode_short(%__MODULE__{data: data, index: index, length: length}) do
|
||||
if index + 2 <= length do
|
||||
<<_::binary-size(index), value::signed-integer-little-16, _::binary>> = data
|
||||
{value, %__MODULE__{data: data, index: index + 2, length: length}}
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a short and returns only the value, raising on error.
|
||||
"""
|
||||
@spec decode_short!(t()) :: integer()
|
||||
def decode_short!(packet) do
|
||||
case decode_short(packet) do
|
||||
{value, _} -> value
|
||||
:error -> raise "Packet underrun reading short"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads an int (4 bytes) and returns {value, updated_packet}.
|
||||
"""
|
||||
@spec decode_int(t()) :: {integer(), t()} | :error
|
||||
def decode_int(%__MODULE__{data: data, index: index, length: length}) do
|
||||
if index + 4 <= length do
|
||||
<<_::binary-size(index), value::signed-integer-little-32, _::binary>> = data
|
||||
{value, %__MODULE__{data: data, index: index + 4, length: length}}
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads an int and returns only the value, raising on error.
|
||||
"""
|
||||
@spec decode_int!(t()) :: integer()
|
||||
def decode_int!(packet) do
|
||||
case decode_int(packet) do
|
||||
{value, _} -> value
|
||||
:error -> raise "Packet underrun reading int"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a long (8 bytes) and returns {value, updated_packet}.
|
||||
"""
|
||||
@spec decode_long(t()) :: {integer(), t()} | :error
|
||||
def decode_long(%__MODULE__{data: data, index: index, length: length}) do
|
||||
if index + 8 <= length do
|
||||
<<_::binary-size(index), value::signed-integer-little-64, _::binary>> = data
|
||||
{value, %__MODULE__{data: data, index: index + 8, length: length}}
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a long and returns only the value, raising on error.
|
||||
"""
|
||||
@spec decode_long!(t()) :: integer()
|
||||
def decode_long!(packet) do
|
||||
case decode_long(packet) do
|
||||
{value, _} -> value
|
||||
:error -> raise "Packet underrun reading long"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a MapleStory ASCII string (length-prefixed).
|
||||
Format: [2-byte length][ASCII bytes]
|
||||
"""
|
||||
@spec decode_string(t()) :: {String.t(), t()} | :error
|
||||
def decode_string(packet) do
|
||||
case decode_short(packet) do
|
||||
{length, packet} when length >= 0 ->
|
||||
decode_buffer(packet, length) |> decode_string_result()
|
||||
|
||||
_ ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_string_result({bytes, packet}) do
|
||||
{bytes, packet}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a string and returns only the value, raising on error.
|
||||
"""
|
||||
@spec decode_string!(t()) :: String.t()
|
||||
def decode_string!(packet) do
|
||||
case decode_string(packet) do
|
||||
{value, _} -> value
|
||||
:error -> raise "Packet underrun reading string"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a specified number of bytes and returns {bytes, updated_packet}.
|
||||
"""
|
||||
@spec decode_buffer(t(), non_neg_integer()) :: {binary(), t()} | :error
|
||||
def decode_buffer(%__MODULE__{data: data, index: index, length: total_length}, count)
|
||||
when count >= 0 do
|
||||
if index + count <= total_length do
|
||||
<<_::binary-size(index), buffer::binary-size(count), _::binary>> = data
|
||||
{buffer, %__MODULE__{data: data, index: index + count, length: total_length}}
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a buffer and returns only the bytes, raising on error.
|
||||
"""
|
||||
@spec decode_buffer!(t(), non_neg_integer()) :: binary()
|
||||
def decode_buffer!(packet, count) do
|
||||
case decode_buffer(packet, count) do
|
||||
{value, _} -> value
|
||||
:error -> raise "Packet underrun reading buffer"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a boolean (1 byte, 0 = false, 1 = true).
|
||||
"""
|
||||
@spec decode_bool(t()) :: {boolean(), t()} | :error
|
||||
def decode_bool(packet) do
|
||||
case decode_byte(packet) do
|
||||
{0, packet} -> {false, packet}
|
||||
{_, packet} -> {true, packet}
|
||||
:error -> :error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a boolean, raising on error.
|
||||
"""
|
||||
@spec decode_bool!(t()) :: boolean()
|
||||
def decode_bool!(packet) do
|
||||
case decode_bool(packet) do
|
||||
{value, _} -> value
|
||||
:error -> raise "Packet underrun reading bool"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads the remaining bytes in the packet.
|
||||
"""
|
||||
@spec read_remaining(t()) :: {binary(), t()}
|
||||
def read_remaining(%__MODULE__{data: data, index: index, length: length}) do
|
||||
remaining = length - index
|
||||
<<_::binary-size(index), buffer::binary-size(remaining), _::binary>> = data
|
||||
{buffer, %__MODULE__{data: data, index: length, length: length}}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts the packet to a hex string for debugging.
|
||||
"""
|
||||
@spec to_hex_string(t()) :: String.t()
|
||||
def to_hex_string(%__MODULE__{data: data}), do: Odinsea.Net.Hex.encode(data)
|
||||
|
||||
@doc """
|
||||
Converts the packet to a hex string with position markers.
|
||||
"""
|
||||
@spec to_hex_string(t(), boolean()) :: String.t()
|
||||
def to_hex_string(%__MODULE__{data: data, index: index}, true) do
|
||||
hex_str = Odinsea.Net.Hex.encode(data)
|
||||
"[pos=#{index}] " <> hex_str
|
||||
end
|
||||
|
||||
def to_hex_string(packet, false), do: to_hex_string(packet)
|
||||
|
||||
@doc """
|
||||
Returns the raw packet data.
|
||||
"""
|
||||
@spec to_buffer(t()) :: binary()
|
||||
def to_buffer(%__MODULE__{data: data}), do: data
|
||||
|
||||
@doc """
|
||||
Returns a slice of the packet data.
|
||||
"""
|
||||
@spec slice(t(), non_neg_integer(), non_neg_integer()) :: binary()
|
||||
def slice(%__MODULE__{data: data}, start, length) do
|
||||
binary_part(data, start, length)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the packet length.
|
||||
"""
|
||||
@spec length(t()) :: non_neg_integer()
|
||||
def length(%__MODULE__{length: length}), do: length
|
||||
end
|
||||
190
lib/odinsea/net/packet/out.ex
Normal file
190
lib/odinsea/net/packet/out.ex
Normal file
@@ -0,0 +1,190 @@
|
||||
defmodule Odinsea.Net.Packet.Out do
|
||||
@moduledoc """
|
||||
Outgoing packet writer ported from Java OutPacket.
|
||||
Handles little-endian encoding of MapleStory packet data.
|
||||
"""
|
||||
|
||||
defstruct data: <<>>
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
data: iodata()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Creates a new outgoing packet.
|
||||
"""
|
||||
@spec new() :: t()
|
||||
def new do
|
||||
%__MODULE__{data: []}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a new outgoing packet with an opcode.
|
||||
"""
|
||||
@spec new(integer()) :: t()
|
||||
def new(opcode) when is_integer(opcode) do
|
||||
%__MODULE__{data: [<<opcode::signed-integer-little-16>>]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a byte to the packet.
|
||||
"""
|
||||
@spec encode_byte(t(), integer()) :: t()
|
||||
def encode_byte(%__MODULE__{data: data}, value) when is_integer(value) do
|
||||
%__MODULE__{data: [data | <<value::signed-integer-little-8>>]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a short (2 bytes) to the packet.
|
||||
"""
|
||||
@spec encode_short(t(), integer()) :: t()
|
||||
def encode_short(%__MODULE__{data: data}, value) when is_integer(value) do
|
||||
%__MODULE__{data: [data | <<value::signed-integer-little-16>>]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes an int (4 bytes) to the packet.
|
||||
"""
|
||||
@spec encode_int(t(), integer()) :: t()
|
||||
def encode_int(%__MODULE__{data: data}, value) when is_integer(value) do
|
||||
%__MODULE__{data: [data | <<value::signed-integer-little-32>>]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a long (8 bytes) to the packet.
|
||||
"""
|
||||
@spec encode_long(t(), integer()) :: t()
|
||||
def encode_long(%__MODULE__{data: data}, value) when is_integer(value) do
|
||||
%__MODULE__{data: [data | <<value::signed-integer-little-64>>]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a MapleStory ASCII string.
|
||||
Format: [2-byte length][ASCII bytes]
|
||||
"""
|
||||
@spec encode_string(t(), String.t()) :: t()
|
||||
def encode_string(%__MODULE__{data: data}, value) when is_binary(value) do
|
||||
length = byte_size(value)
|
||||
%__MODULE__{data: [data | <<length::signed-integer-little-16, value::binary>>]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a boolean (1 byte, 0 = false, 1 = true).
|
||||
"""
|
||||
@spec encode_bool(t(), boolean()) :: t()
|
||||
def encode_bool(%__MODULE__{data: data}, true) do
|
||||
%__MODULE__{data: [data | <<1::signed-integer-little-8>>]}
|
||||
end
|
||||
|
||||
def encode_bool(%__MODULE__{data: data}, false) do
|
||||
%__MODULE__{data: [data | <<0::signed-integer-little-8>>]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a raw buffer to the packet.
|
||||
"""
|
||||
@spec encode_buffer(t(), binary()) :: t()
|
||||
def encode_buffer(%__MODULE__{data: data}, buffer) when is_binary(buffer) do
|
||||
%__MODULE__{data: [data | buffer]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a fixed-size buffer, padding with zeros if necessary.
|
||||
"""
|
||||
@spec encode_fixed_buffer(t(), binary(), non_neg_integer()) :: t()
|
||||
def encode_fixed_buffer(%__MODULE__{data: data}, buffer, size) when is_binary(buffer) do
|
||||
current_size = byte_size(buffer)
|
||||
|
||||
padding =
|
||||
if current_size < size do
|
||||
<<0::size((size - current_size) * 8)>>
|
||||
else
|
||||
<<>>
|
||||
end
|
||||
|
||||
truncated = binary_part(buffer, 0, min(current_size, size))
|
||||
%__MODULE__{data: [data | truncated]}
|
||||
|> then(&%__MODULE__{data: [&1.data | padding]})
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a timestamp (current time in milliseconds).
|
||||
"""
|
||||
@spec encode_timestamp(t()) :: t()
|
||||
def encode_timestamp(%__MODULE__{data: data}) do
|
||||
timestamp = System.system_time(:millisecond)
|
||||
%__MODULE__{data: [data | <<timestamp::signed-integer-little-64>>]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a file time (Windows FILETIME format).
|
||||
"""
|
||||
@spec encode_filetime(t(), integer()) :: t()
|
||||
def encode_filetime(%__MODULE__{data: data}, value) when is_integer(value) do
|
||||
%__MODULE__{data: [data | <<value::signed-integer-little-64>>]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a file time for "zero" (infinite/no expiration).
|
||||
"""
|
||||
@spec encode_filetime_zero(t()) :: t()
|
||||
def encode_filetime_zero(%__MODULE__{data: data}) do
|
||||
%__MODULE__{data: [data | <<0x00::64>>]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a file time for "infinite".
|
||||
"""
|
||||
@spec encode_filetime_infinite(t()) :: t()
|
||||
def encode_filetime_infinite(%__MODULE__{data: data}) do
|
||||
%__MODULE__{data: [data | <<0xDD15F1C0::little-32, 0x11CE::little-16, 0xC0C0::little-16>>]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a position (2 ints: x, y).
|
||||
"""
|
||||
@spec encode_position(t(), {integer(), integer()}) :: t()
|
||||
def encode_position(%__MODULE__{data: data}, {x, y}) do
|
||||
%__MODULE__{data: [data | <<x::signed-integer-little-32, y::signed-integer-little-32>>]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Encodes a short position (2 shorts: x, y).
|
||||
"""
|
||||
@spec encode_short_position(t(), {integer(), integer()}) :: t()
|
||||
def encode_short_position(%__MODULE__{data: data}, {x, y}) do
|
||||
%__MODULE__{data: [data | <<x::signed-integer-little-16, y::signed-integer-little-16>>]}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the packet as a binary.
|
||||
"""
|
||||
@spec to_binary(t()) :: binary()
|
||||
def to_binary(%__MODULE__{data: data}) do
|
||||
IO.iodata_to_binary(data)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the packet as iodata (for efficient writing).
|
||||
"""
|
||||
@spec to_iodata(t()) :: iodata()
|
||||
def to_iodata(%__MODULE__{data: data}) do
|
||||
data
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts the packet to a hex string for debugging.
|
||||
"""
|
||||
@spec to_string(t()) :: String.t()
|
||||
def to_string(%__MODULE__{data: data}) do
|
||||
data |> IO.iodata_to_binary() |> Odinsea.Net.Hex.encode()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the current packet size in bytes.
|
||||
"""
|
||||
@spec length(t()) :: non_neg_integer()
|
||||
def length(%__MODULE__{data: data}) do
|
||||
IO.iodata_length(data)
|
||||
end
|
||||
end
|
||||
413
lib/odinsea/net/processor.ex
Normal file
413
lib/odinsea/net/processor.ex
Normal file
@@ -0,0 +1,413 @@
|
||||
defmodule Odinsea.Net.Processor do
|
||||
@moduledoc """
|
||||
Central packet routing/dispatch system.
|
||||
Ported from Java PacketProcessor.java
|
||||
|
||||
Routes incoming packets to appropriate handlers based on:
|
||||
- Server type (Login, Channel, Shop)
|
||||
- Packet opcode
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
alias Odinsea.Net.Opcodes
|
||||
|
||||
@type server_type :: :login | :channel | :shop
|
||||
@type client_state :: map()
|
||||
|
||||
# ==================================================================================================
|
||||
# Main Entry Point
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Main packet handler entry point.
|
||||
Routes packets to appropriate server-specific handlers.
|
||||
|
||||
## Parameters
|
||||
- `opcode` - The packet opcode (integer)
|
||||
- `packet` - The InPacket struct (already read past opcode)
|
||||
- `client_state` - The client's state map
|
||||
- `server_type` - :login | :channel | :shop
|
||||
|
||||
## Returns
|
||||
- `{:ok, new_state}` - Success with updated state
|
||||
- `{:error, reason, state}` - Error with reason
|
||||
- `{:disconnect, reason}` - Client should be disconnected
|
||||
"""
|
||||
def handle(opcode, %In{} = packet, client_state, server_type) do
|
||||
# Pre-process common packets that apply to all server types
|
||||
case preprocess(opcode, packet, client_state) do
|
||||
{:handled, new_state} ->
|
||||
{:ok, new_state}
|
||||
|
||||
:continue ->
|
||||
# Route to server-specific handler
|
||||
case server_type do
|
||||
:login ->
|
||||
handle_login(opcode, packet, client_state)
|
||||
|
||||
:shop ->
|
||||
handle_shop(opcode, packet, client_state)
|
||||
|
||||
:channel ->
|
||||
handle_channel(opcode, packet, client_state)
|
||||
|
||||
_ ->
|
||||
Logger.warning("Unknown server type: #{inspect(server_type)}")
|
||||
{:ok, client_state}
|
||||
end
|
||||
end
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("Packet processing error: #{inspect(e)}")
|
||||
{:error, :processing_error, client_state}
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Pre-Processing (Common Packets)
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Pre-processes packets that are common across all server types.
|
||||
Returns {:handled, state} if the packet was fully handled.
|
||||
Returns :continue if the packet should be routed to server-specific handlers.
|
||||
"""
|
||||
# Define opcodes as module attributes for use in guards
|
||||
@cp_security_packet Opcodes.cp_security_packet()
|
||||
@cp_alive_ack Opcodes.cp_alive_ack()
|
||||
@cp_client_dump_log Opcodes.cp_client_dump_log()
|
||||
@cp_hardware_info Opcodes.cp_hardware_info()
|
||||
@cp_inject_packet Opcodes.cp_inject_packet()
|
||||
@cp_set_code_page Opcodes.cp_set_code_page()
|
||||
@cp_window_focus Opcodes.cp_window_focus()
|
||||
@cp_exception_log Opcodes.cp_exception_log()
|
||||
|
||||
defp preprocess(opcode, packet, state) do
|
||||
case opcode do
|
||||
# Security packet - always handled in pre-process
|
||||
@cp_security_packet ->
|
||||
handle_security_packet(packet, state)
|
||||
{:handled, state}
|
||||
|
||||
# Alive acknowledgement - keep connection alive
|
||||
@cp_alive_ack ->
|
||||
handle_alive_ack(state)
|
||||
{:handled, state}
|
||||
|
||||
# Client dump log - debugging/crash reports
|
||||
@cp_client_dump_log ->
|
||||
handle_dump_log(packet, state)
|
||||
{:handled, state}
|
||||
|
||||
# Hardware info - client machine identification
|
||||
@cp_hardware_info ->
|
||||
handle_hardware_info(packet, state)
|
||||
{:handled, state}
|
||||
|
||||
# Packet injection attempt - security violation
|
||||
@cp_inject_packet ->
|
||||
handle_inject_packet(packet, state)
|
||||
{:disconnect, :packet_injection}
|
||||
|
||||
# Code page settings
|
||||
@cp_set_code_page ->
|
||||
handle_code_page(packet, state)
|
||||
{:handled, state}
|
||||
|
||||
# Window focus - anti-cheat detection
|
||||
@cp_window_focus ->
|
||||
handle_window_focus(packet, state)
|
||||
{:handled, state}
|
||||
|
||||
# Exception log from client
|
||||
@cp_exception_log ->
|
||||
handle_exception_log(packet, state)
|
||||
{:handled, state}
|
||||
|
||||
# Not a common packet, continue to server-specific handling
|
||||
_ ->
|
||||
:continue
|
||||
end
|
||||
end
|
||||
|
||||
# Login opcodes as module attributes
|
||||
@cp_client_hello Opcodes.cp_client_hello()
|
||||
@cp_check_password Opcodes.cp_check_password()
|
||||
@cp_world_info_request Opcodes.cp_world_info_request()
|
||||
@cp_select_world Opcodes.cp_select_world()
|
||||
@cp_check_user_limit Opcodes.cp_check_user_limit()
|
||||
@cp_check_duplicated_id Opcodes.cp_check_duplicated_id()
|
||||
@cp_create_new_character Opcodes.cp_create_new_character()
|
||||
@cp_create_ultimate Opcodes.cp_create_ultimate()
|
||||
@cp_delete_character Opcodes.cp_delete_character()
|
||||
@cp_select_character Opcodes.cp_select_character()
|
||||
@cp_check_spw_request Opcodes.cp_check_spw_request()
|
||||
@cp_rsa_key Opcodes.cp_rsa_key()
|
||||
|
||||
# ==================================================================================================
|
||||
# Login Server Packet Handlers
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Routes packets for the Login server.
|
||||
Delegates to Odinsea.Login.Handler module.
|
||||
"""
|
||||
defp handle_login(opcode, packet, state) do
|
||||
alias Odinsea.Login.Handler
|
||||
|
||||
case opcode do
|
||||
# Permission request (client hello / initial handshake)
|
||||
@cp_client_hello ->
|
||||
Handler.on_permission_request(packet, state)
|
||||
|
||||
# Password check (login authentication)
|
||||
@cp_check_password ->
|
||||
Handler.on_check_password(packet, state)
|
||||
|
||||
# World info request (server list)
|
||||
@cp_world_info_request ->
|
||||
Handler.on_world_info_request(state)
|
||||
|
||||
# Select world
|
||||
@cp_select_world ->
|
||||
Handler.on_select_world(packet, state)
|
||||
|
||||
# Check user limit (channel population check)
|
||||
@cp_check_user_limit ->
|
||||
Handler.on_check_user_limit(state)
|
||||
|
||||
# Check duplicated ID (character name availability)
|
||||
@cp_check_duplicated_id ->
|
||||
Handler.on_check_duplicated_id(packet, state)
|
||||
|
||||
# Create new character
|
||||
@cp_create_new_character ->
|
||||
Handler.on_create_new_character(packet, state)
|
||||
|
||||
# Create ultimate (Cygnus Knights)
|
||||
@cp_create_ultimate ->
|
||||
Handler.on_create_ultimate(packet, state)
|
||||
|
||||
# Delete character
|
||||
@cp_delete_character ->
|
||||
Handler.on_delete_character(packet, state)
|
||||
|
||||
# Select character (enter game)
|
||||
@cp_select_character ->
|
||||
Handler.on_select_character(packet, state)
|
||||
|
||||
# Second password check
|
||||
@cp_check_spw_request ->
|
||||
Handler.on_check_spw_request(packet, state)
|
||||
|
||||
# RSA key request
|
||||
@cp_rsa_key ->
|
||||
Handler.on_rsa_key(packet, state)
|
||||
|
||||
# Unhandled login packet
|
||||
_ ->
|
||||
Logger.debug("Unhandled login packet: opcode=0x#{Integer.to_string(opcode, 16)}")
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# Channel opcodes as module attributes
|
||||
@cp_migrate_in Opcodes.cp_migrate_in()
|
||||
@cp_move_player Opcodes.cp_move_player()
|
||||
@cp_general_chat Opcodes.cp_general_chat()
|
||||
@cp_change_map Opcodes.cp_change_map()
|
||||
|
||||
# ==================================================================================================
|
||||
# Channel Server Packet Handlers
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Routes packets for the Channel server (game world).
|
||||
Delegates to appropriate handler modules.
|
||||
"""
|
||||
defp handle_channel(opcode, packet, state) do
|
||||
# TODO: Implement channel packet routing
|
||||
# Will route to:
|
||||
# - Odinsea.Channel.Handler.Player (movement, attacks, skills)
|
||||
# - Odinsea.Channel.Handler.Inventory (items, equipment)
|
||||
# - Odinsea.Channel.Handler.Mob (monster interactions)
|
||||
# - Odinsea.Channel.Handler.NPC (NPC dialogs, shops)
|
||||
# - Odinsea.Channel.Handler.Chat (chat, party, guild)
|
||||
# - etc.
|
||||
|
||||
case opcode do
|
||||
# Migrate in from login server
|
||||
@cp_migrate_in ->
|
||||
{character_id, _} = In.decode_int(packet)
|
||||
handle_migrate_in(character_id, state)
|
||||
|
||||
# Player movement
|
||||
@cp_move_player ->
|
||||
{:ok, state} # TODO: Implement
|
||||
|
||||
# General chat
|
||||
@cp_general_chat ->
|
||||
{:ok, state} # TODO: Implement
|
||||
|
||||
# Change map
|
||||
@cp_change_map ->
|
||||
{:ok, state} # TODO: Implement
|
||||
|
||||
# Unhandled channel packet
|
||||
_ ->
|
||||
Logger.debug("Unhandled channel packet: opcode=0x#{Integer.to_string(opcode, 16)}")
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# Shop opcodes as module attributes
|
||||
@cp_buy_cs_item Opcodes.cp_buy_cs_item()
|
||||
@cp_coupon_code Opcodes.cp_coupon_code()
|
||||
@cp_cs_update Opcodes.cp_cs_update()
|
||||
|
||||
# ==================================================================================================
|
||||
# Cash Shop Server Packet Handlers
|
||||
# ==================================================================================================
|
||||
|
||||
@doc """
|
||||
Routes packets for the Cash Shop server.
|
||||
Delegates to Odinsea.Shop.Handler module.
|
||||
"""
|
||||
defp handle_shop(opcode, packet, state) do
|
||||
# TODO: Implement cash shop packet routing
|
||||
|
||||
case opcode do
|
||||
# Migrate in from channel server
|
||||
@cp_migrate_in ->
|
||||
{character_id, _} = In.decode_int(packet)
|
||||
handle_migrate_in(character_id, state)
|
||||
|
||||
# Buy cash item
|
||||
@cp_buy_cs_item ->
|
||||
{:ok, state} # TODO: Implement
|
||||
|
||||
# Coupon code
|
||||
@cp_coupon_code ->
|
||||
{:ok, state} # TODO: Implement
|
||||
|
||||
# Cash shop update
|
||||
@cp_cs_update ->
|
||||
{:ok, state} # TODO: Implement
|
||||
|
||||
# Leave cash shop
|
||||
@cp_change_map ->
|
||||
{:ok, state} # TODO: Implement
|
||||
|
||||
# Unhandled shop packet
|
||||
_ ->
|
||||
Logger.debug("Unhandled shop packet: opcode=0x#{Integer.to_string(opcode, 16)}")
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Common Packet Implementations
|
||||
# ==================================================================================================
|
||||
|
||||
defp handle_security_packet(_packet, _state) do
|
||||
# Security packet - just acknowledge
|
||||
# In the Java version, this is a no-op that returns true in preProcess
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_alive_ack(state) do
|
||||
# Update last alive time
|
||||
new_state = Map.put(state, :last_alive, System.system_time(:millisecond))
|
||||
Logger.debug("Alive ack received from #{state.ip}")
|
||||
{:ok, new_state}
|
||||
end
|
||||
|
||||
defp handle_dump_log(packet, state) do
|
||||
{call_type, packet} = In.decode_short(packet)
|
||||
{error_code, packet} = In.decode_int(packet)
|
||||
{backup_buffer_size, packet} = In.decode_short(packet)
|
||||
{raw_seq, packet} = In.decode_int(packet)
|
||||
{p_type, packet} = In.decode_short(packet)
|
||||
{backup_buffer, _packet} = In.decode_buffer(packet, backup_buffer_size - 6)
|
||||
|
||||
log_msg = "[ClientDumpLog] RawSeq: #{raw_seq} CallType: #{call_type} " <>
|
||||
"ErrorCode: #{error_code} BufferSize: #{backup_buffer_size} " <>
|
||||
"Type: 0x#{Integer.to_string(p_type, 16)} " <>
|
||||
"Packet: #{inspect(backup_buffer, limit: :infinity)}"
|
||||
|
||||
Logger.warning(log_msg)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_hardware_info(packet, state) do
|
||||
{hardware_info, _packet} = In.decode_string(packet)
|
||||
new_state = Map.put(state, :hardware_info, hardware_info)
|
||||
Logger.debug("Hardware info: #{hardware_info}")
|
||||
{:ok, new_state}
|
||||
end
|
||||
|
||||
defp handle_inject_packet(packet, state) do
|
||||
start = packet.position
|
||||
finish = byte_size(packet.data) - 2
|
||||
|
||||
# Read opcode at end
|
||||
packet = %{packet | position: finish}
|
||||
{opcode, _packet} = In.decode_short(packet)
|
||||
|
||||
# Get hex string of injected packet
|
||||
injected_data = binary_part(packet.data, start, finish - start)
|
||||
|
||||
player_name = Map.get(state, :character_name, "<none>")
|
||||
player_id = Map.get(state, :character_id, 0)
|
||||
|
||||
Logger.error(
|
||||
"[InjectPacket] [Session #{state.ip}] [Player #{player_name} - #{player_id}] " <>
|
||||
"[OpCode 0x#{Integer.to_string(opcode, 16)}] #{inspect(injected_data, limit: :infinity)}"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_code_page(packet, state) do
|
||||
{code_page, packet} = In.decode_int(packet)
|
||||
{code_page_read, _packet} = In.decode_int(packet)
|
||||
|
||||
new_state =
|
||||
state
|
||||
|> Map.put(:code_page, code_page)
|
||||
|> Map.put(:code_page_read, code_page_read)
|
||||
|
||||
Logger.debug("Code page set: #{code_page}, read: #{code_page_read}")
|
||||
{:ok, new_state}
|
||||
end
|
||||
|
||||
defp handle_window_focus(packet, state) do
|
||||
{focus, _packet} = In.decode_byte(packet)
|
||||
|
||||
if focus == 0 do
|
||||
player_name = Map.get(state, :character_name, "<none>")
|
||||
player_id = Map.get(state, :character_id, 0)
|
||||
|
||||
Logger.warning(
|
||||
"[WindowFocus] Client lost focus [Session #{state.ip}] [Player #{player_name} - #{player_id}]"
|
||||
)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_exception_log(packet, state) do
|
||||
{exception_msg, _packet} = In.decode_string(packet)
|
||||
|
||||
Logger.warning("[ClientExceptionLog] [Session #{state.ip}] #{exception_msg}")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_migrate_in(character_id, state) do
|
||||
# TODO: Load character from database, restore session
|
||||
Logger.info("Migrate in: character_id=#{character_id}")
|
||||
new_state = Map.put(state, :character_id, character_id)
|
||||
{:ok, new_state}
|
||||
end
|
||||
end
|
||||
90
lib/odinsea/shop/client.ex
Normal file
90
lib/odinsea/shop/client.ex
Normal file
@@ -0,0 +1,90 @@
|
||||
defmodule Odinsea.Shop.Client do
|
||||
@moduledoc """
|
||||
Client connection handler for the cash shop server.
|
||||
"""
|
||||
|
||||
use GenServer, restart: :temporary
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Net.Packet.In
|
||||
|
||||
defstruct [:socket, :ip, :state, :character_id, :account_id]
|
||||
|
||||
def start_link(socket) do
|
||||
GenServer.start_link(__MODULE__, socket)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(socket) do
|
||||
{:ok, {ip, _port}} = :inet.peername(socket)
|
||||
ip_string = format_ip(ip)
|
||||
|
||||
Logger.info("Cash shop client connected from #{ip_string}")
|
||||
|
||||
state = %__MODULE__{
|
||||
socket: socket,
|
||||
ip: ip_string,
|
||||
state: :connected,
|
||||
character_id: nil,
|
||||
account_id: nil
|
||||
}
|
||||
|
||||
send(self(), :receive)
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:receive, %{socket: socket} = state) do
|
||||
case :gen_tcp.recv(socket, 0, 30_000) do
|
||||
{:ok, data} ->
|
||||
new_state = handle_packet(data, state)
|
||||
send(self(), :receive)
|
||||
{:noreply, new_state}
|
||||
|
||||
{:error, :closed} ->
|
||||
Logger.info("Cash shop client disconnected: #{state.ip}")
|
||||
{:stop, :normal, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Cash shop client error: #{inspect(reason)}")
|
||||
{:stop, :normal, state}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, state) do
|
||||
if state.socket do
|
||||
:gen_tcp.close(state.socket)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp handle_packet(data, state) do
|
||||
packet = In.new(data)
|
||||
|
||||
case In.decode_short(packet) do
|
||||
{opcode, packet} ->
|
||||
Logger.debug("Cash shop packet: opcode=0x#{Integer.to_string(opcode, 16)}")
|
||||
dispatch_packet(opcode, packet, state)
|
||||
|
||||
:error ->
|
||||
Logger.warning("Failed to read packet opcode")
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
defp dispatch_packet(_opcode, _packet, state) do
|
||||
# TODO: Implement cash shop packet handlers
|
||||
state
|
||||
end
|
||||
|
||||
defp format_ip({a, b, c, d}) do
|
||||
"#{a}.#{b}.#{c}.#{d}"
|
||||
end
|
||||
|
||||
defp format_ip({a, b, c, d, e, f, g, h}) do
|
||||
"#{a}:#{b}:#{c}:#{d}:#{e}:#{f}:#{g}:#{h}"
|
||||
end
|
||||
end
|
||||
59
lib/odinsea/shop/listener.ex
Normal file
59
lib/odinsea/shop/listener.ex
Normal file
@@ -0,0 +1,59 @@
|
||||
defmodule Odinsea.Shop.Listener do
|
||||
@moduledoc """
|
||||
TCP listener for the cash shop server.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
port = Application.get_env(:odinsea, :shop, [])[:port] || 8605
|
||||
|
||||
case :gen_tcp.listen(port, tcp_options()) do
|
||||
{:ok, socket} ->
|
||||
Logger.info("Cash shop server listening on port #{port}")
|
||||
send(self(), :accept)
|
||||
{:ok, %{socket: socket, port: port}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to start cash shop server on port #{port}: #{inspect(reason)}")
|
||||
{:stop, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:accept, %{socket: socket} = state) do
|
||||
case :gen_tcp.accept(socket) do
|
||||
{:ok, client_socket} ->
|
||||
{:ok, _pid} =
|
||||
DynamicSupervisor.start_child(
|
||||
Odinsea.ClientSupervisor,
|
||||
{Odinsea.Shop.Client, client_socket}
|
||||
)
|
||||
|
||||
send(self(), :accept)
|
||||
{:noreply, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Cash shop accept error: #{inspect(reason)}")
|
||||
send(self(), :accept)
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp tcp_options do
|
||||
[
|
||||
:binary,
|
||||
packet: :raw,
|
||||
active: false,
|
||||
reuseaddr: true,
|
||||
backlog: 50
|
||||
]
|
||||
end
|
||||
end
|
||||
134
lib/odinsea/util/bit_tools.ex
Normal file
134
lib/odinsea/util/bit_tools.ex
Normal file
@@ -0,0 +1,134 @@
|
||||
defmodule Odinsea.Util.BitTools do
|
||||
@moduledoc """
|
||||
Utility functions for bit and byte manipulation.
|
||||
Provides helper functions for working with binary data, similar to Java's BitTools.
|
||||
|
||||
Ported from: src/tools/BitTools.java
|
||||
"""
|
||||
|
||||
use Bitwise
|
||||
|
||||
@doc """
|
||||
Reads a 16-bit short integer (little-endian) from a byte array at the given index.
|
||||
|
||||
## Parameters
|
||||
- array: Binary array
|
||||
- index: Starting position (0-based)
|
||||
|
||||
## Returns
|
||||
- 16-bit integer value (0-65535)
|
||||
"""
|
||||
@spec get_short(binary(), non_neg_integer()) :: non_neg_integer()
|
||||
def get_short(array, index) when is_binary(array) do
|
||||
<<_skip::binary-size(index), value::little-unsigned-16, _rest::binary>> = array
|
||||
value
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a string from a byte array at the given index with specified length.
|
||||
|
||||
## Parameters
|
||||
- array: Binary array
|
||||
- index: Starting position
|
||||
- length: Number of bytes to read
|
||||
|
||||
## Returns
|
||||
- String extracted from the byte array
|
||||
"""
|
||||
@spec get_string(binary(), non_neg_integer(), non_neg_integer()) :: String.t()
|
||||
def get_string(array, index, length) when is_binary(array) do
|
||||
<<_skip::binary-size(index), string_data::binary-size(length), _rest::binary>> = array
|
||||
to_string(string_data)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Reads a MapleStory-convention string from a byte array.
|
||||
Format: 2-byte little-endian length prefix + string data
|
||||
|
||||
## Parameters
|
||||
- array: Binary array
|
||||
- index: Starting position
|
||||
|
||||
## Returns
|
||||
- String extracted from the byte array
|
||||
"""
|
||||
@spec get_maple_string(binary(), non_neg_integer()) :: String.t()
|
||||
def get_maple_string(array, index) when is_binary(array) do
|
||||
length = get_short(array, index)
|
||||
get_string(array, index + 2, length)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Rotates bits of a byte left by count positions.
|
||||
|
||||
## Parameters
|
||||
- byte_val: Byte value (0-255)
|
||||
- count: Number of positions to rotate
|
||||
|
||||
## Returns
|
||||
- Rotated byte value
|
||||
"""
|
||||
@spec roll_left(byte(), non_neg_integer()) :: byte()
|
||||
def roll_left(byte_val, count) when is_integer(byte_val) and byte_val >= 0 and byte_val <= 255 do
|
||||
tmp = byte_val &&& 0xFF
|
||||
rotated = tmp <<< rem(count, 8)
|
||||
((rotated &&& 0xFF) ||| (rotated >>> 8)) &&& 0xFF
|
||||
end
|
||||
|
||||
@doc """
|
||||
Rotates bits of a byte right by count positions.
|
||||
|
||||
## Parameters
|
||||
- byte_val: Byte value (0-255)
|
||||
- count: Number of positions to rotate
|
||||
|
||||
## Returns
|
||||
- Rotated byte value
|
||||
"""
|
||||
@spec roll_right(byte(), non_neg_integer()) :: byte()
|
||||
def roll_right(byte_val, count) when is_integer(byte_val) and byte_val >= 0 and byte_val <= 255 do
|
||||
tmp = byte_val &&& 0xFF
|
||||
rotated = (tmp <<< 8) >>> rem(count, 8)
|
||||
((rotated &&& 0xFF) ||| (rotated >>> 8)) &&& 0xFF
|
||||
end
|
||||
|
||||
@doc """
|
||||
Repeats the first `count` bytes of `input` `mul` times.
|
||||
|
||||
## Parameters
|
||||
- input: Binary input
|
||||
- count: Number of bytes to repeat from the input
|
||||
- mul: Number of times to repeat
|
||||
|
||||
## Returns
|
||||
- Binary with repeated bytes
|
||||
"""
|
||||
@spec multiply_bytes(binary(), non_neg_integer(), non_neg_integer()) :: binary()
|
||||
def multiply_bytes(input, count, mul) when is_binary(input) do
|
||||
# Take first `count` bytes
|
||||
chunk = binary_part(input, 0, min(count, byte_size(input)))
|
||||
|
||||
# Repeat `mul` times
|
||||
1..mul
|
||||
|> Enum.map(fn _ -> chunk end)
|
||||
|> IO.iodata_to_binary()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts a double-precision float to a short by extracting high bits.
|
||||
|
||||
## Parameters
|
||||
- d: Double-precision float
|
||||
|
||||
## Returns
|
||||
- 16-bit integer extracted from the high bits
|
||||
"""
|
||||
@spec double_to_short_bits(float()) :: integer()
|
||||
def double_to_short_bits(d) when is_float(d) do
|
||||
# Convert double to 64-bit integer representation
|
||||
<<long_bits::signed-64>> = <<d::float>>
|
||||
|
||||
# Extract high 16 bits (shift right by 48)
|
||||
(long_bits >>> 48) &&& 0xFFFF
|
||||
end
|
||||
end
|
||||
34
lib/odinsea/world.ex
Normal file
34
lib/odinsea/world.ex
Normal file
@@ -0,0 +1,34 @@
|
||||
defmodule Odinsea.World do
|
||||
@moduledoc """
|
||||
World state manager.
|
||||
Coordinates cross-server state like parties, guilds, and families.
|
||||
Ported from Java World.java.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
# Client API
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
# Server Callbacks
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
Logger.info("World state initialized")
|
||||
|
||||
state = %{
|
||||
online_count: 0,
|
||||
channels: %{},
|
||||
parties: %{},
|
||||
guilds: %{},
|
||||
families: %{}
|
||||
}
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
end
|
||||
16
lib/odinsea/world/expedition.ex
Normal file
16
lib/odinsea/world/expedition.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule Odinsea.World.Expedition do
|
||||
@moduledoc """
|
||||
Expedition management service.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
{:ok, %{expeditions: %{}, next_id: 1}}
|
||||
end
|
||||
end
|
||||
16
lib/odinsea/world/family.ex
Normal file
16
lib/odinsea/world/family.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule Odinsea.World.Family do
|
||||
@moduledoc """
|
||||
Family management service.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
{:ok, %{families: %{}}}
|
||||
end
|
||||
end
|
||||
16
lib/odinsea/world/guild.ex
Normal file
16
lib/odinsea/world/guild.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule Odinsea.World.Guild do
|
||||
@moduledoc """
|
||||
Guild management service.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
{:ok, %{guilds: %{}}}
|
||||
end
|
||||
end
|
||||
16
lib/odinsea/world/messenger.ex
Normal file
16
lib/odinsea/world/messenger.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule Odinsea.World.Messenger do
|
||||
@moduledoc """
|
||||
Messenger (chat) management service.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
{:ok, %{messengers: %{}}}
|
||||
end
|
||||
end
|
||||
277
lib/odinsea/world/migration.ex
Normal file
277
lib/odinsea/world/migration.ex
Normal file
@@ -0,0 +1,277 @@
|
||||
defmodule Odinsea.World.Migration do
|
||||
@moduledoc """
|
||||
Character migration system for server transfers.
|
||||
Manages transfer tokens when moving between login/channel/cash shop servers.
|
||||
|
||||
Ported from Java handling.world.CharacterTransfer and World.ChannelChange_Data
|
||||
|
||||
In the Java version, this uses Redis for cross-server communication.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias Odinsea.Database.Context
|
||||
|
||||
@token_ttl 60 # Token expires after 60 seconds
|
||||
|
||||
@doc """
|
||||
Creates a migration token for character transfer.
|
||||
|
||||
Called when:
|
||||
- Player selects character (login -> channel)
|
||||
- Player changes channel (channel -> channel)
|
||||
- Player enters cash shop (channel -> cash shop)
|
||||
|
||||
## Parameters
|
||||
- character_id: Character ID being transferred
|
||||
- account_id: Account ID
|
||||
- source_server: :login, :channel, or :shop
|
||||
- target_channel: Target channel ID (or -10 for cash shop, -20 for MTS)
|
||||
- character_data: Optional character state to preserve during transfer
|
||||
|
||||
## Returns
|
||||
- {:ok, token_id} on success
|
||||
- {:error, reason} on failure
|
||||
"""
|
||||
def create_migration_token(character_id, account_id, source_server, target_channel, character_data \\ %{}) do
|
||||
token_id = generate_token_id()
|
||||
|
||||
token = %{
|
||||
id: token_id,
|
||||
character_id: character_id,
|
||||
account_id: account_id,
|
||||
source_server: source_server,
|
||||
target_channel: target_channel,
|
||||
character_data: character_data,
|
||||
created_at: System.system_time(:second),
|
||||
status: :pending
|
||||
}
|
||||
|
||||
# Store in ETS (local cache)
|
||||
:ets.insert(:migration_tokens, {token_id, token})
|
||||
|
||||
# Also store in Redis for cross-server visibility
|
||||
case store_in_redis(token_id, token) do
|
||||
:ok ->
|
||||
Logger.info("Created migration token #{token_id} for character #{character_id} to channel #{target_channel}")
|
||||
{:ok, token_id}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to store migration token in Redis: #{inspect(reason)}")
|
||||
# Still return OK since ETS has it (for single-node deployments)
|
||||
{:ok, token_id}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates a migration token when a character migrates into a server.
|
||||
|
||||
Called by InterServerHandler.MigrateIn when player connects to channel/shop.
|
||||
|
||||
## Parameters
|
||||
- token_id: The migration token ID
|
||||
- character_id: Expected character ID
|
||||
- target_server: :channel or :shop
|
||||
|
||||
## Returns
|
||||
- {:ok, token} if valid
|
||||
- {:error, reason} if invalid/expired
|
||||
"""
|
||||
def validate_migration_token(token_id, character_id, target_server) do
|
||||
# Try Redis first (for cross-server)
|
||||
token =
|
||||
case get_from_redis(token_id) do
|
||||
{:ok, nil} ->
|
||||
# Try ETS (local fallback)
|
||||
get_from_ets(token_id)
|
||||
|
||||
{:ok, token} ->
|
||||
token
|
||||
|
||||
{:error, _} ->
|
||||
get_from_ets(token_id)
|
||||
end
|
||||
|
||||
cond do
|
||||
is_nil(token) ->
|
||||
Logger.warning("Migration token #{token_id} not found")
|
||||
{:error, :token_not_found}
|
||||
|
||||
token.character_id != character_id ->
|
||||
Logger.warning("Migration token character mismatch: expected #{character_id}, got #{token.character_id}")
|
||||
{:error, :character_mismatch}
|
||||
|
||||
token_expired?(token) ->
|
||||
Logger.warning("Migration token #{token_id} expired")
|
||||
delete_token(token_id)
|
||||
{:error, :token_expired}
|
||||
|
||||
token.status != :pending ->
|
||||
Logger.warning("Migration token #{token_id} already used")
|
||||
{:error, :token_already_used}
|
||||
|
||||
true ->
|
||||
# Mark as used
|
||||
update_token_status(token_id, :used)
|
||||
{:ok, token}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets pending character data for a migration.
|
||||
Returns nil if no pending migration exists.
|
||||
"""
|
||||
def get_pending_character(character_id) do
|
||||
# Search for pending tokens for this character
|
||||
case :ets.select(:migration_tokens, [
|
||||
{{:_, %{character_id: character_id, status: :pending}}, [], [:_]}])
|
||||
do
|
||||
[{_key, token}] -> token
|
||||
[] -> nil
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a migration token.
|
||||
"""
|
||||
def delete_token(token_id) do
|
||||
:ets.delete(:migration_tokens, token_id)
|
||||
delete_from_redis(token_id)
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up expired tokens.
|
||||
Should be called periodically.
|
||||
"""
|
||||
def cleanup_expired_tokens do
|
||||
now = System.system_time(:second)
|
||||
|
||||
expired = :ets.select(:migration_tokens, [
|
||||
{{:_, %{created_at: :"$1", id: :"$2"}},
|
||||
[{:<, {:+, :"$1", @token_ttl}, now}],
|
||||
[:"$2"]}
|
||||
])
|
||||
|
||||
Enum.each(expired, fn token_id ->
|
||||
Logger.debug("Cleaning up expired migration token #{token_id}")
|
||||
delete_token(token_id)
|
||||
end)
|
||||
|
||||
length(expired)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the count of pending migrations (for server capacity checking).
|
||||
"""
|
||||
def pending_count do
|
||||
:ets.info(:migration_tokens, :size)
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# GenServer Callbacks for Token Cleanup
|
||||
# ==================================================================================================
|
||||
|
||||
use GenServer
|
||||
|
||||
def start_link(_opts) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
# Create ETS table for migration tokens
|
||||
:ets.new(:migration_tokens, [
|
||||
:set,
|
||||
:public,
|
||||
:named_table,
|
||||
read_concurrency: true,
|
||||
write_concurrency: true
|
||||
])
|
||||
|
||||
# Schedule cleanup every 30 seconds
|
||||
schedule_cleanup()
|
||||
|
||||
{:ok, %{}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:cleanup, state) do
|
||||
count = cleanup_expired_tokens()
|
||||
|
||||
if count > 0 do
|
||||
Logger.debug("Cleaned up #{count} expired migration tokens")
|
||||
end
|
||||
|
||||
schedule_cleanup()
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
defp schedule_cleanup do
|
||||
Process.send_after(self(), :cleanup, 30_000)
|
||||
end
|
||||
|
||||
# ==================================================================================================
|
||||
# Private Functions
|
||||
# ==================================================================================================
|
||||
|
||||
defp generate_token_id do
|
||||
:crypto.strong_rand_bytes(16)
|
||||
|> Base.encode16(case: :lower)
|
||||
end
|
||||
|
||||
defp token_expired?(token) do
|
||||
now = System.system_time(:second)
|
||||
now > token.created_at + @token_ttl
|
||||
end
|
||||
|
||||
defp update_token_status(token_id, status) do
|
||||
case :ets.lookup(:migration_tokens, token_id) do
|
||||
[{^token_id, token}] ->
|
||||
updated = %{token | status: status}
|
||||
:ets.insert(:migration_tokens, {token_id, updated})
|
||||
store_in_redis(token_id, updated)
|
||||
|
||||
[] ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp get_from_ets(token_id) do
|
||||
case :ets.lookup(:migration_tokens, token_id) do
|
||||
[{^token_id, token}] -> token
|
||||
[] -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Redis operations (for cross-server communication)
|
||||
|
||||
defp store_in_redis(token_id, token) do
|
||||
case Redix.command(:redix, ["SETEX", "migration:#{token_id}", @token_ttl, :erlang.term_to_binary(token)]) do
|
||||
{:ok, _} -> :ok
|
||||
error -> error
|
||||
end
|
||||
rescue
|
||||
_ -> {:error, :redis_unavailable}
|
||||
end
|
||||
|
||||
defp get_from_redis(token_id) do
|
||||
case Redix.command(:redix, ["GET", "migration:#{token_id}"]) do
|
||||
{:ok, nil} -> {:ok, nil}
|
||||
{:ok, data} -> {:ok, :erlang.binary_to_term(data)}
|
||||
error -> error
|
||||
end
|
||||
rescue
|
||||
_ -> {:error, :redis_unavailable}
|
||||
end
|
||||
|
||||
defp delete_from_redis(token_id) do
|
||||
case Redix.command(:redix, ["DEL", "migration:#{token_id}"]) do
|
||||
{:ok, _} -> :ok
|
||||
error -> error
|
||||
end
|
||||
rescue
|
||||
_ -> {:ok}
|
||||
end
|
||||
end
|
||||
16
lib/odinsea/world/party.ex
Normal file
16
lib/odinsea/world/party.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule Odinsea.World.Party do
|
||||
@moduledoc """
|
||||
Party management service.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
def start_link(_) do
|
||||
GenServer.start_link(__MODULE__, [], name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_) do
|
||||
{:ok, %{parties: %{}, next_id: 1}}
|
||||
end
|
||||
end
|
||||
37
lib/odinsea/world/supervisor.ex
Normal file
37
lib/odinsea/world/supervisor.ex
Normal file
@@ -0,0 +1,37 @@
|
||||
defmodule Odinsea.World.Supervisor do
|
||||
@moduledoc """
|
||||
Supervisor for the game world services.
|
||||
Manages parties, guilds, families, and global state.
|
||||
"""
|
||||
|
||||
use Supervisor
|
||||
|
||||
def start_link(init_arg) do
|
||||
Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_init_arg) do
|
||||
children = [
|
||||
# World state
|
||||
Odinsea.World,
|
||||
|
||||
# Party management
|
||||
Odinsea.World.Party,
|
||||
|
||||
# Guild management
|
||||
Odinsea.World.Guild,
|
||||
|
||||
# Family management
|
||||
Odinsea.World.Family,
|
||||
|
||||
# Expedition management
|
||||
Odinsea.World.Expedition,
|
||||
|
||||
# Messenger system
|
||||
Odinsea.World.Messenger
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
end
|
||||
57
mix.exs
Normal file
57
mix.exs
Normal file
@@ -0,0 +1,57 @@
|
||||
defmodule Odinsea.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
def project do
|
||||
[
|
||||
app: :odinsea,
|
||||
version: "0.1.0",
|
||||
elixir: "~> 1.17",
|
||||
start_permanent: Mix.env() == :prod,
|
||||
elixirc_paths: elixirc_paths(Mix.env()),
|
||||
deps: deps()
|
||||
]
|
||||
end
|
||||
|
||||
# Run "mix help compile.app" to learn about applications.
|
||||
def application do
|
||||
[
|
||||
extra_applications: [:logger, :crypto],
|
||||
mod: {Odinsea.Application, []}
|
||||
]
|
||||
end
|
||||
|
||||
defp elixirc_paths(:test), do: ["lib", "test/support"]
|
||||
defp elixirc_paths(_), do: ["lib"]
|
||||
|
||||
# Run "mix help deps" to learn about dependencies.
|
||||
defp deps do
|
||||
[
|
||||
# Networking
|
||||
{:ranch, "~> 2.1"},
|
||||
{:gen_state_machine, "~> 3.0"},
|
||||
|
||||
# Database
|
||||
{:ecto_sql, "~> 3.11"},
|
||||
{:myxql, "~> 0.7"},
|
||||
{:redix, "~> 1.2"},
|
||||
|
||||
# Scripting
|
||||
# {:quickjs, "~> 0.1"}, # JavaScript support - enable when needed
|
||||
|
||||
# Observability
|
||||
{:telemetry, "~> 1.2"},
|
||||
{:logger_file_backend, "~> 0.0.11"},
|
||||
|
||||
# Utilities
|
||||
{:jason, "~> 1.4"},
|
||||
{:decimal, "~> 2.1"},
|
||||
{:timex, "~> 3.7"},
|
||||
{:poolboy, "~> 1.5"},
|
||||
{:nimble_pool, "~> 1.0"},
|
||||
|
||||
# Development
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false},
|
||||
{:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
|
||||
]
|
||||
end
|
||||
end
|
||||
34
mix.lock
Normal file
34
mix.lock
Normal file
@@ -0,0 +1,34 @@
|
||||
%{
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
|
||||
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
|
||||
"credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"},
|
||||
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
|
||||
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"},
|
||||
"erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"gen_state_machine": {:hex, :gen_state_machine, "3.0.0", "1e57f86a494e5c6b14137ebef26a7eb342b3b0070c7135f2d6768ed3f6b6cdff", [:mix], [], "hexpm", "0a59652574bebceb7309f6b749d2a41b45fdeda8dbb4da0791e355dd19f0ed15"},
|
||||
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
|
||||
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
|
||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"logger_file_backend": {:hex, :logger_file_backend, "0.0.14", "774bb661f1c3fed51b624d2859180c01e386eb1273dc22de4f4a155ef749a602", [:mix], [], "hexpm", "071354a18196468f3904ef09413af20971d55164267427f6257b52cfba03f9e6"},
|
||||
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
|
||||
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
|
||||
"myxql": {:hex, :myxql, "0.8.0", "60c60e87c7320d2f5759416aa1758c8e7534efbae07b192861977f8455e35acd", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:geo, "~> 3.4 or ~> 4.0", [hex: :geo, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "1ec0ceb26fb3cd0f8756519cf4f0e4f9348177a020705223bdf4742a2c44d774"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
|
||||
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
|
||||
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
|
||||
"redix": {:hex, :redix, "1.5.3", "4eaae29c75e3285c0ff9957046b7c209aa7f72a023a17f0a9ea51c2a50ab5b0f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7b06fb5246373af41f5826b03334dfa3f636347d4d5d98b4d455b699d425ae7e"},
|
||||
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
|
||||
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||
"timex": {:hex, :timex, "3.7.13", "0688ce11950f5b65e154e42b47bf67b15d3bc0e0c3def62199991b8a8079a1e2", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "09588e0522669328e973b8b4fd8741246321b3f0d32735b589f78b136e6d4c54"},
|
||||
"tzdata": {:hex, :tzdata, "1.1.3", "b1cef7bb6de1de90d4ddc25d33892b32830f907e7fc2fccd1e7e22778ab7dfbc", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d4ca85575a064d29d4e94253ee95912edfb165938743dbf002acdf0dcecb0c28"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
}
|
||||
8
test/odinsea_test.exs
Normal file
8
test/odinsea_test.exs
Normal file
@@ -0,0 +1,8 @@
|
||||
defmodule OdinseaTest do
|
||||
use ExUnit.Case
|
||||
doctest Odinsea
|
||||
|
||||
test "greets the world" do
|
||||
assert Odinsea.hello() == :world
|
||||
end
|
||||
end
|
||||
1
test/test_helper.exs
Normal file
1
test/test_helper.exs
Normal file
@@ -0,0 +1 @@
|
||||
ExUnit.start()
|
||||
Reference in New Issue
Block a user