Code Sketch


yoi steal an egg
By: Mhalsakant School
Category: Programming
import java.io.{DataInputStream, DataOutputStream}
import java.net.{DatagramPacket, DatagramSocket, Inet4Address, InetAddress, InetSocketAddress, NetworkInterface, ServerSocket, Socket, SocketTimeoutException}
import javax.net.ssl.{SSLServerSocketFactory, SSLSocketFactory}
import java.nio.charset.StandardCharsets
import java.util.Base64

object GameProtocol {
  val DefaultPort = 43871
  val ProtocolVersion = "STEAL-EGG-YADNESh-1"

  def enc(s: String): String = Base64.getUrlEncoder.withoutPadding()
    .encodeToString(Option(s).getOrElse("").getBytes(StandardCharsets.UTF_8))

  def dec(s: String): String = try {
    new String(Base64.getUrlDecoder.decode(s), StandardCharsets.UTF_8)
  } catch { case _: Throwable => "" }

  def pack(command: String, args: String*): String =
    (command +: args.map(enc)).mkString("|")

  def unpack(line: String): (String, Vector[String]) = {
    val p = line.split("\\|", -1).toVector
    if (p.isEmpty) ("", Vector.empty)
    else (p.head, p.drop(1).map(dec))
  }

  def serverSocket(port: Int): ServerSocket = {
    if (java.lang.Boolean.getBoolean("stealegg.tls"))
      SSLServerSocketFactory.getDefault.createServerSocket(port)
    else new ServerSocket(port)
  }

  def clientSocket(host: String, port: Int): Socket = {
    if (!java.lang.Boolean.getBoolean("stealegg.tls")) new Socket(host, port)
    else {
      val s = SSLSocketFactory.getDefault.createSocket(host, port)
        .asInstanceOf[javax.net.ssl.SSLSocket]
      val p = s.getSSLParameters
      p.setEndpointIdentificationAlgorithm("HTTPS")
      s.setSSLParameters(p)
      s.startHandshake()
      s
    }
  }

  // LAN discovery works only on the same local network. It does not discover
  // a server across the public Internet or bypass router/firewall rules.
  val DiscoveryPort = 43872
  private val DiscoveryRequest = "STEAL_EGG_DISCOVER_V1"
  private val DiscoveryResponse = "STEAL_EGG_SERVER_V1"

  def localIPv4Address(): String = {
    val found = scala.collection.mutable.ArrayBuffer.empty[String]
    try {
      val interfaces = NetworkInterface.getNetworkInterfaces
      while (interfaces != null && interfaces.hasMoreElements) {
        val ni = interfaces.nextElement()
        if (ni.isUp && !ni.isLoopback) {
          val addresses = ni.getInetAddresses
          while (addresses.hasMoreElements) {
            val address = addresses.nextElement()
            if (address.isInstanceOf[Inet4Address] && !address.isLoopbackAddress)
              found += address.getHostAddress
          }
        }
      }
    } catch { case _: Exception => () }
    found.headOption.getOrElse("127.0.0.1")
  }

  def startDiscoveryResponder(serverPort: Int): Thread = {
    val t = new Thread(new Runnable {
      override def run(): Unit = {
        var socket: DatagramSocket = null
        try {
          socket = new DatagramSocket(null)
          socket.setReuseAddress(true)
          socket.bind(new InetSocketAddress(DiscoveryPort))
          socket.setBroadcast(true)
          while (!socket.isClosed) {
            val bytes = new Array[Byte](128)
            val request = new DatagramPacket(bytes, bytes.length)
            socket.receive(request)
            val message = new String(request.getData, request.getOffset,
              request.getLength, StandardCharsets.UTF_8).trim
            if (message == DiscoveryRequest) {
              val response = (DiscoveryResponse + "|" + serverPort + "|" + ProtocolVersion)
                .getBytes(StandardCharsets.UTF_8)
              socket.send(new DatagramPacket(response, response.length,
                request.getAddress, request.getPort))
            }
          }
        } catch {
          case _: java.net.SocketException => ()
          case e: Exception => System.err.println("LAN discovery stopped: " + e.getMessage)
        } finally {
          if (socket != null) socket.close()
        }
      }
    }, "steal-egg-lan-discovery")
    t.setDaemon(true)
    t.start()
    t
  }

  def discoverServer(timeoutMillis: Int = 1800): Option[(String, Int)] = {
    var socket: DatagramSocket = null
    try {
      socket = new DatagramSocket()
      socket.setBroadcast(true)
      socket.setSoTimeout(250)
      val payload = DiscoveryRequest.getBytes(StandardCharsets.UTF_8)
      val broadcasts = scala.collection.mutable.ArrayBuffer.empty[InetAddress]
      broadcasts += InetAddress.getByName("255.255.255.255")
      try {
        val interfaces = NetworkInterface.getNetworkInterfaces
        while (interfaces != null && interfaces.hasMoreElements) {
          val ni = interfaces.nextElement()
          if (ni.isUp && !ni.isLoopback) {
            val addresses = ni.getInterfaceAddresses
            val it = addresses.iterator()
            while (it.hasNext) {
              val broadcast = it.next().getBroadcast
              if (broadcast != null) broadcasts += broadcast
            }
          }
        }
      } catch { case _: Exception => () }
      broadcasts.distinct.foreach { address =>
        try socket.send(new DatagramPacket(payload, payload.length, address, DiscoveryPort))
        catch { case _: Exception => () }
      }
      val deadline = System.nanoTime() + timeoutMillis.toLong * 1000000L
      while (System.nanoTime() < deadline) {
        val bytes = new Array[Byte](256)
        val packet = new DatagramPacket(bytes, bytes.length)
        try {
          socket.receive(packet)
          val message = new String(packet.getData, packet.getOffset,
            packet.getLength, StandardCharsets.UTF_8).trim
          val parts = message.split("\\|", -1)
          if (parts.length == 3 && parts(0) == DiscoveryResponse && parts(2) == ProtocolVersion) {
            val discoveredPort = scala.util.Try(parts(1).toInt).getOrElse(0)
            if (discoveredPort >= 1 && discoveredPort <= 65535)
              return Some((packet.getAddress.getHostAddress, discoveredPort))
          }
        } catch {
          case _: SocketTimeoutException => ()
        }
      }
      None
    } catch {
      case _: Exception => None
    } finally {
      if (socket != null) socket.close()
    }
  }

  case class PlayerView(name: String, x: Double, y: Double,
                        baseX: Double, baseY: Double, role: String)
  case class EggView(id: String, name: String, rarity: String,
                     biome: String, x: Double, y: Double)
  case class EggItem(id: String, name: String, rarity: String,
                     biome: String, hatchAt: Long)
  case class PetView(name: String, rarity: String, income: Long)

  case class Snapshot(coins: Long, speed: Int, role: String,
                      baseLevel: Int, x: Double, y: Double,
                      eventTitle: String, eventText: String,
                      announcement: String, updateNote: String,
                      boostMultiplier: Double, boostUntil: Long,
                      players: Vector[PlayerView], worldEggs: Vector[EggView],
                      eggs: Vector[EggItem], pets: Vector[PetView])

  def writeSnapshot(out: DataOutputStream, s: Snapshot): Unit = {
    out.writeUTF("STATE")
    out.writeLong(s.coins); out.writeInt(s.speed); out.writeUTF(s.role)
    out.writeInt(s.baseLevel); out.writeDouble(s.x); out.writeDouble(s.y)
    out.writeUTF(s.eventTitle); out.writeUTF(s.eventText)
    out.writeUTF(s.announcement); out.writeUTF(s.updateNote)
    out.writeDouble(s.boostMultiplier); out.writeLong(s.boostUntil)
    out.writeInt(s.players.size)
    s.players.foreach { p =>
      out.writeUTF(p.name); out.writeDouble(p.x); out.writeDouble(p.y)
      out.writeDouble(p.baseX); out.writeDouble(p.baseY); out.writeUTF(p.role)
    }
    out.writeInt(s.worldEggs.size)
    s.worldEggs.foreach { e =>
      out.writeUTF(e.id); out.writeUTF(e.name); out.writeUTF(e.rarity)
      out.writeUTF(e.biome); out.writeDouble(e.x); out.writeDouble(e.y)
    }
    out.writeInt(s.eggs.size)
    s.eggs.foreach { e =>
      out.writeUTF(e.id); out.writeUTF(e.name); out.writeUTF(e.rarity)
      out.writeUTF(e.biome); out.writeLong(e.hatchAt)
    }
    out.writeInt(s.pets.size)
    s.pets.foreach { p =>
      out.writeUTF(p.name); out.writeUTF(p.rarity); out.writeLong(p.income)
    }
    out.flush()
  }

  def readSnapshot(in: DataInputStream): Snapshot = {
    val marker = in.readUTF()
    if (marker != "STATE") throw new java.io.IOException("Invalid state packet")
    val coins = in.readLong(); val speed = in.readInt(); val role = in.readUTF()
    val baseLevel = in.readInt(); val x = in.readDouble(); val y = in.readDouble()
    val eventTitle = in.readUTF(); val eventText = in.readUTF()
    val announcement = in.readUTF(); val updateNote = in.readUTF()
    val boost = in.readDouble(); val boostUntil = in.readLong()
    val pc = math.max(0, math.min(64, in.readInt()))
    val players = Vector.fill(pc) {
      PlayerView(in.readUTF(), in.readDouble(), in.readDouble(),
        in.readDouble(), in.readDouble(), in.readUTF())
    }
    val ec = math.max(0, math.min(256, in.readInt()))
    val worldEggs = Vector.fill(ec) {
      EggView(in.readUTF(), in.readUTF(), in.readUTF(), in.readUTF(),
        in.readDouble(), in.readDouble())
    }
    val ic = math.max(0, math.min(128, in.readInt()))
    val eggs = Vector.fill(ic) {
      EggItem(in.readUTF(), in.readUTF(), in.readUTF(), in.readUTF(), in.readLong())
    }
    val petCount = math.max(0, math.min(128, in.readInt()))
    val pets = Vector.fill(petCount) {
      PetView(in.readUTF(), in.readUTF(), in.readLong())
    }
    Snapshot(coins, speed, role, baseLevel, x, y, eventTitle, eventText,
      announcement, updateNote, boost, boostUntil, players, worldEggs, eggs, pets)
  }
}

import java.io._
import java.net._
import java.security.SecureRandom
import java.util.Properties
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
import scala.collection.mutable
import scala.util.Try
import GameProtocol._

/**
 * Authoritative game server. Run with: sbt "runMain StealAnEggServer"
 * LAN prototype is enabled by default. For public internet use TLS and a trusted
 * certificate: set -Dstealegg.tls=true and configure the JVM keyStore/trustStore.
 */
object StealAnEggServer {
  private val port = Try(sys.props.getOrElse("stealegg.port", DefaultPort.toString).toInt)
    .getOrElse(DefaultPort)
  private val dataDir = new File(System.getProperty("user.home"), ".steal_an_egg_yadnesh")
  private val accountFile = new File(dataDir, "accounts.properties")
  private val rng = new SecureRandom()
  private val lock = new Object
  private val MaxPlayers = 10
  private val accounts = mutable.Map.empty[String, Account]
  private val sessions = mutable.Map.empty[String, Session]
  private val worldEggs = mutable.ArrayBuffer.empty[WorldEgg]
  private var announcement = "Welcome! Find an egg, hatch a pet and grow your base."
  private var eventTitle = "SPRING EGG FESTIVAL"
  private var eventText = "A new adventure begins!"
  private var updateNote = "Game build 0.1.0"
  private var boostMultiplier = 1.0
  private var boostUntil = 0L
  // Owner bootstrap credential requested for this private game prototype.
  // Override it before public hosting with -Dstealegg.owner.password=YOUR_NEW_SECRET
  // or the STEAL_EGG_OWNER_PASSWORD environment variable.
  private val ownerBootstrapPassword =
    sys.props.get("stealegg.owner.password")
      .orElse(Option(System.getenv("STEAL_EGG_OWNER_PASSWORD")))
      .filter(_.nonEmpty)
      .getOrElse("1772admin")
  @volatile private var running = true

  private case class Biome(name: String, gate: Int, xStart: Double, color: String)
  private val biomes = Vector(
    Biome("Green Meadow", 0, 0, "meadow"),
    Biome("Whispering Forest", 5, 24, "forest"),
    Biome("Crystal Lake", 10, 48, "crystal-lake"),
    Biome("Golden Desert", 15, 72, "desert"),
    Biome("Mystic Jungle", 20, 96, "jungle"),
    Biome("Savannah", 30, 120, "savannah"),
    Biome("Sakura Valley", 40, 144, "sakura"),
    Biome("Frost Peaks", 50, 168, "frost"),
    Biome("Ice Cavern", 60, 192, "ice"),
    Biome("Candy Fields", 70, 216, "candy"),
    Biome("Mushroom Woods", 85, 240, "mushroom"),
    Biome("Coral Reef", 100, 264, "coral"),
    Biome("Deep Ocean", 120, 288, "deep-ocean"),
    Biome("Volcanic Crater", 140, 312, "volcano"),
    Biome("Prehistoric Wilds", 165, 336, "prehistoric"),
    Biome("Sky Islands", 190, 360, "sky"),
    Biome("Cloud Kingdom", 220, 384, "cloud"),
    Biome("Crystal Caverns", 250, 408, "crystal"),
    Biome("Cyber City", 285, 432, "cyber"),
    Biome("Cosmic Realm", 320, 456, "cosmic"),
    Biome("Void Dimension", 360, 480, "void")
  )
  private case class EggDef(name: String, rarity: String, biome: String, income: Long)
  private val eggDefs = Vector(
    EggDef("Meadow Egg", "Common", "Green Meadow", 2),
    EggDef("Forest Egg", "Uncommon", "Whispering Forest", 4),
    EggDef("Crystal Lake Egg", "Rare", "Crystal Lake", 7),
    EggDef("Dune Egg", "Rare", "Golden Desert", 12),
    EggDef("Tiger Egg", "Epic", "Mystic Jungle", 18),
    EggDef("Savannah Egg", "Epic", "Savannah", 25),
    EggDef("Sakura Egg", "Epic", "Sakura Valley", 34),
    EggDef("Frost Egg", "Legendary", "Frost Peaks", 48),
    EggDef("Glacier Egg", "Legendary", "Ice Cavern", 62),
    EggDef("Candy Egg", "Legendary", "Candy Fields", 82),
    EggDef("Mushroom Egg", "Mythic", "Mushroom Woods", 110),
    EggDef("Coral Egg", "Mythic", "Coral Reef", 145),
    EggDef("Abyss Egg", "Mythic", "Deep Ocean", 190),
    EggDef("Phoenix Egg", "Mythic", "Volcanic Crater", 250),
    EggDef("Dino Egg", "Secret", "Prehistoric Wilds", 330),
    EggDef("Sky Egg", "Secret", "Sky Islands", 430),
    EggDef("Cloud Egg", "Secret", "Cloud Kingdom", 560),
    EggDef("Prism Egg", "Secret", "Crystal Caverns", 720),
    EggDef("Cyber Egg", "Celestial", "Cyber City", 920),
    EggDef("Galaxy Egg", "Celestial", "Cosmic Realm", 1200),
    EggDef("Void Egg", "Celestial", "Void Dimension", 1600)
  )

  private class Account(val username: String, var salt: Array[Byte], var hash: Array[Byte],
                        var coins: Long, var speed: Int, var role: String,
                        var baseLevel: Int, val baseX: Double, val baseY: Double) {
    val eggs = mutable.ArrayBuffer.empty[EggItem]
    val pets = mutable.ArrayBuffer.empty[PetView]
    var lastIncome = System.currentTimeMillis()
  }
  private case class WorldEgg(id: String, name: String, rarity: String,
                              biome: String, x: Double, y: Double, income: Long)
  private class Session(val socket: Socket, val in: DataInputStream,
                        val out: DataOutputStream, val account: Account,
                        var x: Double, var y: Double) {
    var lastTrain = 0L
    var lastMove = System.currentTimeMillis()
  }

  private def safeName(s: String): Boolean =
    s != null && s.matches("[A-Za-z0-9_]{3,18}")

  private def norm(s: String): String = s.toLowerCase(java.util.Locale.ROOT)

  private def randomBytes(n: Int): Array[Byte] = {
    val b = new Array[Byte](n); rng.nextBytes(b); b
  }

  private def hashPassword(password: String, salt: Array[Byte]): Array[Byte] = {
    val spec = new PBEKeySpec(password.toCharArray, salt, 160000, 256)
    try SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded
    finally spec.clearPassword()
  }

  private def verifyPassword(a: Account, password: String): Boolean = {
    val actual = hashPassword(password, a.salt)
    java.security.MessageDigest.isEqual(actual, a.hash)
  }

  private def freshBase(name: String): (Double, Double) = {
    val h = math.abs(name.hashCode.toLong)
    (3.0 + (h % 5).toDouble * 2.2, 3.0 + ((h / 5) % 4).toDouble * 2.0)
  }

  private def encodeRecord(fields: String*): String = fields.map(GameProtocol.enc).mkString("~")
  private def decodeRecord(s: String): Vector[String] = s.split("~", -1).toVector.map(GameProtocol.dec)

  private def loadAccounts(): Unit = lock.synchronized {
    if (accountFile.exists()) {
      val p = new Properties()
      val in = new FileInputStream(accountFile)
      try p.load(in) finally in.close()
      val count = Try(p.getProperty("count", "0").toInt).getOrElse(0).max(0).min(10000)
      for (i <- 0 until count) {
      val pre = "a." + i + "."
      val name = p.getProperty(pre + "name", "")
      if (name.nonEmpty) {
        val salt = Try(java.util.Base64.getDecoder.decode(p.getProperty(pre + "salt", ""))).getOrElse(Array.emptyByteArray)
        val hash = Try(java.util.Base64.getDecoder.decode(p.getProperty(pre + "hash", ""))).getOrElse(Array.emptyByteArray)
        if (salt.nonEmpty && hash.nonEmpty) {
          val (bx, by) = freshBase(name)
          val a = new Account(name, salt, hash,
            Try(p.getProperty(pre + "coins", "100").toLong).getOrElse(100L),
            Try(p.getProperty(pre + "speed", "1").toInt).getOrElse(1),
            p.getProperty(pre + "role", "PLAYER"),
            Try(p.getProperty(pre + "baseLevel", "1").toInt).getOrElse(1),
            Try(p.getProperty(pre + "baseX", bx.toString).toDouble).getOrElse(bx),
            Try(p.getProperty(pre + "baseY", by.toString).toDouble).getOrElse(by)
          )
          p.getProperty(pre + "eggs", "").split(";").filter(_.nonEmpty).foreach { rec =>
            val f = decodeRecord(rec)
            if (f.size == 5) a.eggs += EggItem(f(0), f(1), f(2), f(3), Try(f(4).toLong).getOrElse(0L))
          }
          p.getProperty(pre + "pets", "").split(";").filter(_.nonEmpty).foreach { rec =>
            val f = decodeRecord(rec)
            if (f.size == 3) a.pets += PetView(f(0), f(1), Try(f(2).toLong).getOrElse(1L))
          }
          accounts(norm(name)) = a
        }
      }
      }
    }
  }

  private def saveAccounts(): Unit = lock.synchronized {
    dataDir.mkdirs()
    val p = new Properties()
    val sorted = accounts.values.toVector.sortBy(_.username.toLowerCase(java.util.Locale.ROOT))
    p.setProperty("count", sorted.size.toString)
    sorted.zipWithIndex.foreach { case (a, i) =>
      val pre = "a." + i + "."
      p.setProperty(pre + "name", a.username)
      p.setProperty(pre + "salt", java.util.Base64.getEncoder.encodeToString(a.salt))
      p.setProperty(pre + "hash", java.util.Base64.getEncoder.encodeToString(a.hash))
      p.setProperty(pre + "coins", a.coins.toString)
      p.setProperty(pre + "speed", a.speed.toString)
      p.setProperty(pre + "role", a.role)
      p.setProperty(pre + "baseLevel", a.baseLevel.toString)
      p.setProperty(pre + "baseX", a.baseX.toString)
      p.setProperty(pre + "baseY", a.baseY.toString)
      p.setProperty(pre + "eggs", a.eggs.map(e => encodeRecord(e.id, e.name, e.rarity, e.biome, e.hatchAt.toString)).mkString(";"))
      p.setProperty(pre + "pets", a.pets.map(e => encodeRecord(e.name, e.rarity, e.income.toString)).mkString(";"))
    }
    val tmp = new File(dataDir, "accounts.properties.tmp")
    val out = new FileOutputStream(tmp)
    try p.store(out, "Steal An Egg - Yadnesh Edition accounts") finally out.close()
    try {
      java.nio.file.Files.move(tmp.toPath, accountFile.toPath,
        java.nio.file.StandardCopyOption.REPLACE_EXISTING,
        java.nio.file.StandardCopyOption.ATOMIC_MOVE)
    } catch {
      case _: java.nio.file.AtomicMoveNotSupportedException =>
        java.nio.file.Files.move(tmp.toPath, accountFile.toPath,
          java.nio.file.StandardCopyOption.REPLACE_EXISTING)
    }
  }

  private def eggId(): String = java.util.UUID.randomUUID().toString.take(12)

  private def spawnEgg(biomeName: String, rarityOverride: String = ""): Unit = {
    val idx = biomes.indexWhere(_.name.equalsIgnoreCase(biomeName))
    if (idx < 0) return
    val choices = eggDefs.filter(_.biome.equalsIgnoreCase(biomeName))
    val base = choices.headOption.getOrElse(eggDefs.head)
    val rarity = if (rarityOverride.nonEmpty) rarityOverride else base.rarity
    val x = biomes(idx).xStart + 4.0 + rng.nextDouble() * 14.0
    val y = 5.0 + rng.nextDouble() * 9.0
    worldEggs += WorldEgg(eggId(), base.name, rarity, biomeName, x, y, base.income)
  }

  private def seedWorld(): Unit = lock.synchronized {
    if (worldEggs.nonEmpty) return
    biomes.foreach { b =>
      val count = if (b.name == "Forest") 5 else 3
      for (_ <- 0 until count) spawnEgg(b.name)
    }
  }

  private def biomeIndex(x: Double): Int =
    math.max(0, math.min(biomes.size - 1, (x / 24.0).toInt))

  private def canEnter(a: Account, x: Double): Boolean =
    a.speed >= biomes(biomeIndex(x)).gate

  private def nearestBase(x: Double, y: Double, target: Account): Boolean =
    math.hypot(x - target.baseX, y - target.baseY) <= 3.0

  private def buildSnapshot(s: Session): Snapshot = lock.synchronized {
    val now = System.currentTimeMillis()
    val players = sessions.values.toVector.map { p =>
      PlayerView(p.account.username, p.x, p.y, p.account.baseX, p.account.baseY, p.account.role)
    }
    val eggs = worldEggs.map(e => EggView(e.id, e.name, e.rarity, e.biome, e.x, e.y)).toVector
    Snapshot(s.account.coins, s.account.speed, s.account.role, s.account.baseLevel,
      s.x, s.y, eventTitle, eventText, announcement, updateNote,
      if (now < boostUntil) boostMultiplier else 1.0, boostUntil,
      players, eggs, s.account.eggs.toVector, s.account.pets.toVector)
  }

  private def handle(s: Session, command: String, args: Vector[String]): Boolean = lock.synchronized {
    val now = System.currentTimeMillis()
    def arg(i: Int): String = args.lift(i).getOrElse("")
    def isAdmin: Boolean = s.account.role == "OWNER" || s.account.role == "ADMIN"
    def isOwner: Boolean = s.account.role == "OWNER"
    command match {
      case "SYNC" => writeSnapshot(s.out, buildSnapshot(s))
      case "MOVE" =>
        val nx = Try(arg(0).toDouble).getOrElse(s.x)
        val ny = Try(arg(1).toDouble).getOrElse(s.y)
        val dx = nx - s.x; val dy = ny - s.y
        val dt = math.max(0.02, math.min(0.5, (now - s.lastMove) / 1000.0))
        val maxStep = (1.7 + s.account.speed.toDouble / 180.0) * dt + 0.22
        val worldMaxX = biomes.size * 24.0 - 1.0
        val safeNx = math.max(0.6, math.min(worldMaxX, nx))
        if (math.hypot(dx, dy) <= maxStep && ny >= 1.0 && ny <= 19.0) {
          if (canEnter(s.account, safeNx)) s.x = safeNx
          else s.x = math.max(0.6, math.min(safeNx, (biomeIndex(safeNx) * 24.0) - 0.4))
          s.y = ny
        }
        s.lastMove = now
      case "TRAIN" =>
        if (now - s.lastTrain >= 800L) {
          if (isAdmin) s.account.speed = 100000000
          else s.account.speed = math.min(100000, s.account.speed + 2)
          s.lastTrain = now
          announcement = s.account.username + " trained on the treadmill! Speed +2."
          saveAccounts()
        }
      case "UPGRADE_SPEED" =>
        val cost = 100L + s.account.speed.toLong * 8L
        if (s.account.coins >= cost) {
          s.account.coins -= cost
          if (isAdmin) s.account.speed = 100000000
          else s.account.speed = math.min(100000, s.account.speed + 20)
          announcement = s.account.username + " upgraded Speed!"
          saveAccounts()
        }
      case "UPGRADE_BASE" =>
        val cost = 250L * s.account.baseLevel
        if (s.account.coins >= cost && s.account.baseLevel < 20) {
          s.account.coins -= cost
          // baseLevel is mutable in Account
          s.account.baseLevel += 1
          announcement = s.account.username + " upgraded their base!"
          saveAccounts()
        }
      case "COLLECT" =>
        val id = arg(0)
        worldEggs.indexWhere(_.id == id) match {
          case -1 => ()
          case ix =>
            val e = worldEggs(ix)
            if (math.hypot(s.x - e.x, s.y - e.y) <= 2.2 && canEnter(s.account, e.x)) {
              worldEggs.remove(ix)
              s.account.eggs += EggItem(e.id, e.name, e.rarity, e.biome, now + 12000L)
              announcement = s.account.username + " found a " + e.rarity + " egg!"
              spawnEgg(e.biome)
              saveAccounts()
            }
        }
      case "HATCH" =>
        val id = arg(0)
        val ix = s.account.eggs.indexWhere(_.id == id)
        if (ix >= 0 && now >= s.account.eggs(ix).hatchAt) {
          val e = s.account.eggs.remove(ix)
          val mult = e.rarity match {
            case "Celestial" => 12L
            case "Secret" => 8L
            case "Mythic" => 5L
            case "Legendary" => 3L
            case "Epic" => 2L
            case _ => 1L
          }
          val income = eggDefs.find(_.name == e.name).map(_.income).getOrElse(3L) * mult
          val petName = e.name.stripSuffix(" Egg") + "ling"
          s.account.pets += PetView(petName, e.rarity, income)
          announcement = s.account.username + " hatched " + petName + "!"
          saveAccounts()
        }
      case "STEAL_PLAYER" =>
        val targetName = arg(0)
        sessions.get(norm(targetName)).filterNot(_.account.username == s.account.username).foreach { target =>
          if (nearestBase(s.x, s.y, target.account) && target.account.eggs.nonEmpty) {
            val stolen = target.account.eggs.remove(target.account.eggs.size - 1)
            s.account.eggs += stolen.copy(hatchAt = math.max(stolen.hatchAt, now + 6000L))
            announcement = s.account.username + " stole an egg from " + target.account.username + "!"
            saveAccounts()
          }
        }
      case "BUY_BOOST" =>
        val cost = 500L
        if (s.account.coins >= cost) {
          s.account.coins -= cost
          // A short global boost can be purchased by any player for the whole server.
          boostMultiplier = 2.0; boostUntil = now + 60000L
          announcement = s.account.username + " activated a 2x server boost for 60 seconds!"
          saveAccounts()
        }
      case "ANNOUNCE" if isAdmin =>
        announcement = arg(0).take(220)
      case "EVENT" if isAdmin =>
        eventTitle = arg(0).take(60)
        eventText = arg(1).take(220)
        announcement = "EVENT LIVE: " + eventTitle + " ? " + eventText
      case "SPAWN_EGG" if isAdmin =>
        val biome = arg(0); val rarity = arg(1)
        if (biomes.exists(_.name.equalsIgnoreCase(biome))) {
          spawnEgg(biome, rarity.take(24))
          announcement = "ADMIN SPAWNED A " + rarity.toUpperCase + " EGG IN " + biome.toUpperCase + "!"
        }
      case "GRANT_COINS" if isOwner =>
        val target = accounts.get(norm(arg(0)))
        val amount = Try(arg(1).toLong).getOrElse(0L)
        if (target.nonEmpty && amount > 0 && amount <= 1000000000L) {
          target.get.coins = math.min(Long.MaxValue / 4, target.get.coins + amount)
          announcement = s.account.username + " sent " + amount + " coins to " + target.get.username + "."
          saveAccounts()
        }
      case "PROMOTE" if isOwner =>
        accounts.get(norm(arg(0))).filterNot(_.username.equalsIgnoreCase("Yadnesh")).foreach { target =>
          target.role = if (arg(1).equalsIgnoreCase("ADMIN")) "ADMIN" else "PLAYER"
          announcement = target.username + " role changed to " + target.role + "."
          saveAccounts()
        }
      case "BOOST" if isAdmin =>
        val mult = Try(arg(0).toDouble).getOrElse(1.0)
        val seconds = Try(arg(1).toInt).getOrElse(60).max(5).min(3600)
        boostMultiplier = math.max(1.0, math.min(10.0, mult))
        boostUntil = now + seconds * 1000L
        announcement = "ADMIN EVENT BOOST: " + boostMultiplier + "x for " + seconds + " seconds!"
      case "UPDATE_NOTE" if isOwner =>
        updateNote = (arg(0) + " ? " + arg(1)).take(240)
        announcement = "GAME UPDATE: " + updateNote
      case "QUIT" => return false
      case _ => () // Unknown or unauthorized commands are ignored server-side.
    }
    true
  }

  private def authenticate(in: DataInputStream, out: DataOutputStream): Account = {
    val (cmd, args) = GameProtocol.unpack(in.readUTF())
    def a(i: Int): String = args.lift(i).getOrElse("")
    val name = a(0).trim
    val pass = a(1)
    val account = lock.synchronized {
      cmd match {
        case "REGISTER" =>
          if (!safeName(name) || name.equalsIgnoreCase("Yadnesh") || pass.length < 8 || accounts.contains(norm(name))) {
            out.writeUTF("ERROR|Use a new username and a password of at least 8 characters. Yadnesh is reserved for the owner.")
            out.flush(); null
          } else {
            val salt = randomBytes(16)
            val (bx, by) = freshBase(name)
            val acc = new Account(name, salt, hashPassword(pass, salt), 250L, 1, "PLAYER", 1, bx, by)
            accounts(norm(name)) = acc; saveAccounts(); acc
          }
        case "OWNER_LOGIN" =>
          val supplied = pass.getBytes(java.nio.charset.StandardCharsets.UTF_8)
          val expected = ownerBootstrapPassword.getBytes(java.nio.charset.StandardCharsets.UTF_8)
          if (!name.equalsIgnoreCase("Yadnesh") ||
              !java.security.MessageDigest.isEqual(supplied, expected)) {
            out.writeUTF("ERROR|Admin login failed. Use the Yadnesh Owner account credentials.")
            out.flush(); null
          } else {
            accounts.get(norm("Yadnesh")) match {
              case Some(existing) if existing.role == "OWNER" =>
                var ownerChanged = false
                if (!verifyPassword(existing, pass)) {
                  // Migrate an older Owner profile to the configured Admin Login.
                  val newSalt = randomBytes(16)
                  existing.salt = newSalt
                  existing.hash = hashPassword(pass, newSalt)
                  ownerChanged = true
                  println("Existing Yadnesh Owner profile migrated to the configured Admin Login.")
                }
                // Always restore the creator's requested 100-million speed on Admin login.
                if (existing.speed != 100000000) {
                  existing.speed = 100000000
                  ownerChanged = true
                }
                if (ownerChanged) saveAccounts()
                existing
              case Some(_) =>
                out.writeUTF("ERROR|The reserved Yadnesh account is not an Owner account.")
                out.flush(); null
              case None if accounts.values.exists(_.role == "OWNER") =>
                out.writeUTF("ERROR|An Owner account already exists on this server.")
                out.flush(); null
              case None =>
                val salt = randomBytes(16)
                val (bx, by) = freshBase("Yadnesh")
                val acc = new Account("Yadnesh", salt, hashPassword(pass, salt),
                  1000000L, 100000000, "OWNER", 10, bx, by)
                accounts(norm("Yadnesh")) = acc
                saveAccounts()
                println("Yadnesh Owner profile created and authenticated.")
                acc
            }
          }
        case "LOGIN" =>
          accounts.get(norm(name)).filter(a => verifyPassword(a, pass)).orElse {
            out.writeUTF("ERROR|Login failed. Check your username and password.")
            out.flush(); None
          }.orNull
        case _ =>
          out.writeUTF("ERROR|Unknown login request."); out.flush(); null
      }
    }
    if (account != null) {
      val availability = lock.synchronized {
        if (sessions.contains(norm(account.username))) "already"
        else if (sessions.size >= MaxPlayers) "full"
        else "ok"
      }
      if (availability != "ok") {
        val reason = if (availability == "full") "This server is full. Maximum 10 players are allowed."
                     else "This account is already online on this server."
        out.writeUTF("ERROR|" + reason)
        out.flush()
        null
      } else {
        out.writeUTF("OK")
        out.writeLong(account.coins); out.writeInt(account.speed); out.writeUTF(account.role)
        out.flush()
        account
      }
    } else null
  }

  private def handleSocket(socket: Socket): Unit = {
    var session: Session = null
    try {
      socket.setTcpNoDelay(true)
      socket.setSoTimeout(0)
      val in = new DataInputStream(new BufferedInputStream(socket.getInputStream))
      val out = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream))
      val account = authenticate(in, out)
      if (account == null) return
      session = lock.synchronized {
        val s = new Session(socket, in, out, account, account.baseX, account.baseY)
        sessions(norm(account.username)) = s
        s
      }
      println("Connected: " + account.username)
      var connected = true
      while (connected && running && !socket.isClosed) {
        val line = in.readUTF()
        val (cmd, args) = GameProtocol.unpack(line)
        connected = handle(session, cmd, args)
      }
    } catch {
      case _: java.io.EOFException => ()
      case e: Exception => println("Client disconnected: " + Option(e.getMessage).getOrElse(e.getClass.getSimpleName))
    } finally {
      if (session != null) lock.synchronized {
        sessions.get(norm(session.account.username)).filter(_ eq session).foreach(_ => sessions.remove(norm(session.account.username)))
        saveAccounts()
      }
      try socket.close() catch { case _: Throwable => () }
    }
  }

  def main(args: Array[String]): Unit = {
    dataDir.mkdirs()
    loadAccounts()
    seedWorld()
    if (!accounts.values.exists(_.role == "OWNER"))
      println("First run: open the client, choose ADMIN LOGIN, and sign in as Yadnesh.")
    val server = GameProtocol.serverSocket(port)
    GameProtocol.startDiscoveryResponder(server.getLocalPort)
    println("Steal An Egg server listening on port " + server.getLocalPort)
    println("LAN discovery listening on UDP port " + GameProtocol.DiscoveryPort)
    println("TLS enabled: " + java.lang.Boolean.getBoolean("stealegg.tls"))
    println("Account data: " + accountFile.getAbsolutePath)
    val incomeThread = new Thread(() => {
      while (running) {
        try {
          Thread.sleep(1000L)
          lock.synchronized {
            val now = System.currentTimeMillis()
            val mult = if (now < boostUntil) boostMultiplier else 1.0
            sessions.values.foreach { s =>
              val income = s.account.pets.map(_.income).sum
              if (income > 0) s.account.coins = math.min(Long.MaxValue / 4,
                s.account.coins + math.max(1L, (income * mult).toLong))
            }
            if (now % 15000L < 1000L) saveAccounts()
          }
        } catch { case _: InterruptedException => running = false; case e: Exception => println("Income loop: " + e.getMessage) }
      }
    }, "steal-egg-income")
    incomeThread.setDaemon(true); incomeThread.start()
    val acceptThread = new Thread(() => {
      while (running) {
        try {
          val s = server.accept()
          val t = new Thread(() => handleSocket(s), "egg-client-" + s.getRemoteSocketAddress)
          t.setDaemon(true); t.start()
        } catch { case e: Exception => if (running) println("Accept error: " + e.getMessage) }
      }
    }, "steal-egg-accept")
    acceptThread.setDaemon(false); acceptThread.start()
    println("Server ready. Press Ctrl+C to stop.")
  }
}

import java.awt._
import java.awt.event._
import java.io._
import java.net.Socket
import java.util.concurrent.LinkedBlockingQueue
import javax.swing._
import scala.collection.mutable
import scala.util.Try
import GameProtocol._

/**
 * STEAL AN EGG - YADNESH EDITION
 * Perspective 3D polygon-rendered multiplayer egg-collecting game client.
 * Run: sbt "runMain StealAnEggClient"
 */
object StealAnEggClient {
  private val bg = new Color(10, 15, 30)
  private val panelBg = new Color(22, 31, 54)
  private val cyan = new Color(55, 230, 220)
  private val gold = new Color(255, 205, 80)
  private val white = new Color(240, 245, 255)
  private val fontName = "Arial"

  private case class Biome(name: String, gate: Int, color1: Color, color2: Color)
  private val biomes = Vector(
    Biome("Green Meadow", 0, new Color(91, 174, 92), new Color(54, 132, 73)),
    Biome("Whispering Forest", 5, new Color(47, 128, 75), new Color(28, 83, 55)),
    Biome("Crystal Lake", 10, new Color(74, 178, 205), new Color(37, 119, 170)),
    Biome("Golden Desert", 15, new Color(222, 183, 103), new Color(176, 126, 62)),
    Biome("Mystic Jungle", 20, new Color(41, 142, 81), new Color(20, 93, 55)),
    Biome("Savannah", 30, new Color(191, 166, 91), new Color(143, 119, 61)),
    Biome("Sakura Valley", 40, new Color(231, 153, 190), new Color(173, 104, 151)),
    Biome("Frost Peaks", 50, new Color(204, 232, 247), new Color(135, 188, 219)),
    Biome("Ice Cavern", 60, new Color(119, 204, 235), new Color(53, 125, 177)),
    Biome("Candy Fields", 70, new Color(248, 155, 190), new Color(126, 190, 226)),
    Biome("Mushroom Woods", 85, new Color(147, 130, 99), new Color(77, 113, 73)),
    Biome("Coral Reef", 100, new Color(73, 180, 177), new Color(37, 119, 148)),
    Biome("Deep Ocean", 120, new Color(40, 93, 157), new Color(17, 48, 101)),
    Biome("Volcanic Crater", 140, new Color(159, 73, 54), new Color(88, 40, 43)),
    Biome("Prehistoric Wilds", 165, new Color(139, 151, 79), new Color(83, 102, 48)),
    Biome("Sky Islands", 190, new Color(117, 177, 204), new Color(81, 127, 179)),
    Biome("Cloud Kingdom", 220, new Color(214, 227, 244), new Color(147, 177, 217)),
    Biome("Crystal Caverns", 250, new Color(127, 103, 194), new Color(75, 63, 147)),
    Biome("Cyber City", 285, new Color(47, 101, 133), new Color(27, 57, 105)),
    Biome("Cosmic Realm", 320, new Color(101, 75, 168), new Color(49, 37, 104)),
    Biome("Void Dimension", 360, new Color(59, 42, 82), new Color(26, 19, 46))
  )

  private var frame: JFrame = _
  private var connection: ClientConnection = _
  private var currentUsername = ""
  private var currentSnapshot: Snapshot = Snapshot(0, 1, "PLAYER", 1, 3.5, 3.5,
    "", "", "", "", 1.0, 0L, Vector.empty, Vector.empty, Vector.empty, Vector.empty)
  private var gamePanel: WorldPanel = _
  private var statusLabel: JLabel = _

  // Admin Login starts a local host automatically when this PC is the host.
  // Remote hosts are never started locally.
  private val localServerStartLock = new Object

  private def isLoopbackHost(host: String): Boolean = {
    val h = Option(host).getOrElse("").trim.toLowerCase
    h == "127.0.0.1" || h == "localhost" || h == "::1"
  }

  private def canReachTcp(host: String, port: Int, timeoutMs: Int = 450): Boolean = {
    val socket = new Socket()
    try {
      socket.connect(new java.net.InetSocketAddress(host, port), timeoutMs)
      true
    } catch { case _: Exception => false }
    finally { try socket.close() catch { case _: Throwable => () } }
  }

  private def ensureLocalGameServer(host: String, port: Int): Unit = {
    if (!isLoopbackHost(host))
      throw new IOException("Enter the host PC IP or use FIND LAN SERVER to connect to a remote host.")
    if (canReachTcp(host, port)) return

    localServerStartLock.synchronized {
      if (!canReachTcp(host, port)) {
        System.setProperty("stealegg.tls", "false")
        System.setProperty("stealegg.port", port.toString)
        try StealAnEggServer.main(Array.empty[String])
        catch { case _: java.net.BindException => () }

        val deadline = System.nanoTime() + 7000L * 1000000L
        var ready = canReachTcp(host, port)
        while (!ready && System.nanoTime() < deadline) {
          Thread.sleep(150L)
          ready = canReachTcp(host, port)
        }
        if (!ready)
          throw new IOException("Local server did not start on port " + port + ". Check whether another app is using this port, then restart the game.")
      }
    }
  }

  private var profileStatsLabel: JLabel = _

  private def button(text: String): JButton = {
    val b = new JButton(text)
    b.setFont(new Font(fontName, Font.BOLD, 13))
    b.setForeground(white)
    b.setBackground(new Color(34, 52, 82))
    b.setFocusPainted(false)
    b.setBorder(BorderFactory.createCompoundBorder(
      BorderFactory.createLineBorder(new Color(65, 205, 210), 1),
      BorderFactory.createEmptyBorder(9, 13, 9, 13)))
    b.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR))
    b
  }

  private def label(text: String, size: Int, color: Color): JLabel = {
    val l = new JLabel(text)
    l.setFont(new Font(fontName, Font.BOLD, size)); l.setForeground(color); l
  }

  private def panel(layout: LayoutManager): JPanel = {
    val p = new JPanel(layout); p.setOpaque(false); p
  }

  private def showMessage(text: String, title: String = "STEAL AN EGG"): Unit =
    JOptionPane.showMessageDialog(frame, text, title, JOptionPane.INFORMATION_MESSAGE)

  private def showError(text: String): Unit =
    JOptionPane.showMessageDialog(frame, text, "Connection error", JOptionPane.ERROR_MESSAGE)

  private def screen(p: JPanel, w: Int, h: Int): Unit = {
    frame.setContentPane(p); frame.setSize(w, h); frame.setLocationRelativeTo(null)
    frame.revalidate(); frame.repaint(); frame.setVisible(true)
  }

  private class ClientConnection(val socket: Socket, val in: DataInputStream,
                                 val out: DataOutputStream) {
    private val queue = new LinkedBlockingQueue[String]()
    @volatile private var alive = true
    @volatile var latest: Snapshot = currentSnapshot

    def send(cmd: String, args: String*): Unit = {
      if (alive) queue.offer(GameProtocol.pack(cmd, args: _*))
    }

    def close(): Unit = {
      alive = false
      queue.offer(GameProtocol.pack("QUIT"))
      try socket.close() catch { case _: Throwable => () }
    }

    def start(onState: Snapshot => Unit): Unit = {
      val t = new Thread(() => {
        try {
          while (alive && !socket.isClosed) {
            var item = queue.poll(90, java.util.concurrent.TimeUnit.MILLISECONDS)
            while (item != null) {
              out.writeUTF(item)
              item = queue.poll()
            }
            out.writeUTF(GameProtocol.pack("SYNC"))
            out.flush()
            val state = GameProtocol.readSnapshot(in)
            latest = state
            SwingUtilities.invokeLater(new Runnable {
              override def run(): Unit = onState(state)
            })
          }
        } catch {
          case _: InterruptedException => Thread.currentThread().interrupt()
          case e: Exception =>
            alive = false
            SwingUtilities.invokeLater(new Runnable {
              override def run(): Unit = {
                if (frame != null && frame.isDisplayable)
                  statusLabel.setText("Connection lost: " + Option(e.getMessage).getOrElse("network error"))
              }
            })
        }
      }, "steal-egg-network")
      t.setDaemon(true); t.start()
    }
  }

  private def connect(host: String, port: Int, mode: String, username: String,
                      password: String, useTls: Boolean): Unit = {
    if (useTls) System.setProperty("stealegg.tls", "true")
    else System.setProperty("stealegg.tls", "false")
    val worker = new SwingWorker[ClientConnection, Unit]() {
      override def doInBackground(): ClientConnection = {
        val socket = GameProtocol.clientSocket(host, port)
        socket.setTcpNoDelay(true)
        val in = new DataInputStream(new BufferedInputStream(socket.getInputStream))
        val out = new DataOutputStream(new BufferedOutputStream(socket.getOutputStream))
        val request = mode match {
          case "Create account" => GameProtocol.pack("REGISTER", username, password)
          case "Admin Login" => GameProtocol.pack("OWNER_LOGIN", username, password)
          case _ => GameProtocol.pack("LOGIN", username, password)
        }
        out.writeUTF(request); out.flush()
        val answer = in.readUTF()
        if (answer != "OK") {
          socket.close()
          val parts = answer.split("\\|", -1)
          val msg = if (parts.length > 1) parts.drop(1).mkString("|") else answer
          throw new IOException(if (msg.startsWith("ERROR|")) msg else msg)
        }
        // Initial account summary; subsequent authoritative state arrives with SYNC.
        val coins = in.readLong(); val speed = in.readInt(); val role = in.readUTF()
        val c = new ClientConnection(socket, in, out)
        c.latest = currentSnapshot.copy(coins = coins, speed = speed, role = role)
        c
      }
      override def done(): Unit = {
        try {
          val established = get()
          if (mode == "Admin Login" && !established.latest.role.equalsIgnoreCase("OWNER")) {
            established.close()
            throw new IOException("This account does not have Yadnesh OWNER access.")
          }
          connection = established
          currentUsername = if (mode == "Admin Login") "Yadnesh" else username
          currentSnapshot = connection.latest
          connection.start(onSnapshot)
          if (mode == "Admin Login") showAdminLobby()
          else showProfileHome()
        } catch {
          case e: Exception =>
            if (connection != null && mode == "Admin Login") {
              connection.close()
              connection = null
            }
            showError(Option(e.getCause).map(_.getMessage).getOrElse(e.getMessage))
            showLogin()
        }
      }
    }
    worker.execute()
  }

  private def showLogin(): Unit = {
    profileStatsLabel = null
    val root = new JPanel(new BorderLayout(14, 14))
    root.setBackground(bg)
    root.setBorder(BorderFactory.createEmptyBorder(22, 28, 22, 28))

    val title = label("STEAL AN EGG", 34, gold)
    title.setHorizontalAlignment(SwingConstants.CENTER)
    val sub = label("YADNESH EDITION  ?  3D WORLD  ?  EGGS  ?  PETS  ?  BIOMES", 13, cyan)
    sub.setHorizontalAlignment(SwingConstants.CENTER)
    val north = panel(new GridLayout(2, 1, 4, 4))
    north.add(title); north.add(sub)
    root.add(north, BorderLayout.NORTH)

    val form = new JPanel(new GridLayout(5, 2, 10, 10))
    form.setOpaque(false)
    val host = new JTextField("127.0.0.1")
    val port = new JTextField(GameProtocol.DefaultPort.toString)
    val user = new JTextField()
    val pass = new JPasswordField()
    val tls = new JCheckBox("Use TLS for hosted Internet server", false)
    tls.setOpaque(false); tls.setForeground(white)
    form.add(label("SERVER HOST / IP", 13, white)); form.add(host)
    form.add(label("SERVER PORT (AUTO)", 13, white)); form.add(port)
    form.add(label("PLAYER NAME", 13, white)); form.add(user)
    form.add(label("PASSWORD", 13, white)); form.add(pass)
    form.add(label("SECURE CONNECTION", 13, white)); form.add(tls)
    root.add(form, BorderLayout.CENTER)

    val footer = panel(new BorderLayout(8, 8))
    val note = new JTextArea(
      "Create an account once and your profile is saved on the game server. " +
      "For same-Wi-Fi play, use FIND LAN SERVER. Different Wi-Fi requires an online hosted server and TLS. " +
      "The automatic port is " + GameProtocol.DefaultPort + "."
    )
    note.setLineWrap(true); note.setWrapStyleWord(true); note.setEditable(false)
    note.setForeground(new Color(195, 207, 226)); note.setBackground(panelBg)
    footer.add(note, BorderLayout.CENTER)

    val buttons = new JPanel(new GridLayout(2, 3, 8, 8))
    buttons.setOpaque(false)
    val login = button("LOGIN")
    val create = button("CREATE ACCOUNT")
    val adminLogin = button("ADMIN LOGIN")
    val findServer = button("FIND LAN SERVER")
    val startServer = button("START LOCAL SERVER")
    val help = button("NETWORK HELP")

    def begin(action: String): Unit = {
      val h = host.getText.trim
      val p = Try(port.getText.trim.toInt).getOrElse(0)
      val n = user.getText.trim
      val pw = new String(pass.getPassword)
      if (h.isEmpty || p < 1 || p > 65535 || n.isEmpty || pw.isEmpty) {
        showError("Enter a server host, valid port, username and password.")
      } else if (action == "Create account" && pw.length < 8) {
        showError("Use a password with at least 8 characters.")
      } else {
        login.setEnabled(false); create.setEnabled(false)
        login.setText("CONNECTING...")
        connect(h, p, action, n, pw, tls.isSelected)
      }
    }

    login.addActionListener(_ => begin("Login"))
    create.addActionListener(_ => begin("Create account"))
    adminLogin.addActionListener(_ => showAdminPasswordLogin(
      host.getText.trim,
      Try(port.getText.trim.toInt).getOrElse(GameProtocol.DefaultPort),
      tls.isSelected
    ))

    findServer.addActionListener(_ => {
      findServer.setEnabled(false); findServer.setText("SEARCHING...")
      val worker = new SwingWorker[Option[(String, Int)], Unit]() {
        override def doInBackground(): Option[(String, Int)] = GameProtocol.discoverServer(2200)
        override def done(): Unit = {
          findServer.setEnabled(true); findServer.setText("FIND LAN SERVER")
          try {
            get() match {
              case Some((ip, discoveredPort)) =>
                host.setText(ip); port.setText(discoveredPort.toString); tls.setSelected(false)
                showMessage("Game server found!\nIP: " + ip + "\nPort: " + discoveredPort, "SERVER FOUND")
              case None =>
                showMessage("No server found on this Wi-Fi. Start the server on the host PC, allow it through Windows Firewall on Private networks, then try again. For a hosted Internet server, enter its hostname manually.", "SERVER NOT FOUND")
            }
          } catch {
            case ex: Exception => showError("LAN discovery failed: " + Option(ex.getCause).map(_.getMessage).getOrElse(ex.getMessage))
          }
        }
      }
      worker.execute()
    })

    startServer.addActionListener(_ => {
      startServer.setEnabled(false); startServer.setText("SERVER STARTING...")
      val t = new Thread(() => {
        try {
          ensureLocalGameServer("127.0.0.1", GameProtocol.DefaultPort)
          SwingUtilities.invokeLater(() => {
            host.setText("127.0.0.1")
            port.setText(GameProtocol.DefaultPort.toString)
            startServer.setText("SERVER RUNNING")
            showMessage(
              "Local server started.\nThis PC: 127.0.0.1:" + GameProtocol.DefaultPort +
              "\nSame Wi-Fi friends can click FIND LAN SERVER.\nLAN IP: " +
              GameProtocol.localIPv4Address() + ":" + GameProtocol.DefaultPort +
              "\nAdmin: choose ADMIN LOGIN and use the Yadnesh Owner credentials.",
              "LOCAL SERVER"
            )
          })
        } catch {
          case ex: Exception => SwingUtilities.invokeLater(() => {
            startServer.setEnabled(true); startServer.setText("START LOCAL SERVER")
            showError("Server start failed: " + ex.getMessage)
          })
        }
      }, "stealegg-local-server-launch")
      t.setDaemon(false); t.start()
    })

    help.addActionListener(_ => showMessage(
      "1. On the host PC, ADMIN LOGIN can start the local server automatically.\n" +
      "2. On another PC on the same Wi-Fi, click FIND LAN SERVER.\n" +
      "3. The IP and port are filled automatically if UDP broadcast is allowed.\n" +
      "4. Create an account, or use ADMIN LOGIN for the Yadnesh Owner profile.\n" +
      "5. GAME ON opens the 3D world after login.\n\n" +
      "Different Wi-Fi networks need a publicly hosted server, TLS certificate and an Internet hostname. LAN auto-discovery cannot cross the Internet."
    , "NETWORK HELP"))

    buttons.add(login); buttons.add(create); buttons.add(adminLogin)
    buttons.add(findServer); buttons.add(startServer); buttons.add(help)
    footer.add(buttons, BorderLayout.SOUTH)
    root.add(footer, BorderLayout.SOUTH)
    screen(root, 920, 660)
  }

  /** Password-only owner entry. The username is always fixed to Yadnesh. */
  private def showAdminPasswordLogin(fallbackHost: String, fallbackPort: Int,
                                     useTls: Boolean): Unit = {
    val passwordField = new JPasswordField(22)
    val content = new JPanel(new GridLayout(2, 1, 8, 8))
    content.setOpaque(false)
    content.add(label("ENTER OWNER PASSWORD", 14, white))
    content.add(passwordField)

    val choice = JOptionPane.showConfirmDialog(
      frame, content, "YADNESH ADMIN LOGIN",
      JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE
    )

    if (choice != JOptionPane.OK_OPTION) return

    val password = new String(passwordField.getPassword)
    if (password.isEmpty) {
      showError("Enter the Admin password.")
      return
    }

    val safeHost = Option(fallbackHost).map(_.trim).filter(_.nonEmpty).getOrElse("127.0.0.1")
    val safePort = if (fallbackPort >= 1 && fallbackPort <= 65535)
      fallbackPort else GameProtocol.DefaultPort

    val worker = new SwingWorker[Option[(String, Int)], Unit]() {
      override def doInBackground(): Option[(String, Int)] = {
        val discovered = if (useTls) None else GameProtocol.discoverServer(1600)
        val target = discovered.getOrElse((safeHost, safePort))
        if (discovered.isEmpty && isLoopbackHost(target._1) && !useTls)
          ensureLocalGameServer(target._1, target._2)
        discovered.orElse(Some(target))
      }
      override def done(): Unit = {
        try {
          val target = get().getOrElse((safeHost, safePort))
          connect(target._1, target._2, "Admin Login", "Yadnesh", password, useTls)
        } catch {
          case e: Exception => showError("Could not find or connect to the game server: " +
            Option(e.getCause).map(_.getMessage).getOrElse(e.getMessage))
        }
      }
    }
    worker.execute()
  }

  /** Shown immediately after a successful password-only Yadnesh Owner login. */
  private def showAdminLobby(): Unit = {
    profileStatsLabel = null
    val s = if (connection != null) connection.latest else currentSnapshot
    val root = new JPanel(new BorderLayout(16, 16))
    root.setBackground(bg)
    root.setBorder(BorderFactory.createEmptyBorder(24, 34, 24, 34))

    val heading = label("YADNESH OWNER LOBBY", 32, gold)
    heading.setHorizontalAlignment(SwingConstants.CENTER)
    val sub = label("OWNER ACCESS VERIFIED  ?  STEAL AN EGG  ?  21 BIOMES", 14, cyan)
    sub.setHorizontalAlignment(SwingConstants.CENTER)
    val north = panel(new GridLayout(2, 1, 4, 5))
    north.add(heading); north.add(sub)
    root.add(north, BorderLayout.NORTH)

    val center = new JPanel(new GridLayout(0, 1, 12, 12))
    center.setOpaque(false)
    center.add(label("WELCOME BACK, YADNESH!", 27, white))
    center.add(label("ROLE: OWNER / GAME CREATOR", 19, gold))
    profileStatsLabel = label(
      "COINS: " + fmt(s.coins) + "     SPEED: " + s.speed +
      "     BASE LEVEL: " + s.baseLevel + "     EGGS: " + s.eggs.size +
      "     PETS: " + s.pets.size,
      16, white
    )
    center.add(profileStatsLabel)
    center.add(label("SERVER WORLD: 21 BIOMES  ?  MAXIMUM PLAYERS: 10", 14,
      new Color(185, 203, 224)))
    root.add(center, BorderLayout.CENTER)

    val actions = panel(new GridLayout(1, 4, 10, 10))
    val gameOn = button("? GAME ON")
    val controls = button("ADMIN CONTROL PANEL")
    val profile = button("MY PROFILE")
    val logout = button("LOG OUT")
    gameOn.setFont(new Font(fontName, Font.BOLD, 18))
    gameOn.addActionListener(_ => showGame())
    controls.addActionListener(_ => showAdminPanel())
    profile.addActionListener(_ => showProfileHome())
    logout.addActionListener(_ => {
      if (connection != null) connection.close()
      connection = null
      gamePanel = null
      profileStatsLabel = null
      currentUsername = ""
      showLogin()
    })
    actions.add(gameOn); actions.add(controls); actions.add(profile); actions.add(logout)
    root.add(actions, BorderLayout.SOUTH)
    screen(root, 960, 620)
  }

  private def showProfileHome(): Unit = {
    profileStatsLabel = null
    val s = if (connection != null) connection.latest else currentSnapshot
    val root = new JPanel(new BorderLayout(14, 14))
    root.setBackground(bg)
    root.setBorder(BorderFactory.createEmptyBorder(24, 36, 24, 36))
    val title = label("PLAYER PROFILE", 30, gold)
    title.setHorizontalAlignment(SwingConstants.CENTER)
    val sub = label("STEAL AN EGG  ?  YADNESH EDITION", 14, cyan)
    sub.setHorizontalAlignment(SwingConstants.CENTER)
    val north = panel(new GridLayout(2, 1, 4, 4)); north.add(title); north.add(sub)
    root.add(north, BorderLayout.NORTH)

    val center = new JPanel(new GridLayout(0, 1, 10, 10))
    center.setOpaque(false)
    val roleText = if (s.role == "OWNER") "OWNER / GAME CREATOR" else if (s.role == "ADMIN") "ADMIN" else "PLAYER"
    center.add(label("WELCOME, " + currentUsername.toUpperCase(java.util.Locale.ROOT), 25, white))
    center.add(label("ROLE: " + roleText, 18, if (s.role == "OWNER") gold else cyan))
    profileStatsLabel = label(
      "COINS: " + fmt(s.coins) + "     SPEED: " + s.speed +
      "     BASE LEVEL: " + s.baseLevel + "     EGGS: " + s.eggs.size + "     PETS: " + s.pets.size,
      15, white
    )
    center.add(profileStatsLabel)
    center.add(label("WORLD: 21 BIOMES  ?  MAX PLAYERS: 10", 14, new Color(185, 203, 224)))
    root.add(center, BorderLayout.CENTER)

    val actions = panel(new FlowLayout(FlowLayout.CENTER, 12, 10))
    val gameOn = button("? GAME ON")
    val admin = button("OWNER / ADMIN PANEL")
    val logout = button("LOG OUT")
    gameOn.setFont(new Font(fontName, Font.BOLD, 18))
    gameOn.addActionListener(_ => showGame())
    admin.addActionListener(_ => showAdminPanel())
    logout.addActionListener(_ => {
      if (connection != null) connection.close()
      connection = null; gamePanel = null; profileStatsLabel = null
      currentUsername = ""; showLogin()
    })
    actions.add(gameOn)
    if (s.role == "OWNER" || s.role == "ADMIN") actions.add(admin)
    actions.add(logout)
    root.add(actions, BorderLayout.SOUTH)
    screen(root, 900, 610)
  }

  private def onSnapshot(s: Snapshot): Unit = {
    currentSnapshot = s
    if (profileStatsLabel != null) {
      profileStatsLabel.setText("COINS: " + fmt(s.coins) + "     SPEED: " + s.speed +
        "     BASE LEVEL: " + s.baseLevel + "     EGGS: " + s.eggs.size + "     PETS: " + s.pets.size)
    }
    if (gamePanel != null) gamePanel.repaint()
    if (statusLabel != null) {
      val biome = biomeFor(s.x)
      statusLabel.setText("COINS  " + fmt(s.coins) + "     SPEED  " + s.speed +
        "     BIOME  " + biome.name.toUpperCase + "     PETS  " + s.pets.size +
        "     EGGS  " + s.eggs.size)
    }
  }

  private def fmt(n: Long): String = {
    if (n >= 1000000000L) f"${n / 1000000000.0}%.1fB"
    else if (n >= 1000000L) f"${n / 1000000.0}%.1fM"
    else if (n >= 1000L) f"${n / 1000.0}%.1fK"
    else n.toString
  }

  private def biomeFor(x: Double): Biome = biomes(math.max(0, math.min(biomes.size - 1, (x / 24.0).toInt)))

  private class WorldPanel extends JPanel {
    // Real perspective 3D software renderer: 3D vertices -> camera space -> perspective projection.
    // It uses Java2D polygons so it remains dependency-free in a Scala Swing project.
    private case class V3(x: Double, y: Double, z: Double)
    private case class Tri3(a: V3, b: V3, c: V3, color: Color)
    private case class CamV(x: Double, y: Double, z: Double)
    private case class DrawPoly(depth: Double, xs: Array[Int], ys: Array[Int], color: Color)

    private val keys = mutable.HashSet.empty[Int]
    private var px = currentSnapshot.x
    private var py = currentSnapshot.y
    private var yaw = math.Pi / 2.0 // face east so W moves towards the next biome
    private var lastMoveSend = 0L
    private var mouseLastX = -1
    private var dragLook = false
    private val nearPlane = 0.28
    private val worldLength = biomes.size * 24.0

    private val sceneTriangles: Vector[Tri3] = buildScene()

    private val timer = new Timer(33, new ActionListener {
      override def actionPerformed(e: ActionEvent): Unit = tick()
    })

    setFocusable(true)
    setBackground(new Color(8, 13, 27))
    setFocusTraversalKeysEnabled(false)

    addKeyListener(new KeyAdapter {
      override def keyPressed(e: KeyEvent): Unit = {
        keys += e.getKeyCode
        e.getKeyCode match {
          case KeyEvent.VK_E => interact()
          case KeyEvent.VK_T => if (connection != null) connection.send("TRAIN")
          case KeyEvent.VK_I => showInventory()
          case KeyEvent.VK_M => showBiomes()
          case KeyEvent.VK_ESCAPE => keys.clear()
          case _ => ()
        }
      }
      override def keyReleased(e: KeyEvent): Unit = keys -= e.getKeyCode
    })

    addMouseListener(new MouseAdapter {
      override def mousePressed(e: MouseEvent): Unit = {
        dragLook = true
        mouseLastX = e.getX
        requestFocusInWindow()
      }
      override def mouseReleased(e: MouseEvent): Unit = {
        dragLook = false
        mouseLastX = -1
      }
    })
    addMouseMotionListener(new MouseMotionAdapter {
      override def mouseDragged(e: MouseEvent): Unit = {
        if (dragLook && mouseLastX >= 0) {
          yaw += (e.getX - mouseLastX) * 0.008
          mouseLastX = e.getX
        }
      }
    })

    timer.start()

    private def clamp(v: Double, lo: Double, hi: Double): Double =
      math.max(lo, math.min(hi, v))

    private def colorScale(c: Color, f: Double): Color = {
      val k = clamp(f, 0.12, 1.30)
      new Color(
        clamp(c.getRed * k, 0, 255).toInt,
        clamp(c.getGreen * k, 0, 255).toInt,
        clamp(c.getBlue * k, 0, 255).toInt
      )
    }

    private def addQuad(out: mutable.ArrayBuffer[Tri3], x0: Double, z0: Double,
                        x1: Double, z1: Double, y: Double, color: Color): Unit = {
      val a = V3(x0, y, z0); val b = V3(x1, y, z0)
      val c = V3(x1, y, z1); val d = V3(x0, y, z1)
      // Winding gives an upward-facing surface.
      out += Tri3(a, d, c, color)
      out += Tri3(a, c, b, color)
    }

    private def addBox(out: mutable.ArrayBuffer[Tri3], cx: Double, cy: Double, cz: Double,
                       sx: Double, sy: Double, sz: Double, color: Color): Unit = {
      val x0 = cx - sx / 2.0; val x1 = cx + sx / 2.0
      val y0 = cy - sy / 2.0; val y1 = cy + sy / 2.0
      val z0 = cz - sz / 2.0; val z1 = cz + sz / 2.0
      val v = Array(
        V3(x0,y0,z0), V3(x1,y0,z0), V3(x1,y1,z0), V3(x0,y1,z0),
        V3(x0,y0,z1), V3(x1,y0,z1), V3(x1,y1,z1), V3(x0,y1,z1)
      )
      val faces = Array(
        Array(0,1,2,3), Array(4,7,6,5), Array(0,4,5,1),
        Array(3,2,6,7), Array(0,3,7,4), Array(1,5,6,2)
      )
      val light = Array(0.78, 1.08, 0.68, 0.92, 0.82, 0.76)
      var i = 0
      while (i < faces.length) {
        val f = faces(i); val col = colorScale(color, light(i))
        out += Tri3(v(f(0)), v(f(1)), v(f(2)), col)
        out += Tri3(v(f(0)), v(f(2)), v(f(3)), col)
        i += 1
      }
    }

    private def addPyramid(out: mutable.ArrayBuffer[Tri3], x: Double, y: Double, z: Double,
                           width: Double, height: Double, color: Color): Unit = {
      val a = V3(x-width/2,y,z-width/2); val b = V3(x+width/2,y,z-width/2)
      val c = V3(x+width/2,y,z+width/2); val d = V3(x-width/2,y,z+width/2)
      val top = V3(x,y+height,z)
      out += Tri3(a,b,top,colorScale(color,0.92))
      out += Tri3(b,c,top,colorScale(color,1.08))
      out += Tri3(c,d,top,colorScale(color,0.82))
      out += Tri3(d,a,top,colorScale(color,1.0))
      out += Tri3(a,d,c,colorScale(color,0.58))
      out += Tri3(a,c,b,colorScale(color,0.58))
    }

    private def addSphere(out: mutable.ArrayBuffer[Tri3], cx: Double, cy: Double, cz: Double,
                          rx: Double, ry: Double, rz: Double, color: Color,
                          rings: Int = 5, slices: Int = 8): Unit = {
      def point(r: Int, s: Int): V3 = {
        val theta = math.Pi * r.toDouble / rings
        val phi = 2.0 * math.Pi * s.toDouble / slices
        V3(cx + rx * math.sin(theta) * math.cos(phi),
           cy + ry * math.cos(theta),
           cz + rz * math.sin(theta) * math.sin(phi))
      }
      var r = 0
      while (r < rings) {
        var s = 0
        while (s < slices) {
          val p00 = point(r,s); val p01 = point(r,s+1)
          val p10 = point(r+1,s); val p11 = point(r+1,s+1)
          val col1 = colorScale(color, 0.88 + 0.20 * math.sin((s + 1) * 0.8))
          val col2 = colorScale(color, 0.92 + 0.17 * math.cos((s + 2) * 0.7))
          if (r != 0) out += Tri3(p00,p10,p01,col1)
          if (r != rings - 1) out += Tri3(p01,p10,p11,col2)
          s += 1
        }
        r += 1
      }
    }

    private def buildScene(): Vector[Tri3] = {
      val out = mutable.ArrayBuffer.empty[Tri3]
      val meadow = new Color(76, 148, 89)
      val sceneryPositions = Array((4.0,3.8),(9.0,16.5),(16.0,4.0),(20.0,15.7),(6.0,10.0),(18.0,10.0))

      biomes.zipWithIndex.foreach { case (biome, index) =>
        val start = index * 24.0
        addBox(out, start + 12.0, -0.24, 10.0, 24.0, 0.45, 20.0, biome.color1)
        addBox(out, start + 12.0, -0.005, 10.0, 24.0, 0.035, 2.2,
          colorScale(biome.color2, 1.10))

        // Natural low-poly landmarks, with a distinct visual style in every biome.
        sceneryPositions.zipWithIndex.foreach { case ((localX, z), j) =>
          val x = start + localX
          val offset = ((index * 19 + j * 13) % 7) * 0.16
          val main = biome.color2
          biome.name match {
            case "Golden Desert" =>
              addBox(out,x,0.85+offset,z,0.55,1.7,0.55,new Color(42,137,73))
              addBox(out,x+0.35,0.83+offset,z,0.40,0.45,0.42,new Color(42,137,73))
              addPyramid(out,x-0.7,0.0,z+0.7,1.4,1.25,new Color(204,158,81))
            case "Candy Fields" =>
              addBox(out,x,0.70,z,0.22,1.4,0.22,Color.WHITE)
              addSphere(out,x,1.55,z,0.72,0.62,0.72,
                if (j%2==0) new Color(245,105,160) else new Color(110,205,250),4,8)
            case "Mushroom Woods" =>
              addBox(out,x,0.48,z,0.30,0.95,0.30,new Color(221,205,169))
              addSphere(out,x,1.10,z,0.88,0.45,0.82,
                if (j%2==0) new Color(216,75,85) else new Color(147,98,205),4,8)
            case "Volcanic Crater" =>
              addPyramid(out,x,0.0,z,2.2,2.6,new Color(70,48,48))
              addSphere(out,x,2.05,z,0.38,0.22,0.38,new Color(255,94,35),3,7)
            case "Cyber City" =>
              val h = 2.0 + ((index+j)%4)*0.85
              addBox(out,x,h/2,z,1.25,h,1.2,new Color(45,66,111))
              addBox(out,x,h*0.68,z,1.33,0.10,1.28,new Color(43,226,232))
              addBox(out,x+0.55,h*0.33,z+0.62,0.10,0.48,0.08,new Color(255,89,194))
            case "Cosmic Realm" | "Void Dimension" =>
              addPyramid(out,x,0.0,z,1.3,2.2,main)
              addSphere(out,x,2.9+offset,z,0.80,0.80,0.80,
                if (biome.name=="Void Dimension") new Color(90,47,142) else new Color(93,119,235),4,8)
            case "Sky Islands" | "Cloud Kingdom" =>
              addBox(out,x,0.45,z,1.3,0.9,1.3,new Color(115,126,145))
              addSphere(out,x,2.5+offset,z,1.35,0.65,1.0,new Color(225,235,247),4,8)
            case "Crystal Lake" | "Ice Cavern" | "Crystal Caverns" =>
              addPyramid(out,x,0.0,z,0.72,2.0+offset,new Color(79,196,226))
              addPyramid(out,x+0.65,0.0,z+0.4,0.48,1.35,new Color(153,230,248))
            case "Coral Reef" | "Deep Ocean" =>
              addBox(out,x,0.95,z,0.24,1.9,0.24,new Color(230,105,139))
              addBox(out,x-0.35,1.25,z,0.75,0.18,0.22,new Color(237,145,118))
              addSphere(out,x+0.5,0.8,z+0.25,0.42,0.52,0.38,new Color(75,183,173),4,7)
            case _ =>
              val trunk = if (biome.name.contains("Snow") || biome.name.contains("Frost"))
                new Color(105,100,92) else new Color(104,71,43)
              addBox(out,x,0.9,z,0.36,1.8,0.36,trunk)
              val canopy = biome.name match {
                case "Sakura Valley" => new Color(246,139,190)
                case "Savannah" => new Color(184,157,78)
                case "Frost Peaks" => new Color(208,233,247)
                case "Prehistoric Wilds" => new Color(71,139,71)
                case "Mystic Jungle" => new Color(35,106,66)
                case "Whispering Forest" => new Color(35,93,57)
                case _ => new Color(69,158,86)
              }
              addSphere(out,x,2.15+offset,z,1.18,1.05,1.18,canopy,4,8)
              if (biome.name == "Frost Peaks")
                addPyramid(out,x,0.0,z,1.65,2.1,new Color(190,220,242))
          }
          if (j % 2 == 0) addSphere(out,x+1.0,0.22,z+0.6,0.18,0.22,0.18,
            if (biome.name == "Volcanic Crater") new Color(255,100,38) else biome.color2,3,6)
        }

        // Large 3D gate marking the entrance to the biome.
        if (index > 0) {
          val gx = start + 0.55
          addBox(out,gx,1.7,2.0,0.45,3.4,0.45,new Color(42,54,75))
          addBox(out,gx,1.7,18.0,0.45,3.4,0.45,new Color(42,54,75))
          addBox(out,gx,3.28,10.0,0.48,0.42,16.3,
            if (index%2==0) new Color(78,216,229) else new Color(203,119,255))
        }
      }

      out.toVector
    }

    private def tick(): Unit = {
      if (connection == null) return
      val oldX = px; val oldY = py
      val dt = 0.033

      if (keys.contains(KeyEvent.VK_LEFT)) yaw -= 1.9 * dt
      if (keys.contains(KeyEvent.VK_RIGHT)) yaw += 1.9 * dt

      var forward = 0.0
      var strafe = 0.0
      if (keys.contains(KeyEvent.VK_W) || keys.contains(KeyEvent.VK_UP)) forward += 1.0
      if (keys.contains(KeyEvent.VK_S) || keys.contains(KeyEvent.VK_DOWN)) forward -= 1.0
      if (keys.contains(KeyEvent.VK_A)) strafe -= 1.0
      if (keys.contains(KeyEvent.VK_D)) strafe += 1.0
      val len = math.hypot(forward, strafe)

      if (len > 0.0) {
        val sprint = keys.contains(KeyEvent.VK_SHIFT)
        // Speed stat controls movement. Log scaling keeps 100,000,000 fast
        // but still steerable in the 3D world rather than teleporting instantly.
        val speedStat = math.max(1, currentSnapshot.speed).toDouble
        val speedFactor = math.min(80.0, 1.0 + math.log10(speedStat) * 10.0)
        val moveSpeed = (if (sprint) 5.0 else 2.7) * speedFactor
        val f = forward / len * moveSpeed * dt
        val s = strafe / len * moveSpeed * dt
        val dx = math.sin(yaw) * f + math.cos(yaw) * s
        val dz = math.cos(yaw) * f - math.sin(yaw) * s
        px = clamp(px + dx, 0.6, worldLength - 1.0)
        py = clamp(py + dz, 1.2, 18.8)
      }

      val now = System.currentTimeMillis()
      if ((math.abs(px - oldX) + math.abs(py - oldY) > 0.001) && now - lastMoveSend > 80) {
        connection.send("MOVE", px.toString, py.toString)
        lastMoveSend = now
      }
      if (len == 0.0) {
        px = currentSnapshot.x
        py = currentSnapshot.y
      }
      repaint()
    }

    private def interact(): Unit = {
      if (connection == null) return
      val state = currentSnapshot
      val nearbyEgg = state.worldEggs.filter(e => math.hypot(e.x - px, e.y - py) < 2.7)
        .sortBy(e => math.hypot(e.x - px, e.y - py)).headOption
      nearbyEgg match {
        case Some(e) => connection.send("COLLECT", e.id)
        case None =>
          val nearbyPlayer = state.players.filterNot(_.name.equalsIgnoreCase(currentUsername))
            .filter(p => math.hypot(p.baseX - px, p.baseY - py) < 3.5).headOption
          nearbyPlayer match {
            case Some(p) => connection.send("STEAL_PLAYER", p.name)
            case None => Toolkit.getDefaultToolkit.beep()
          }
      }
    }

    private def normalize(v: V3): V3 = {
      val n = math.sqrt(v.x*v.x + v.y*v.y + v.z*v.z)
      if (n < 0.00001) V3(0,0,0) else V3(v.x/n,v.y/n,v.z/n)
    }
    private def dot(a: V3,b: V3): Double = a.x*b.x+a.y*b.y+a.z*b.z
    private def subtract(a: V3,b: V3): V3 = V3(a.x-b.x,a.y-b.y,a.z-b.z)
    private def cross(a: V3,b: V3): V3 = V3(
      a.y*b.z-a.z*b.y, a.z*b.x-a.x*b.z, a.x*b.y-a.y*b.x
    )

    private def cameraBasis(w: Int, h: Int): (V3,V3,V3,V3) = {
      val target = V3(px, 1.0, py)
      val cam = V3(px - math.sin(yaw)*8.2, 6.1, py - math.cos(yaw)*8.2)
      val forward = normalize(subtract(target,cam))
      val right = normalize(cross(forward,V3(0,1,0)))
      val up = normalize(cross(right,forward))
      (cam,right,up,forward)
    }

    private def toCamera(v: V3, cam: V3, right: V3, up: V3, forward: V3): CamV = {
      val rel = subtract(v,cam)
      CamV(dot(rel,right),dot(rel,up),dot(rel,forward))
    }

    private def clipNear(poly: scala.collection.immutable.List[CamV]): scala.collection.immutable.List[CamV] = {
      if (poly.isEmpty) return scala.collection.immutable.List.empty[CamV]
      val out = mutable.ListBuffer.empty[CamV]
      var prev = poly.last
      var prevInside = prev.z >= nearPlane
      poly.foreach { cur =>
        val curInside = cur.z >= nearPlane
        if (curInside != prevInside) {
          val denom = cur.z - prev.z
          if (math.abs(denom) > 0.0000001) {
            val t = (nearPlane - prev.z) / denom
            out += CamV(
              prev.x + (cur.x-prev.x)*t,
              prev.y + (cur.y-prev.y)*t,
              nearPlane
            )
          }
        }
        if (curInside) out += cur
        prev = cur
        prevInside = curInside
      }
      out.toList
    }

    private def faceLighting(t: Tri3, depth: Double): Color = {
      val u = subtract(t.b,t.a); val v = subtract(t.c,t.a)
      val n = normalize(cross(u,v))
      val light = normalize(V3(-0.35,0.88,-0.30))
      val diffuse = math.max(0.0,dot(n,light))
      val fog = clamp(1.0 - depth/105.0,0.34,1.0)
      colorScale(t.color,(0.34 + diffuse*0.72)*fog)
    }

    private def projectTriangle(t: Tri3, basis: (V3,V3,V3,V3), w: Int, h: Int): Option[DrawPoly] = {
      val (cam,right,up,forward) = basis
      val poly = clipNear(scala.collection.immutable.List(
        toCamera(t.a,cam,right,up,forward),
        toCamera(t.b,cam,right,up,forward),
        toCamera(t.c,cam,right,up,forward)
      ))
      if (poly.size < 3) return None
      val focal = math.min(w,h)*0.88
      val xs = poly.map(p => clamp(w/2.0 + p.x*focal/p.z,-4000,w+4000).toInt).toArray
      val ys = poly.map(p => clamp(h/2.0 - p.y*focal/p.z,-4000,h+4000).toInt).toArray
      val depth = poly.map(_.z).sum/poly.size
      Some(DrawPoly(depth,xs,ys,faceLighting(t,depth)))
    }

    private def addEgg(out: mutable.ArrayBuffer[Tri3], x: Double, z: Double, rarity: String): Unit = {
      val c = rarity match {
        case "Uncommon" => new Color(72,218,125)
        case "Rare" => new Color(75,160,255)
        case "Epic" => new Color(181,87,255)
        case "Legendary" => new Color(255,192,55)
        case "Mythic" => new Color(255,79,76)
        case "Secret" => new Color(248,76,203)
        case "Celestial" => new Color(90,235,255)
        case _ => new Color(228,233,242)
      }
      addSphere(out,x,0.85,z,0.48,0.68,0.43,c,6,9)
      addSphere(out,x-0.12,1.03,z-0.23,0.09,0.12,0.06,Color.WHITE,3,6)
    }

    private def addPlayerModel(out: mutable.ArrayBuffer[Tri3], x: Double, z: Double,
                               name: String, owner: Boolean): Unit = {
      val body = if (owner) new Color(250,185,48) else new Color(53,178,211)
      addBox(out,x,0.92,z,0.68,1.15,0.42,body)
      addSphere(out,x,1.75,z,0.36,0.38,0.34,new Color(239,194,157),5,8)
      addSphere(out,x,1.96,z-0.03,0.38,0.16,0.37,new Color(48,37,31),3,8)
      addBox(out,x-0.20,0.27,z,0.22,0.55,0.24,new Color(36,43,66))
      addBox(out,x+0.20,0.27,z,0.22,0.55,0.24,new Color(36,43,66))
      addBox(out,x-0.46,0.96,z,0.19,0.82,0.20,body)
      addBox(out,x+0.46,0.96,z,0.19,0.82,0.20,body)
    }

    private def addBase(out: mutable.ArrayBuffer[Tri3], x: Double, z: Double, mine: Boolean): Unit = {
      val col = if (mine) new Color(48,183,172) else new Color(105,124,158)
      addBox(out,x,0.12,z,5.0,0.24,4.8,col)
      addBox(out,x,1.0,z,3.0,1.75,2.8,new Color(51,67,95))
      addPyramid(out,x,1.88,z,3.5,1.5,new Color(43,52,79))
      addBox(out,x,0.73,z-1.43,0.8,1.15,0.08,new Color(237,193,74))
      addBox(out,x+2.0,0.18,z,1.1,0.28,2.0,new Color(27,36,51)) // treadmill
      addBox(out,x+2.0,0.36,z,1.0,0.08,1.9,new Color(68,220,222))
    }

    private def makeDrawList(w: Int, h: Int): Vector[DrawPoly] = {
      val basis = cameraBasis(w,h)
      val all = mutable.ArrayBuffer.empty[Tri3]
      // Static world is culled by distance before projecting.
      sceneTriangles.foreach { t =>
        val cx = (t.a.x+t.b.x+t.c.x)/3.0
        val cz = (t.a.z+t.b.z+t.c.z)/3.0
        if (math.hypot(cx-px,cz-py) < 44.0) all += t
      }
      currentSnapshot.worldEggs.foreach { e =>
        if (math.hypot(e.x-px,e.y-py) < 38.0) addEgg(all,e.x,e.y,e.rarity)
      }
      currentSnapshot.players.foreach { p =>
        if (math.hypot(p.baseX-px,p.baseY-py) < 42.0)
          addBase(all,p.baseX,p.baseY,p.name.equalsIgnoreCase(currentUsername))
        if (math.hypot(p.x-px,p.y-py) < 42.0 && !p.name.equalsIgnoreCase(currentUsername))
          addPlayerModel(all,p.x,p.y,p.name,p.role=="OWNER")
      }
      addPlayerModel(all,px,py,"YOU",true)

      all.iterator.flatMap(t => projectTriangle(t,basis,w,h)).toVector.sortBy(p => -p.depth)
    }

    private def drawHud(g: Graphics2D, w: Int, h: Int): Unit = {
      val s = currentSnapshot
      g.setColor(new Color(5,10,23,205)); g.fillRoundRect(14,14,345,84,16,16)
      g.setFont(new Font(fontName,Font.BOLD,17)); g.setColor(gold)
      g.drawString("STEAL AN EGG  ?  3D WORLD",28,39)
      g.setFont(new Font(fontName,Font.BOLD,13)); g.setColor(white)
      g.drawString("COINS  "+fmt(s.coins)+"   SPEED  "+s.speed+"   PETS  "+s.pets.size,28,62)
      g.drawString("BIOME  "+biomeFor(px).name.toUpperCase+"   EGGS  "+s.eggs.size+"   PLAYERS  "+s.players.size+"/10",28,84)
      g.setColor(new Color(5,10,23,190)); g.fillRoundRect(14,h-48,Math.min(w-28,700),34,12,12)
      g.setFont(new Font(fontName,Font.BOLD,12)); g.setColor(new Color(224,241,255))
      g.drawString("WASD MOVE  ?  SHIFT SPRINT  ?  MOUSE DRAG / ARROWS LOOK  ?  E COLLECT / STEAL  ?  T TRAIN  ?  I BAG  ?  M BIOMES",25,h-26)
      if (s.announcement.nonEmpty) {
        g.setColor(new Color(0,0,0,165)); g.fillRoundRect(w/2-235,14,470,34,12,12)
        g.setFont(new Font(fontName,Font.BOLD,13)); g.setColor(new Color(255,236,153))
        val msg=s.announcement.take(65)
        g.drawString(msg,w/2-g.getFontMetrics.stringWidth(msg)/2,36)
      }
      if (s.boostUntil > System.currentTimeMillis()) {
        g.setFont(new Font(fontName,Font.BOLD,14)); g.setColor(new Color(255,220,88))
        g.drawString("GLOBAL "+s.boostMultiplier+"x BOOST",w-205,80)
      }
    }

    override def paintComponent(graphics: Graphics): Unit = {
      super.paintComponent(graphics)
      val g = graphics.create().asInstanceOf[Graphics2D]
      try {
        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON)
        val w=getWidth; val h=getHeight
        g.setPaint(new GradientPaint(0,0,new Color(8,15,34),0,h/2,new Color(80,117,144)))
        g.fillRect(0,0,w,h)
        val horizon=(h*0.43).toInt
        g.setPaint(new GradientPaint(0,horizon,new Color(87,130,145),0,h,new Color(9,25,30)))
        g.fillRect(0,horizon,w,h-horizon)
        val drawList=makeDrawList(w,h)
        drawList.foreach { p =>
          g.setColor(p.color)
          g.fillPolygon(p.xs,p.ys,p.xs.length)
          if (p.xs.length==3) {
            g.setColor(new Color(255,255,255,22))
            g.drawPolygon(p.xs,p.ys,3)
          }
        }
        drawHud(g,w,h)
      } finally g.dispose()
    }

    override def addNotify(): Unit = { super.addNotify(); requestFocusInWindow() }
    override def removeNotify(): Unit = { timer.stop(); super.removeNotify() }
  }
  private def showGame(): Unit = {
    profileStatsLabel = null
    val root = new JPanel(new BorderLayout(8, 8)); root.setBackground(bg)
    val top = panel(new BorderLayout(8, 4))
    val title = label("STEAL AN EGG  ?  YADNESH EDITION", 22, gold)
    title.setBorder(BorderFactory.createEmptyBorder(8, 12, 2, 8))
    statusLabel = label("Connecting to world...", 13, cyan)
    statusLabel.setBorder(BorderFactory.createEmptyBorder(0, 13, 8, 8))
    val north = panel(new GridLayout(2, 1)); north.add(title); north.add(statusLabel)
    top.add(north, BorderLayout.CENTER)
    root.add(top, BorderLayout.NORTH)

    gamePanel = new WorldPanel()
    gamePanel.setPreferredSize(new Dimension(1000, 620))
    root.add(gamePanel, BorderLayout.CENTER)

    val bar = panel(new FlowLayout(FlowLayout.CENTER, 7, 7))
    val train = button("? TREADMILL  [T]")
    val bag = button("? EGG BAG  [I]")
    val shop = button("? SHOP")
    val biomesButton = button("? BIOMES  [M]")
    val admin = button("? ADMIN PANEL")
    val profile = button("PROFILE")
    val logout = button("LOG OUT")
    train.addActionListener(_ => connection.send("TRAIN"))
    bag.addActionListener(_ => showInventory())
    shop.addActionListener(_ => showShop())
    biomesButton.addActionListener(_ => showBiomes())
    admin.addActionListener(_ => showAdminPanel())
    profile.addActionListener(_ => showProfileHome())
    logout.addActionListener(_ => {
      if (connection != null) connection.close()
      connection = null; gamePanel = null; profileStatsLabel = null; currentUsername = ""; showLogin()
    })
    bar.add(train); bar.add(bag); bar.add(shop); bar.add(biomesButton); bar.add(profile)
    if (currentSnapshot.role == "OWNER" || currentSnapshot.role == "ADMIN") bar.add(admin)
    bar.add(logout)
    root.add(bar, BorderLayout.SOUTH)
    screen(root, 1200, 820)
    gamePanel.requestFocusInWindow()
    if (connection != null) onSnapshot(connection.latest)
  }

  private def showInventory(): Unit = {
    if (connection == null) return
    val s = connection.latest
    val dialog = new JDialog(frame, "EGG BAG & PET COLLECTION", true)
    dialog.getContentPane.setBackground(panelBg)
    val root = new JPanel(new BorderLayout(8, 8)); root.setBackground(panelBg)
    val text = new JTextArea()
    text.setEditable(false); text.setBackground(new Color(16, 23, 41)); text.setForeground(white)
    text.setFont(new Font(fontName, Font.PLAIN, 14))
    val now = System.currentTimeMillis()
    val eggLines = if (s.eggs.isEmpty) "No eggs yet. Explore a biome and press E near an egg."
      else s.eggs.map(e => e.name + "  ?  " + e.rarity + "  ?  " +
        (if (now >= e.hatchAt) "READY TO HATCH" else "Hatches in " + ((e.hatchAt - now + 999) / 1000) + "s") +
        "  ? ID " + e.id).mkString("\n")
    val petLines = if (s.pets.isEmpty) "No pets hatched yet."
      else s.pets.map(p => p.name + "  ?  " + p.rarity + "  ?  +" + p.income + " coins/sec").mkString("\n")
    text.setText("YOUR EGGS\n===========\n" + eggLines + "\n\nYOUR PETS\n=========\n" + petLines +
      "\n\nPets automatically earn coins while you are connected.")
    root.add(new JScrollPane(text), BorderLayout.CENTER)
    val buttons = panel(new FlowLayout(FlowLayout.CENTER))
    s.eggs.filter(_.hatchAt <= now).foreach { egg =>
      val b = button("HATCH " + egg.name)
      b.addActionListener(_ => { connection.send("HATCH", egg.id); dialog.dispose() })
      buttons.add(b)
    }
    val close = button("CLOSE"); close.addActionListener(_ => dialog.dispose()); buttons.add(close)
    root.add(buttons, BorderLayout.SOUTH)
    dialog.setContentPane(root); dialog.setSize(620, 450); dialog.setLocationRelativeTo(frame); dialog.setVisible(true)
  }

  private def showShop(): Unit = {
    if (connection == null) return
    val s = connection.latest
    val message = "Coins: " + fmt(s.coins) + "\nSpeed: " + s.speed + "\nBase level: " + s.baseLevel +
      "\n\nTreadmill Speed Upgrade: 100 + 8 � current Speed coins (+20 Speed)." +
      "\nBase Upgrade: 250 � current Base Level coins." +
      "\n2x Server Boost: 500 coins for 60 seconds (shared server boost)."
    val choice = JOptionPane.showOptionDialog(frame, message, "EGG SHOP",
      JOptionPane.DEFAULT_OPTION, JOptionPane.PLAIN_MESSAGE, null,
      Array[Object]("UPGRADE SPEED", "UPGRADE BASE", "BUY 2x BOOST", "CANCEL"), "UPGRADE SPEED")
    choice match {
      case 0 => connection.send("UPGRADE_SPEED")
      case 1 => connection.send("UPGRADE_BASE")
      case 2 => connection.send("BUY_BOOST")
      case _ => ()
    }
  }

  private def showBiomes(): Unit = {
    val s = if (connection == null) currentSnapshot else connection.latest
    val body = biomes.map(b => {
      val unlocked = s.speed >= b.gate
      (if (unlocked) "? " else "? ") + b.name + "  ?  Required Speed: " + b.gate
    }).mkString("\n")
    showMessage("BIOME DISCOVERY\n\n" + body + "\n\nMove east to explore the next unlocked biome.", "BIOMES")
  }

  private def showAdminPanel(): Unit = {
    if (connection == null || (connection.latest.role != "OWNER" && connection.latest.role != "ADMIN")) {
      showError("You do not have admin permission."); return
    }
    val owner = connection.latest.role == "OWNER"
    val root = new JPanel(new GridLayout(0, 2, 8, 8)); root.setBorder(BorderFactory.createEmptyBorder(12, 12, 12, 12))
    root.setBackground(panelBg)
    val announce = new JTextField()
    val eventTitle = new JTextField("Double Coins Weekend")
    val eventText = new JTextField("Limited-time event is live!")
    val spawnBiome = new JComboBox[String](biomes.map(_.name).toArray)
    val spawnRarity = new JComboBox[String](Array("Common", "Uncommon", "Rare", "Epic", "Legendary", "Mythic", "Secret", "Celestial"))
    val grantUser = new JTextField(); val grantAmount = new JTextField("1000")
    val promoteUser = new JTextField(); val promoteRole = new JComboBox[String](Array("ADMIN", "PLAYER"))
    val boostMult = new JTextField("2"); val boostSeconds = new JTextField("120")
    val updateVersion = new JTextField("0.2.0"); val updateText = new JTextField("New biome and egg drop!")
    def addRow(name: String, component: Component): Unit = { root.add(label(name, 12, white)); root.add(component) }
    addRow("GLOBAL ANNOUNCEMENT", announce)
    addRow("EVENT TITLE", eventTitle); addRow("EVENT MESSAGE", eventText)
    addRow("SPAWN EGG BIOME", spawnBiome); addRow("SPAWN RARITY", spawnRarity)
    addRow("BOOST MULTIPLIER", boostMult); addRow("BOOST SECONDS", boostSeconds)
    addRow("UPDATE VERSION", updateVersion); addRow("UPDATE NOTES", updateText)
    if (owner) {
      addRow("PLAYER TO GRANT COINS", grantUser); addRow("COINS AMOUNT", grantAmount)
      addRow("PLAYER TO PROMOTE", promoteUser); addRow("ROLE", promoteRole)
    }
    val buttons = panel(new FlowLayout(FlowLayout.CENTER))
    val broadcast = button("SEND ANNOUNCEMENT")
    val event = button("START LIVE EVENT")
    val spawn = button("SPAWN EGG")
    val boost = button("ACTIVATE BOOST")
    val update = button("PUBLISH UPDATE NOTE")
    val close = button("CLOSE")
    broadcast.addActionListener(_ => connection.send("ANNOUNCE", announce.getText.trim))
    event.addActionListener(_ => connection.send("EVENT", eventTitle.getText.trim, eventText.getText.trim))
    spawn.addActionListener(_ => connection.send("SPAWN_EGG", spawnBiome.getSelectedItem.toString, spawnRarity.getSelectedItem.toString))
    boost.addActionListener(_ => connection.send("BOOST", boostMult.getText.trim, boostSeconds.getText.trim))
    update.addActionListener(_ => connection.send("UPDATE_NOTE", updateVersion.getText.trim, updateText.getText.trim))
    close.addActionListener(_ => SwingUtilities.getWindowAncestor(root).dispose())
    buttons.add(broadcast); buttons.add(event); buttons.add(spawn); buttons.add(boost); buttons.add(update)
    if (owner) {
      val grant = button("GRANT COINS")
      grant.addActionListener(_ => connection.send("GRANT_COINS", grantUser.getText.trim, grantAmount.getText.trim))
      val promote = button("CHANGE PLAYER ROLE")
      promote.addActionListener(_ => connection.send("PROMOTE", promoteUser.getText.trim, promoteRole.getSelectedItem.toString))
      buttons.add(grant); buttons.add(promote)
    }
    buttons.add(close)
    val wrapper = new JPanel(new BorderLayout(8, 8)); wrapper.setBackground(panelBg)
    wrapper.add(new JScrollPane(root), BorderLayout.CENTER); wrapper.add(buttons, BorderLayout.SOUTH)
    val dialog = new JDialog(frame, "YADNESH OWNER / ADMIN CONTROL PANEL", false)
    dialog.setContentPane(wrapper); dialog.setSize(760, 650); dialog.setLocationRelativeTo(frame); dialog.setVisible(true)
  }

  def main(args: Array[String]): Unit = SwingUtilities.invokeLater(new Runnable {
    override def run(): Unit = {
      UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName)
      frame = new JFrame("STEAL AN EGG - YADNESH EDITION")
      frame.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE)
      showLogin()
    }
  })
}

// Kojo script entry point. Start the local server from the login screen.
StealAnEggClient.main(Array.empty[String])